From 38179cf00dec32cf134e438fbcdf2694bac3c3c7 Mon Sep 17 00:00:00 2001 From: Scott Halver <76669202+scott-tp@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:58:58 -0400 Subject: [PATCH] Move Open WebUI into the card header and replace confirm-delete with REMOVE modal. Native confirm/prompt dialogs are unreliable in embedded browsers; the in-page modal types REMOVE, uses a quiet danger confirm, and keeps the card Remove button as a normal primary. --- app/static/css/dashboard.css | 77 +++++++++++++++- app/static/css/theme.css | 2 + app/static/js/components/device-card.js | 17 +++- app/static/js/dashboard-app.js | 114 ++++++++++++++++++++++-- app/templates/index.html | 2 +- tests/test_remove_device_ui.py | 36 ++++++++ 6 files changed, 232 insertions(+), 16 deletions(-) create mode 100644 tests/test_remove_device_ui.py diff --git a/app/static/css/dashboard.css b/app/static/css/dashboard.css index 6eb09b0..f67cc0a 100644 --- a/app/static/css/dashboard.css +++ b/app/static/css/dashboard.css @@ -99,6 +99,10 @@ summary { } .device-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); margin: calc(-1 * var(--space-4)); margin-bottom: var(--space-3); padding: var(--space-3) var(--space-4); @@ -107,6 +111,67 @@ summary { border-bottom: 1px solid rgba(255, 255, 255, 0.18); } +.device-card-header .device-card-title { + min-width: 0; +} + +.header-open-webui { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 0.4rem 1rem; + background: #fff; + color: var(--color-nav); + border: 1px solid #fff; + border-radius: var(--radius-md); + font-weight: 600; + text-decoration: none; +} + +.header-open-webui:hover, +.header-open-webui:focus-visible { + background: color-mix(in srgb, #fff 86%, var(--color-nav)); + border-color: color-mix(in srgb, #fff 86%, var(--color-nav)); + color: var(--color-nav); + text-decoration: none; +} + +button.button-danger-quiet { + background: transparent; + color: var(--color-danger); + border: 1px solid color-mix(in srgb, var(--color-danger) 55%, transparent); + font-weight: 500; +} + +button.button-danger-quiet:hover { + background: color-mix(in srgb, var(--color-danger) 14%, transparent); + color: var(--color-danger); + border-color: var(--color-danger); +} + +.delete-device-actions { + margin-top: var(--space-2); + align-items: center; + gap: var(--space-2); +} + +.delete-device-actions button { + margin-top: 0; + min-width: 0; + min-height: 2rem; + height: 2rem; + padding: 0 1rem; + line-height: 1; +} + +.delete-device-actions #delete-device-cancel { + background: transparent; + color: var(--color-text); + border: 1px solid color-mix(in srgb, var(--color-text) 28%, transparent); +} + .device-section > h3, .device-card-title { margin: 0; @@ -646,14 +711,22 @@ button:active { .modal-close { flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 1.75rem; + height: 1.75rem; margin: 0; - padding: 0.15rem 0.45rem; + padding: 0; line-height: 1; - font-size: 1.35rem; + font-size: 1.25rem; font-weight: 500; background: transparent; border: 1px solid color-mix(in srgb, var(--color-text) 25%, transparent); color: var(--color-muted); + /* × sits high in the em box; nudge for optical center. */ + transform: translateY(1px); } .modal-close:hover { diff --git a/app/static/css/theme.css b/app/static/css/theme.css index 5786a85..b6581f1 100644 --- a/app/static/css/theme.css +++ b/app/static/css/theme.css @@ -12,6 +12,7 @@ body { --color-muted: #475569; --color-accent: #0e6b86; --color-accent-hover: #0b5a71; + --color-danger: hsl(5, 57%, 44%); --color-nav: hsl(230, 33%, 33%); --color-nav-text: #ffffff; --space-1: 4px; @@ -30,6 +31,7 @@ body { --color-muted: hsl(220, 18%, 76%); --color-accent: hsl(205, 65%, 52%); --color-accent-hover: hsl(205, 72%, 60%); + --color-danger: hsl(5, 57%, 58%); --color-nav: hsl(230, 33%, 30%); --color-nav-text: #ffffff; } diff --git a/app/static/js/components/device-card.js b/app/static/js/components/device-card.js index 4e87d7e..6fb7270 100644 --- a/app/static/js/components/device-card.js +++ b/app/static/js/components/device-card.js @@ -32,6 +32,17 @@ function deactivateScreenshotLink(link) { link.setAttribute('aria-disabled', 'true'); } +function renderHeaderOpenWebUi(device) { + const url = String(device.base_url || '').trim(); + if (!url) { + return ''; + } + return ( + `Open WebUI` + ); +} + class DeviceCard extends HTMLElement { constructor() { super(); @@ -86,6 +97,7 @@ class DeviceCard extends HTMLElement {

${escapeHtml(device.friendly_name)}

+ ${renderHeaderOpenWebUi(device)}
@@ -133,12 +145,9 @@ class DeviceCard extends HTMLElement {

TinyPilot device

-

- Launch WebUI ↗ -

- +
Retrieving device snapshot... diff --git a/app/static/js/dashboard-app.js b/app/static/js/dashboard-app.js index 6ccb9f2..b2045e6 100644 --- a/app/static/js/dashboard-app.js +++ b/app/static/js/dashboard-app.js @@ -4,6 +4,7 @@ import { formatRelativeTime } from './lib/strings.js'; const ADD_DEVICE_TOGGLE_LABEL = 'Add a device'; const ADD_DEVICE_MODAL_LABEL = 'Add a TinyPilot device'; const ADD_DEVICE_SUBMIT_LABEL = 'Add device'; +const DELETE_DEVICE_MODAL_LABEL = 'Remove device'; const DEVICES_PER_PAGE = 4; const CONNECTED_STATUS_REFRESH_INTERVAL_MS = 30_000; @@ -13,6 +14,7 @@ class DashboardApp extends HTMLElement { this._elements = {}; this._allDevices = []; this._currentPage = 1; + this._pendingDeleteDeviceId = null; this._screenshotAutoRefreshTimers = new Map(); this._screenshotCapturedAtByDevice = new Map(); } @@ -62,6 +64,27 @@ class DashboardApp extends HTMLElement {

+ `; this._elements = { @@ -75,6 +98,12 @@ class DashboardApp extends HTMLElement { addDeviceModalBackdrop: this.shadowRoot.getElementById('add-device-modal-backdrop'), addDeviceForm: this.shadowRoot.getElementById('add-device-form'), addDeviceStatus: this.shadowRoot.getElementById('add-device-status'), + deleteDeviceModal: this.shadowRoot.getElementById('delete-device-modal'), + deleteDeviceModalClose: this.shadowRoot.getElementById('delete-device-modal-close'), + deleteDeviceModalBackdrop: this.shadowRoot.getElementById('delete-device-modal-backdrop'), + deleteDeviceForm: this.shadowRoot.getElementById('delete-device-form'), + deleteDeviceCancel: this.shadowRoot.getElementById('delete-device-cancel'), + deleteDeviceStatus: this.shadowRoot.getElementById('delete-device-status'), }; this._bind(); @@ -94,11 +123,19 @@ class DashboardApp extends HTMLElement { }); e.addDeviceModalClose.addEventListener('click', () => this._closeAddDeviceModal()); e.addDeviceModalBackdrop.addEventListener('click', () => this._closeAddDeviceModal()); + e.deleteDeviceModalClose.addEventListener('click', () => this._closeDeleteDeviceModal()); + e.deleteDeviceModalBackdrop.addEventListener('click', () => this._closeDeleteDeviceModal()); + e.deleteDeviceCancel.addEventListener('click', () => this._closeDeleteDeviceModal()); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') { return; } + if (e.deleteDeviceModal && !e.deleteDeviceModal.hasAttribute('hidden')) { + this._closeDeleteDeviceModal(); + event.preventDefault(); + return; + } if (e.addDeviceModal && !e.addDeviceModal.hasAttribute('hidden')) { this._closeAddDeviceModal(); event.preventDefault(); @@ -107,6 +144,7 @@ class DashboardApp extends HTMLElement { this._initThemeToggle(); this._bindAddDeviceForm(); + this._bindDeleteDeviceForm(); e.deviceSections.addEventListener('click', (event) => { void this._onDeviceSectionClick(event); }); @@ -173,6 +211,34 @@ class DashboardApp extends HTMLElement { document.body.style.overflow = ''; } + _openDeleteDeviceModal(deviceId) { + const e = this._elements; + this._pendingDeleteDeviceId = String(deviceId); + e.deleteDeviceForm?.reset(); + if (e.deleteDeviceStatus) { + e.deleteDeviceStatus.textContent = ''; + } + e.deleteDeviceModal.removeAttribute('hidden'); + document.body.style.overflow = 'hidden'; + window.requestAnimationFrame(() => { + e.deleteDeviceForm?.querySelector('input[name="confirm"]')?.focus(); + }); + } + + _closeDeleteDeviceModal() { + const { deleteDeviceModal } = this._elements; + if (!deleteDeviceModal || deleteDeviceModal.hasAttribute('hidden')) { + return; + } + deleteDeviceModal.setAttribute('hidden', ''); + this._pendingDeleteDeviceId = null; + this._elements.deleteDeviceForm?.reset(); + if (this._elements.deleteDeviceStatus) { + this._elements.deleteDeviceStatus.textContent = ''; + } + document.body.style.overflow = ''; + } + _bindAddDeviceForm() { const { addDeviceForm, addDeviceStatus } = this._elements; if (!addDeviceForm) { @@ -203,6 +269,40 @@ class DashboardApp extends HTMLElement { }); } + _bindDeleteDeviceForm() { + const { deleteDeviceForm, deleteDeviceStatus } = this._elements; + if (!deleteDeviceForm) { + return; + } + deleteDeviceForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const deviceId = this._pendingDeleteDeviceId; + if (!deviceId) { + return; + } + const typed = String( + new FormData(deleteDeviceForm).get('confirm') || '', + ).trim(); + if (typed !== 'REMOVE') { + if (deleteDeviceStatus) { + deleteDeviceStatus.textContent = 'Type REMOVE exactly to confirm.'; + } + return; + } + const result = await window.dashboardApi.deleteJson( + `/api/devices/${deviceId}`, + ); + if (result.error) { + if (deleteDeviceStatus) { + deleteDeviceStatus.textContent = `Could not remove device: ${result.error}`; + } + return; + } + this._closeDeleteDeviceModal(); + await this._refreshDeviceList(); + }); + } + _totalPages() { return Math.max(1, Math.ceil(this._allDevices.length / DEVICES_PER_PAGE)); } @@ -223,6 +323,11 @@ class DashboardApp extends HTMLElement { if (!deviceId) { return; } + if (action === 'delete-device') { + // In-page modal: native confirm/prompt dialogs are suppressed in some browsers. + this._openDeleteDeviceModal(deviceId); + return; + } const card = this._deviceCard(deviceId); if (!card) { return; @@ -253,15 +358,6 @@ class DashboardApp extends HTMLElement { } return; } - if (action === 'delete-device') { - const confirmed = window.confirm('Delete this device from the dashboard?'); - if (!confirmed) { - return; - } - await window.dashboardApi.deleteJson(`/api/devices/${deviceId}`); - await this._refreshDeviceList(); - return; - } if (action === 'fetch-device-snapshot') { await card.refreshSnapshot(); return; diff --git a/app/templates/index.html b/app/templates/index.html index c61ca98..9f8ae66 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,7 +1,7 @@ - {% set asset_v = '20260917-02' %} + {% set asset_v = '20261008-remove-open-webui' %} TinyPilot Dashboard diff --git a/tests/test_remove_device_ui.py b/tests/test_remove_device_ui.py new file mode 100644 index 0000000..afa3522 --- /dev/null +++ b/tests/test_remove_device_ui.py @@ -0,0 +1,36 @@ +"""Static checks for Remove device modal + Open WebUI header.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / 'app/static/js/dashboard-app.js' +CARD = ROOT / 'app/static/js/components/device-card.js' +THEME = ROOT / 'app/static/css/theme.css' + + +def test_remove_device_uses_in_page_modal(): + source = APP.read_text(encoding='utf-8') + assert '_openDeleteDeviceModal' in source + assert 'delete-device-modal' in source + assert "typed !== 'REMOVE'" in source + assert 'window.confirm(' not in source + assert 'window.prompt(' not in source + assert 'button-danger-quiet' in source + + +def test_device_card_open_webui_header_and_remove_label(): + source = CARD.read_text(encoding='utf-8') + assert 'function renderHeaderOpenWebUi' in source + assert 'class="header-open-webui"' in source + assert '>Open WebUI' in source + assert 'Launch WebUI' not in source + assert '>Remove device' in source + assert 'Delete device' not in source + header = source.split('class="device-card-header"', 1)[1] + header = header.split('', 1)[0] + assert 'renderHeaderOpenWebUi(device)' in header + + +def test_theme_defines_danger_color(): + source = THEME.read_text(encoding='utf-8') + assert '--color-danger:' in source