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 1/7] 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 From 5d8c218b1de624b73914fb7d7473166964f7901a Mon Sep 17 00:00:00 2001 From: Scott Halver <76669202+scott-tp@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:58:23 -0400 Subject: [PATCH 2/7] Release v0.3.0: fail-fast unreachable snapshots and customer-safe errors. Unreachable devices were hanging on sequential TinyPilot calls and showing raw connection-pool text. Stop after the first dead probe, hide Expanded until the device is reachable, and map failures to customer-safe copy. --- app/__init__.py | 4 +- app/api.py | 117 ++++++--- app/static/css/dashboard.css | 117 ++++++--- app/static/css/theme.css | 2 + app/static/js/api.js | 15 +- app/static/js/components/device-card.js | 309 ++++++++++++++---------- app/static/js/dashboard-app.js | 55 ++--- app/static/js/lib/snapshot-view.js | 44 +++- app/templates/index.html | 4 +- app/tinypilot_client.py | 25 +- tests/conftest.py | 7 +- tests/test_api_connections.py | 88 +++++++ tests/test_remove_device_ui.py | 103 +++++++- tests/test_tinypilot_client.py | 4 + 14 files changed, 627 insertions(+), 267 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 0661c38..d31bbc4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,11 +10,11 @@ from app.db import close_db, init_db # Public product version. Version bands signal lifecycle stage to customers: -# 0.1.x / 0.2.x -> alpha +# 0.1.x / 0.2.x / 0.3.x -> alpha # 0.5.x -> beta # 1.0.0 -> first stable release # Bump this on every customer-visible release. -__version__ = '0.2.1' +__version__ = '0.3.0' def create_app(test_config=None): diff --git a/app/api.py b/app/api.py index a5652bd..7b95482 100644 --- a/app/api.py +++ b/app/api.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import Optional +import requests from flask import Blueprint from flask import current_app from flask import jsonify @@ -224,7 +225,9 @@ def refresh_screenshot(device_id: int): try: screenshot = client.get_screenshot() except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to refresh screenshot: {err}'}), 502 + return jsonify( + {'error': f'failed to refresh screenshot: {_customer_error(err)}'} + ), 502 db = get_db() data_root = Path(current_app.config['DATABASE_PATH']).resolve().parent @@ -324,7 +327,25 @@ def _safe_fetch(fetcher): try: return fetcher(), None except Exception as err: # pylint: disable=broad-exception-caught - return None, str(err) + return None, err + + +def _is_unreachable(err) -> bool: + return isinstance(err, (requests.Timeout, requests.ConnectionError)) + + +def _customer_error(err): + if err is None: + return None + current_app.logger.warning('TinyPilot request failed: %s', err) + if _is_unreachable(err): + return 'Could not reach this TinyPilot.' + if isinstance(err, requests.HTTPError): + status = err.response.status_code if err.response is not None else None + if status: + return f'TinyPilot returned an error (HTTP {status}).' + return 'TinyPilot returned an error.' + return 'Could not complete this request.' @api_blueprint.get('/devices//device/snapshot') @@ -360,32 +381,48 @@ def get_device_snapshot(device_id: int): client = TinyPilotClient(row['base_url'], api_key=api_key) # API-key allowlist (Pro 3.2.0+): version, network, video, /state, screenshot, - # latestRelease, update status/start. - status, status_error = _safe_fetch(client.get_status) - version, version_error = _safe_fetch(client.get_version) - network, network_error = _safe_fetch(client.get_network_status) - video, video_error = _safe_fetch(client.get_video_settings) - automation_state, automation_state_error = _safe_fetch(client.get_automation_state) - latest_release, latest_release_error = _safe_fetch(client.get_latest_release) - update_job, update_job_error = _safe_fetch(client.get_update_status) - - last_error = ( - status_error - or version_error - or network_error - or video_error - or automation_state_error + # latestRelease, update status/start. Stop after the first unreachable call. + fetched = { + 'status': (None, None), + 'version': (None, None), + 'network': (None, None), + 'video': (None, None), + 'automation_state': (None, None), + 'latest_release': (None, None), + 'update_job': (None, None), + } + fetchers = ( + ('status', client.get_status), + ('version', client.get_version), + ('network', client.get_network_status), + ('video', client.get_video_settings), + ('automation_state', client.get_automation_state), + ('latest_release', client.get_latest_release), + ('update_job', client.get_update_status), ) - - online = any( - error is None - for error in ( - status_error, - version_error, - network_error, - video_error, - automation_state_error, - ) + for name, fetcher in fetchers: + value, err = _safe_fetch(fetcher) + fetched[name] = (value, err) + if _is_unreachable(err): + break + + status, status_error = fetched['status'] + version, version_error = fetched['version'] + network, network_error = fetched['network'] + video, video_error = fetched['video'] + automation_state, automation_state_error = fetched['automation_state'] + latest_release, latest_release_error = fetched['latest_release'] + update_job, update_job_error = fetched['update_job'] + + probe_errors = ( + status_error, + version_error, + network_error, + video_error, + automation_state_error, + ) + online = any(error is None for error in probe_errors) and not any( + _is_unreachable(error) for error in probe_errors ) connected_resolution = ( @@ -407,18 +444,20 @@ def get_device_snapshot(device_id: int): } expanded = { - 'reachability': {'status': status, 'error': status_error}, - 'version': {'status': version, 'error': version_error}, - 'network': {'data': network or {}, 'error': network_error}, - 'video_settings': {'status': video, 'error': video_error}, - 'automation_state': {'status': automation_state, 'error': automation_state_error}, + 'reachability': {'status': status, 'error': _customer_error(status_error)}, + 'version': {'status': version, 'error': _customer_error(version_error)}, + 'network': {'data': network or {}, 'error': _customer_error(network_error)}, + 'video_settings': {'status': video, 'error': _customer_error(video_error)}, + 'automation_state': { + 'status': automation_state, + 'error': _customer_error(automation_state_error), + }, 'connected_device_resolution': connected_resolution, - 'last_management_error': last_error, 'software_update': { 'latest': latest_release, - 'latest_error': latest_release_error, + 'latest_error': _customer_error(latest_release_error), 'job': update_job, - 'job_error': update_job_error, + 'job_error': _customer_error(update_job_error), 'update_available': update_available(current_version, latest_version), 'can_start': can_start_update( current_version, latest_version, license_check_status @@ -444,7 +483,9 @@ def get_device_update_status(device_id: int): try: result = client.get_update_status() except Exception as exc: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to fetch update status: {exc}'}), 502 + return jsonify( + {'error': f'failed to fetch update status: {_customer_error(exc)}'} + ), 502 return jsonify(result) @@ -460,5 +501,7 @@ def start_device_update(device_id: int): try: client.start_update(version) except Exception as exc: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to start update: {exc}'}), 502 + return jsonify( + {'error': f'failed to start update: {_customer_error(exc)}'} + ), 502 return jsonify({'device_id': device_id, 'update_started': True, 'version': version}) diff --git a/app/static/css/dashboard.css b/app/static/css/dashboard.css index f67cc0a..5568d4a 100644 --- a/app/static/css/dashboard.css +++ b/app/static/css/dashboard.css @@ -234,10 +234,19 @@ button.button-danger-quiet:hover { margin: 0; } -.connected-system-body { +.connected-system-body, +.tiny-device-body { margin-top: var(--space-3); } +.tiny-device-header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); + min-width: 0; +} + .connected-screenshot-link { position: relative; display: block; @@ -278,6 +287,10 @@ button.button-danger-quiet:hover { background: transparent; } +.connected-screenshot.connected-screenshot--cached { + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--color-warning) 70%, transparent); +} + .launch-link { color: var(--color-accent); text-decoration: none; @@ -298,6 +311,17 @@ button.button-danger-quiet:hover { color: var(--color-muted); } +.connected-system .subtitle { + margin: var(--space-2) 0 0; + font-size: 0.9rem; + font-weight: 400; + line-height: 1.35; +} + +.subtitle.connected-status--caution { + color: var(--color-warning); +} + .actions { display: flex; gap: var(--space-2); @@ -400,19 +424,32 @@ input { } button { - background: hsl(230, 16%, 55%) linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0) 60%); - color: #fff; - border: 1px solid hsl(230, 18%, 45%); + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: max-content; + background: var(--color-nav); + color: var(--color-nav-text); + border: 1px solid rgba(255, 255, 255, 0.45); border-radius: var(--radius-md); - padding: 0.4rem 1rem; + padding: 0.5rem 1.25rem 0.4rem; + margin: 0; cursor: pointer; + font-family: inherit; font-size: 1rem; font-weight: 500; - transition: background 250ms ease-in-out, transform 150ms ease; + line-height: 1; + text-align: center; + appearance: none; + -webkit-appearance: none; + transition: + background 250ms ease-in-out, + transform 150ms ease; } button:hover { - background-color: hsl(230, 18%, 65%); + background: color-mix(in srgb, var(--color-nav) 88%, #fff); } button:disabled { @@ -513,13 +550,34 @@ button:active { font-size: 0.9rem; } -.theme-toggle { +.theme-toggle, +button.theme-toggle { margin: 0; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: 1.75rem; + min-height: 1.75rem; + padding: 0 0.85rem; + font-family: inherit; font-size: 0.85rem; font-weight: 500; - padding: 0.2rem 0.6rem; + line-height: 1; background: transparent; border: 1px solid rgba(255, 255, 255, 0.45); + border-radius: var(--radius-md); + color: inherit; + text-decoration: none; + cursor: pointer; + appearance: none; + -webkit-appearance: none; +} + +a.theme-toggle:hover, +a.theme-toggle:visited { + color: inherit; + text-decoration: none; } .theme-toggle:hover { @@ -533,6 +591,10 @@ button:active { margin-bottom: 4px; } +.status-copy { + min-width: 0; +} + .status-dot { width: 0.5625rem; height: 0.5625rem; @@ -556,8 +618,7 @@ button:active { line-height: 1; } -.status-meta, -.status-url { +.status-meta { color: var(--color-muted); } @@ -610,6 +671,11 @@ button:active { } .snapshot-row .snapshot-row__value { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + flex-wrap: wrap; flex: 1 1 auto; min-width: 0; font-weight: 600; @@ -773,8 +839,7 @@ button:active { } :root[data-theme="dark"] .snapshot-row > span:first-child, -:root[data-theme="dark"] .status-meta, -:root[data-theme="dark"] .status-url { +:root[data-theme="dark"] .status-meta { color: hsl(220, 20%, 82%); } @@ -785,28 +850,6 @@ button:active { } } -.device-update-banner { - margin-top: var(--space-3); - padding: var(--space-3); - border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent); - border-radius: 6px; - background: color-mix(in srgb, var(--color-accent) 8%, transparent); -} - -.device-update-banner__text { - margin: 0; - font-size: 0.9rem; - line-height: 1.4; -} - -.device-update-banner__actions { - display: flex; - justify-content: flex-end; - margin-top: var(--space-2); -} - -.device-update-banner__error { - display: block; - margin-top: var(--space-1); - color: var(--color-danger, #b42318); +.snapshot-row__value button { + flex-shrink: 0; } diff --git a/app/static/css/theme.css b/app/static/css/theme.css index b6581f1..4d0c409 100644 --- a/app/static/css/theme.css +++ b/app/static/css/theme.css @@ -13,6 +13,7 @@ body { --color-accent: #0e6b86; --color-accent-hover: #0b5a71; --color-danger: hsl(5, 57%, 44%); + --color-warning: hsl(42, 96%, 36%); --color-nav: hsl(230, 33%, 33%); --color-nav-text: #ffffff; --space-1: 4px; @@ -32,6 +33,7 @@ body { --color-accent: hsl(205, 65%, 52%); --color-accent-hover: hsl(205, 72%, 60%); --color-danger: hsl(5, 57%, 58%); + --color-warning: hsl(42, 96%, 62%); --color-nav: hsl(230, 33%, 30%); --color-nav-text: #ffffff; } diff --git a/app/static/js/api.js b/app/static/js/api.js index 7e2f3a8..f4bf61c 100644 --- a/app/static/js/api.js +++ b/app/static/js/api.js @@ -14,17 +14,22 @@ async function fetchJson(path, { method = 'GET', body } = {}) { method, credentials: 'same-origin', headers: {}, + signal: AbortSignal.timeout(30_000), }; if (body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } - const response = await fetch(path, init); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - return { error: httpErrorMessage(data, response) }; + try { + const response = await fetch(path, init); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + return { error: httpErrorMessage(data, response) }; + } + return data; + } catch { + return { error: 'Could not reach the dashboard.' }; } - return data; } async function postJson(path, payload = {}) { diff --git a/app/static/js/components/device-card.js b/app/static/js/components/device-card.js index 6fb7270..606f7d9 100644 --- a/app/static/js/components/device-card.js +++ b/app/static/js/components/device-card.js @@ -2,24 +2,22 @@ import { escapeHtml, formatRelativeTime } from '../lib/strings.js'; import { formatExpandedSnapshot } from '../lib/snapshot-view.js'; import { latestVersionFrom, - licenseReason, - licenseStatusFrom, updateBannerKind, } from '../lib/device-update.js'; const AUTOMATION_LICENSE_INFO_URL = 'https://tinypilotkvm.com/pages/automation'; const UPDATE_POLL_INTERVAL_MS = 3000; +const UPDATE_POLL_MAX_ERRORS = 10; function formatCollapsedSnapshotSummary(c) { const checkedAt = c.last_checked || ''; const checkedText = formatRelativeTime(checkedAt); + const label = c.online ? 'Connected' : 'Could not reach this TinyPilot'; return `
- ${c.online ? 'Connected' : 'Disconnected'} - v${escapeHtml(c.software_version || 'unknown')} · Last checked ${escapeHtml(checkedText)} + ${label}, last checked ${escapeHtml(checkedText)}.
-
${escapeHtml(c.device_url || '')}
`; } @@ -32,8 +30,45 @@ function deactivateScreenshotLink(link) { link.setAttribute('aria-disabled', 'true'); } +function activateScreenshotLink(link) { + if (!link) { + return; + } + link.classList.add('connected-screenshot-link--available'); + link.setAttribute('tabindex', '0'); + link.removeAttribute('aria-disabled'); +} + +function deviceLaunchHref(device) { + return String(device.base_url || '').trim(); +} + +function cachedPreviewCopy(capturedAt) { + return `Cached preview from ${formatRelativeTime(capturedAt)}.`; +} + +function missingPreviewCopy() { + return 'Could not capture a screenshot.'; +} + +function setPreviewStatus(el, text, kind) { + if (!el) { + return; + } + el.textContent = text; + el.classList.toggle( + 'connected-status--caution', + kind === 'cached' || kind === 'error', + ); + if (kind) { + el.dataset.previewKind = kind; + } else { + delete el.dataset.previewKind; + } +} + function renderHeaderOpenWebUi(device) { - const url = String(device.base_url || '').trim(); + const url = deviceLaunchHref(device); if (!url) { return ''; } @@ -82,7 +117,7 @@ class DeviceCard extends HTMLElement { summaryOutput: this.querySelector(`#device-collapsed-summary-${id}`), metricsOutput: this.querySelector(`#device-metrics-output-${id}`), intervalInput: this.querySelector(`#screenshot-interval-${id}`), - updateBanner: this.querySelector(`#device-update-banner-${id}`), + expandedDetails: this.querySelector(`#device-details-${id}`), }; } @@ -92,6 +127,7 @@ class DeviceCard extends HTMLElement { return; } const id = device.id; + const launchHref = deviceLaunchHref(device); this.className = 'device-wrapper'; this.innerHTML = `
@@ -110,12 +146,12 @@ class DeviceCard extends HTMLElement {
-

TinyPilot device

-
- - +
+

TinyPilot device

-
- Retrieving device snapshot... +
+
+ + +
+
+ Retrieving device snapshot... +
+
- -
- Expanded TinyPilot device info -
Retrieving expanded device info...
-
`; this._cacheElements(); + this._primeCachedScreenshot(); + } + + _primeCachedScreenshot() { + const device = this._device; + if (!device?.latest_screenshot_captured_at) { + return; + } + const { screenshot, link, connectedStatus } = this._elements; + if (!screenshot) { + return; + } + const capturedAt = device.latest_screenshot_captured_at; + const busted = `/api/devices/${device.id}/latest-screenshot?t=${encodeURIComponent(capturedAt)}`; + screenshot.src = busted; + screenshot.classList.add('connected-screenshot--cached'); + if (deviceLaunchHref(device)) { + activateScreenshotLink(link); + } + setPreviewStatus(connectedStatus, cachedPreviewCopy(capturedAt), 'cached'); } async refreshScreenshot(capturedAtByDevice) { @@ -175,30 +234,41 @@ class DeviceCard extends HTMLElement { deactivateScreenshotLink(link); const result = await window.dashboardApi.postJson(`/api/devices/${id}/refresh-screenshot`); - - if (connectedStatus) { - if (result.error) { - connectedStatus.textContent = `Screenshot failed: ${result.error}`; - } else { - capturedAtByDevice.set(String(id), result.captured_at || new Date().toISOString()); - connectedStatus.textContent = `Screenshot refreshed ${formatRelativeTime(result.captured_at)}.`; + const cachedAt = + capturedAtByDevice.get(String(id)) || + this._device.latest_screenshot_captured_at; + const enableLaunch = () => { + if (deviceLaunchHref(this._device)) { + activateScreenshotLink(link); } - } + }; if (result.error || !screenshot) { + if (cachedAt) { + screenshot?.classList.add('connected-screenshot--cached'); + enableLaunch(); + setPreviewStatus( + connectedStatus, + cachedPreviewCopy(cachedAt), + 'cached', + ); + } else { + setPreviewStatus(connectedStatus, missingPreviewCopy(), 'error'); + } return; } + capturedAtByDevice.set(String(id), result.captured_at || new Date().toISOString()); const busted = `/api/devices/${id}/latest-screenshot?t=${Date.now()}`; - const activateScreenshotLink = () => { - if (!link) { - return; - } - link.classList.add('connected-screenshot-link--available'); - link.setAttribute('tabindex', '0'); - link.removeAttribute('aria-disabled'); - link.href = busted; + const markLive = () => { + screenshot.classList.remove('connected-screenshot--cached'); + enableLaunch(); + setPreviewStatus( + connectedStatus, + `Live preview, updated ${formatRelativeTime(result.captured_at)}.`, + 'live', + ); }; // Pre-load into an off-screen Image. Only swap the displayed src @@ -206,24 +276,41 @@ class DeviceCard extends HTMLElement { const preload = new Image(); preload.onload = () => { screenshot.src = busted; - activateScreenshotLink(); - if (connectedStatus) { - connectedStatus.textContent = `Screenshot refreshed ${formatRelativeTime(result.captured_at)}.`; - } + markLive(); }; preload.onerror = () => { - // Keep the current screenshot; show a status note. - if (connectedStatus) { - connectedStatus.textContent = 'Screenshot failed to load (image error).'; + if (cachedAt) { + screenshot.classList.add('connected-screenshot--cached'); + enableLaunch(); + setPreviewStatus( + connectedStatus, + cachedPreviewCopy(cachedAt), + 'cached', + ); + } else { + setPreviewStatus(connectedStatus, missingPreviewCopy(), 'error'); + deactivateScreenshotLink(link); } - deactivateScreenshotLink(link); }; preload.src = busted; // If already cached, onload may have fired synchronously — handle that case. if (preload.complete && preload.naturalWidth > 0 && screenshot.src !== busted) { screenshot.src = busted; - activateScreenshotLink(); + markLive(); + } + } + + _setExpandedVisible(visible) { + const { expandedDetails, metricsOutput } = this._elements; + if (expandedDetails) { + expandedDetails.hidden = !visible; + if (!visible) { + expandedDetails.open = false; + } + } + if (!visible && metricsOutput) { + metricsOutput.innerHTML = ''; } } @@ -234,6 +321,7 @@ class DeviceCard extends HTMLElement { const id = this._device.id; const { summaryOutput, metricsOutput } = this._elements; const snapshot = await window.dashboardApi.getJson(`/api/devices/${id}/device/snapshot`); + const online = Boolean(snapshot.collapsed?.online); if (summaryOutput) { if (snapshot.error) { @@ -242,115 +330,82 @@ class DeviceCard extends HTMLElement { summaryOutput.innerHTML = formatCollapsedSnapshotSummary(snapshot.collapsed); } } - if (metricsOutput) { - if (snapshot.error) { - metricsOutput.textContent = snapshot.error; - } else { - metricsOutput.innerHTML = formatExpandedSnapshot(snapshot); - } - } - if (!snapshot.error) { - this._renderUpdateBanner(snapshot); + if (snapshot.error || !online) { + this._setExpandedVisible(false); + this._stopUpdatePoll(); + } else if (metricsOutput) { + this._setExpandedVisible(true); + metricsOutput.innerHTML = formatExpandedSnapshot(snapshot); + this._syncSoftwareUpdate(snapshot); } } _stopUpdatePoll() { if (this._updatePollTimer) { - window.clearInterval(this._updatePollTimer); + window.clearTimeout(this._updatePollTimer); this._updatePollTimer = null; } } - _renderUpdateBanner(snapshot) { - const banner = this._elements.updateBanner; - if (!banner || !this._device) { - return; - } - const id = this._device.id; + _syncSoftwareUpdate(snapshot) { const currentVersion = snapshot.collapsed?.software_version || ''; const softwareUpdate = snapshot.expanded?.software_update || null; const kind = updateBannerKind(softwareUpdate, currentVersion); - const latestVersion = latestVersionFrom(softwareUpdate); - const licenseStatus = licenseStatusFrom(softwareUpdate); - const jobError = softwareUpdate?.job?.updateError; - - if (!kind || kind === 'hidden') { - banner.hidden = true; - banner.innerHTML = ''; - this._stopUpdatePoll(); - return; + if (kind === 'available') { + this._desiredUpdateVersion = latestVersionFrom(softwareUpdate); } - - banner.hidden = false; if (kind === 'updating') { - banner.innerHTML = ` -

- Updating TinyPilot${latestVersion ? ` to ${escapeHtml(latestVersion)}` : ''}… - ${jobError ? `${escapeHtml(jobError)}` : ''} -

- `; this._startUpdatePoll(); - return; - } - if (kind === 'error') { - banner.innerHTML = ` -

- Could not check for updates: - ${escapeHtml(softwareUpdate.latest_error || 'unknown error')} -

- `; + } else { this._stopUpdatePoll(); - return; } - if (kind === 'license-blocked') { - banner.innerHTML = ` -

- Update available (${escapeHtml(currentVersion)} → ${escapeHtml(latestVersion)}), - but ${escapeHtml(licenseReason(licenseStatus))}. - Attach license in WebUI ↗ -

- `; - this._stopUpdatePoll(); + } + + _setSoftwareVersionValue(html) { + const { metricsOutput } = this._elements; + if (!metricsOutput) { return; } - // available - this._desiredUpdateVersion = latestVersion; - banner.innerHTML = ` -

- Update available: ${escapeHtml(currentVersion)} → ${escapeHtml(latestVersion)} -

-
- -
- `; - this._stopUpdatePoll(); + for (const row of metricsOutput.querySelectorAll('.snapshot-row')) { + const label = row.querySelector('span'); + if (label && label.textContent === 'Software version') { + const value = row.querySelector('.snapshot-row__value'); + if (value) { + value.innerHTML = html; + } + } + } } _startUpdatePoll() { if (this._updatePollTimer || !this._device) { return; } + this._updatePollErrors = 0; const id = this._device.id; - this._updatePollTimer = window.setInterval(async () => { + const tick = async () => { const status = await window.dashboardApi.getJson(`/api/devices/${id}/device/update`); if (status.error) { + this._updatePollErrors += 1; + if (this._updatePollErrors >= UPDATE_POLL_MAX_ERRORS) { + this._stopUpdatePoll(); + this._setSoftwareVersionValue('Lost contact during update. Refresh to check.'); + return; + } + this._updatePollTimer = window.setTimeout(tick, UPDATE_POLL_INTERVAL_MS); return; } + this._updatePollErrors = 0; if (status.status === 'IN_PROGRESS') { - const banner = this._elements.updateBanner; - if (banner && !banner.hidden) { - const err = status.updateError - ? `${escapeHtml(status.updateError)}` - : ''; - banner.innerHTML = ` -

Updating TinyPilot… ${err}

- `; - } + const err = status.updateError ? ` ${escapeHtml(status.updateError)}` : ''; + this._setSoftwareVersionValue(`Updating TinyPilot…${err}`); + this._updatePollTimer = window.setTimeout(tick, UPDATE_POLL_INTERVAL_MS); return; } this._stopUpdatePoll(); await this.refreshSnapshot(); - }, UPDATE_POLL_INTERVAL_MS); + }; + this._updatePollTimer = window.setTimeout(tick, UPDATE_POLL_INTERVAL_MS); } async startUpdate() { @@ -358,24 +413,16 @@ class DeviceCard extends HTMLElement { return; } const id = this._device.id; - const banner = this._elements.updateBanner; - if (banner) { - banner.hidden = false; - banner.innerHTML = `

Starting update…

`; - } const result = await window.dashboardApi.putJson(`/api/devices/${id}/device/update`, { version: this._desiredUpdateVersion, }); if (result.error) { - if (banner) { - banner.innerHTML = ` -

- ${escapeHtml(result.error)} -

- `; - } + this._setSoftwareVersionValue( + `${escapeHtml(this._desiredUpdateVersion)} ${escapeHtml(result.error)}`, + ); return; } + this._setSoftwareVersionValue('Updating TinyPilot…'); this._startUpdatePoll(); } diff --git a/app/static/js/dashboard-app.js b/app/static/js/dashboard-app.js index b2045e6..471ee17 100644 --- a/app/static/js/dashboard-app.js +++ b/app/static/js/dashboard-app.js @@ -32,7 +32,6 @@ class DashboardApp extends HTMLElement {
-
@@ -91,7 +90,6 @@ class DashboardApp extends HTMLElement { deviceSections: this.shadowRoot.getElementById('device-sections'), devicePagination: this.shadowRoot.getElementById('device-pagination'), addDeviceToggle: this.shadowRoot.getElementById('add-device-toggle'), - themeToggle: this.shadowRoot.getElementById('theme-toggle'), addDeviceModal: this.shadowRoot.getElementById('add-device-modal'), addDeviceHeading: this.shadowRoot.getElementById('add-device-heading'), addDeviceModalClose: this.shadowRoot.getElementById('add-device-modal-close'), @@ -142,7 +140,6 @@ class DashboardApp extends HTMLElement { } }); - this._initThemeToggle(); this._bindAddDeviceForm(); this._bindDeleteDeviceForm(); e.deviceSections.addEventListener('click', (event) => { @@ -153,26 +150,6 @@ class DashboardApp extends HTMLElement { }); } - _initThemeToggle() { - const root = document.documentElement; - const button = this._elements.themeToggle; - const savedTheme = window.localStorage.getItem('dashboard-theme'); - if (savedTheme === 'dark' || savedTheme === 'light') { - root.dataset.theme = savedTheme; - } - this._updateThemeToggleText(button, root.dataset.theme === 'dark'); - button.addEventListener('click', () => { - const isDark = root.dataset.theme === 'dark'; - root.dataset.theme = isDark ? 'light' : 'dark'; - window.localStorage.setItem('dashboard-theme', root.dataset.theme); - this._updateThemeToggleText(button, !isDark); - }); - } - - _updateThemeToggleText(button, isDark) { - button.textContent = isDark ? 'Light mode' : 'Dark mode'; - } - _addDeviceModalHeadingText() { return ADD_DEVICE_MODAL_LABEL; } @@ -467,7 +444,12 @@ class DashboardApp extends HTMLElement { if (!connectedStatus) { continue; } - connectedStatus.textContent = `Screenshot refreshed ${formatRelativeTime(capturedAt)}.`; + const kind = connectedStatus.dataset.previewKind; + if (kind === 'cached') { + connectedStatus.textContent = `Cached preview from ${formatRelativeTime(capturedAt)}.`; + } else if (kind === 'live') { + connectedStatus.textContent = `Live preview, updated ${formatRelativeTime(capturedAt)}.`; + } } // "Last checked" is baked in at snapshot time; refresh relative labels so // they age past "just now" like screenshot status does. @@ -481,19 +463,26 @@ class DashboardApp extends HTMLElement { async _refreshVisibleDevicePanels() { const visibleDevices = this._getVisibleDevices(); - for (const device of visibleDevices) { - const card = this._deviceCard(String(device.id)); - if (!card) { - continue; - } - await card.refreshScreenshot(this._screenshotCapturedAtByDevice); - await card.refreshSnapshot(); - } + await Promise.allSettled( + visibleDevices.map(async (device) => { + const card = this._deviceCard(String(device.id)); + if (!card) { + return; + } + await Promise.allSettled([ + card.refreshScreenshot(this._screenshotCapturedAtByDevice), + card.refreshSnapshot(), + ]); + }), + ); } async _refreshDeviceList() { const payload = await window.dashboardApi.getJson('/api/devices'); - this._allDevices = payload.devices || []; + if (payload.error || !Array.isArray(payload.devices)) { + return; + } + this._allDevices = payload.devices; for (const device of this._allDevices) { if (device.latest_screenshot_captured_at) { this._screenshotCapturedAtByDevice.set(String(device.id), device.latest_screenshot_captured_at); diff --git a/app/static/js/lib/snapshot-view.js b/app/static/js/lib/snapshot-view.js index 94fdd74..34f9dfa 100644 --- a/app/static/js/lib/snapshot-view.js +++ b/app/static/js/lib/snapshot-view.js @@ -1,7 +1,38 @@ import { escapeHtml, stringValue } from './strings.js'; +import { + latestVersionFrom, + licenseReason, + licenseStatusFrom, + updateBannerKind, +} from './device-update.js'; function snapshotRow(label, valueText) { - return `
${escapeHtml(label)}${escapeHtml(valueText)}
`; + return snapshotRowHtml(label, escapeHtml(valueText)); +} + +function snapshotRowHtml(label, valueHtml) { + return `
${escapeHtml(label)}${valueHtml}
`; +} + +function formatSoftwareVersionRow(snapshot) { + const current = + snapshot.collapsed?.software_version + || snapshot.expanded?.version?.status?.version + || 'unknown'; + const softwareUpdate = snapshot.expanded?.software_update || null; + const kind = updateBannerKind(softwareUpdate, current); + const latest = latestVersionFrom(softwareUpdate); + const id = snapshot.device_id; + let value = escapeHtml(current); + if (kind === 'available' && latest) { + value = `${escapeHtml(current)} → ${escapeHtml(latest)} `; + } else if (kind === 'updating') { + value = `Updating TinyPilot${latest ? ` to ${escapeHtml(latest)}` : ''}…`; + } else if (kind === 'license-blocked' && latest) { + const href = escapeHtml(snapshot.source_base_url || ''); + value = `${escapeHtml(current)} → ${escapeHtml(latest)}. ${escapeHtml(licenseReason(licenseStatusFrom(softwareUpdate)))}. ${href ? `Attach license in WebUI` : ''}`; + } + return snapshotRowHtml('Software version', value); } function friendlyInterfaceName(name) { @@ -194,7 +225,6 @@ function formatVideoTuning(videoSettings, statusSettings) { export function formatExpandedSnapshot(snapshot) { const expanded = snapshot.expanded || {}; const status = expanded.reachability?.status || {}; - const version = expanded.version?.status || {}; const video = expanded.video_settings?.status || {}; const networkData = expanded.network?.data || {}; @@ -209,9 +239,8 @@ export function formatExpandedSnapshot(snapshot) {

Overview

- ${snapshotRow('Target URL', snapshot.source_base_url || 'unknown')} - ${snapshotRow('Software version', version.version || 'unknown')} - ${snapshotRow('Reachability', expanded.reachability?.error ? 'Error' : 'OK')} + ${snapshotRow('Device URL', snapshot.source_base_url || 'unknown')} + ${formatSoftwareVersionRow(snapshot)}

Network status

@@ -221,11 +250,6 @@ export function formatExpandedSnapshot(snapshot) {

Video

${videoRows.join('')}
- ${ - expanded.last_management_error - ? `

Last management error

${escapeHtml(expanded.last_management_error)}

` - : '' - }
`; } diff --git a/app/templates/index.html b/app/templates/index.html index 9f8ae66..98b270a 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,7 +1,7 @@ - + - {% set asset_v = '20261008-remove-open-webui' %} + {% set asset_v = '20261022-v030' %} TinyPilot Dashboard diff --git a/app/tinypilot_client.py b/app/tinypilot_client.py index 91543b5..71c12ab 100644 --- a/app/tinypilot_client.py +++ b/app/tinypilot_client.py @@ -22,6 +22,15 @@ # the dashboard's TinyPilot client only; see module docstring. urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +CONNECT_TIMEOUT_SEC = 3 +READ_TIMEOUT_SEC = 10 +SCREENSHOT_READ_TIMEOUT_SEC = 15 +UPDATE_READ_TIMEOUT_SEC = 30 + + +def _timeout(read: int = READ_TIMEOUT_SEC) -> tuple[int, int]: + return (CONNECT_TIMEOUT_SEC, read) + class TinyPilotClient: """Thin HTTP client for a single TinyPilot device.""" @@ -59,25 +68,25 @@ def _raise_for_status(self, response: requests.Response) -> None: raise requests.HTTPError(detail, response=response) def _get_json(self, path: str, *, bearer: bool = False) -> dict[str, Any]: - warmup = self.session.get(self.base_url, timeout=10) + warmup = self.session.get(self.base_url, timeout=_timeout()) warmup.raise_for_status() headers = self._bearer_headers() if bearer else {} response = self.session.get( f'{self.base_url}{path}', headers=headers, - timeout=10, + timeout=_timeout(), ) self._raise_for_status(response) return response.json() def _put_json(self, path: str, body: Optional[dict] = None) -> dict[str, Any]: - warmup = self.session.get(self.base_url, timeout=10) + warmup = self.session.get(self.base_url, timeout=_timeout()) warmup.raise_for_status() response = self.session.put( f'{self.base_url}{path}', json=body, headers=self._bearer_headers(), - timeout=30, + timeout=_timeout(UPDATE_READ_TIMEOUT_SEC), ) self._raise_for_status(response) return response.json() if response.content else {} @@ -107,9 +116,9 @@ def get_screenshot(self) -> bytes: response = self.session.get( f'{self.base_url}/api/v1/screenshot', headers=self._bearer_headers(), - timeout=15, + timeout=_timeout(SCREENSHOT_READ_TIMEOUT_SEC), ) - response.raise_for_status() + self._raise_for_status(response) if response.status_code == 204 or not response.content: raise ValueError( 'TinyPilot returned no screenshot image (HTTP 204 or empty body); ' @@ -122,7 +131,7 @@ def get_automation_state(self) -> dict[str, Any]: response = self.session.get( f'{self.base_url}/state', headers=self._bearer_headers(), - timeout=10, + timeout=_timeout(), ) - response.raise_for_status() + self._raise_for_status(response) return response.json() diff --git a/tests/conftest.py b/tests/conftest.py index c25abc7..3b7d067 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,5 +8,10 @@ @pytest.fixture def client(tmp_path): db_path = tmp_path / 'dashboard.sqlite' - app = create_app({'TESTING': True, 'DATABASE_PATH': str(db_path)}) + key_path = tmp_path / 'secret.key' + app = create_app({ + 'TESTING': True, + 'DATABASE_PATH': str(db_path), + 'SECRET_KEY_PATH': str(key_path), + }) return app.test_client() diff --git a/tests/test_api_connections.py b/tests/test_api_connections.py index 2ab366d..d882a07 100644 --- a/tests/test_api_connections.py +++ b/tests/test_api_connections.py @@ -1,5 +1,7 @@ from unittest.mock import patch +import requests + from app import create_app from app.resolution import resolution_from_automation_state @@ -297,6 +299,33 @@ def test_refresh_screenshot_surfaces_api_errors(client): assert response.status_code == 502 assert 'failed to refresh screenshot' in response.json['error'] + assert 'Could not complete this request.' in response.json['error'] + assert 'HTTPSConnectionPool' not in response.json['error'] + + +def test_refresh_screenshot_hides_connection_details(client): + create_payload = { + 'friendly_name': 'Offline Screenshot', + 'base_url': 'https://192.168.1.67', + 'api_key': 'test-api-key', + } + create_response = client.post('/api/devices', json=create_payload) + device_id = create_response.json['device']['id'] + + with patch('app.api.TinyPilotClient') as client_cls: + client_cls.return_value.get_screenshot.side_effect = ( + requests.ConnectionError( + 'HTTPSConnectionPool(host=192.168.1.67, port=443): Max retries' + ) + ) + response = client.post(f'/api/devices/{device_id}/refresh-screenshot') + + assert response.status_code == 502 + assert response.json['error'] == ( + 'failed to refresh screenshot: Could not reach this TinyPilot.' + ) + assert 'HTTPSConnectionPool' not in response.json['error'] + assert '192.168.1.67' not in response.json['error'] def test_device_snapshot_license_blocked_update_cannot_start(client): @@ -384,3 +413,62 @@ def test_get_device_update_status_endpoint(client): assert response.status_code == 200 assert response.json['status'] == 'IN_PROGRESS' + + +def test_device_snapshot_stops_when_device_unreachable(client): + create_payload = { + 'friendly_name': 'Unreachable Box', + 'base_url': 'https://192.168.1.77', + 'api_key': 'test-api-key', + } + create_response = client.post('/api/devices', json=create_payload) + device_id = create_response.json['device']['id'] + + with patch('app.api.TinyPilotClient') as client_cls: + tp_client = client_cls.return_value + tp_client.get_status.side_effect = requests.ConnectionError('offline') + response = client.get(f'/api/devices/{device_id}/device/snapshot') + + assert response.status_code == 200 + payload = response.json + assert payload['collapsed']['online'] is False + assert payload['expanded']['reachability']['error'] == ( + 'Could not reach this TinyPilot.' + ) + assert 'HTTPSConnectionPool' not in str(payload) + assert '192.168.1.77' not in payload['expanded']['reachability']['error'] + assert 'last_management_error' not in payload['expanded'] + tp_client.get_version.assert_not_called() + tp_client.get_network_status.assert_not_called() + tp_client.get_video_settings.assert_not_called() + tp_client.get_automation_state.assert_not_called() + tp_client.get_latest_release.assert_not_called() + tp_client.get_update_status.assert_not_called() + + +def test_device_snapshot_stops_when_later_call_times_out(client): + create_payload = { + 'friendly_name': 'Partial Timeout', + 'base_url': 'https://192.168.1.78', + 'api_key': 'test-api-key', + } + create_response = client.post('/api/devices', json=create_payload) + device_id = create_response.json['device']['id'] + + with patch('app.api.TinyPilotClient') as client_cls: + tp_client = client_cls.return_value + tp_client.get_status.return_value = {'ok': True} + tp_client.get_version.side_effect = requests.Timeout('slow') + response = client.get(f'/api/devices/{device_id}/device/snapshot') + + assert response.status_code == 200 + payload = response.json + assert payload['collapsed']['online'] is False + assert payload['expanded']['version']['error'] == ( + 'Could not reach this TinyPilot.' + ) + tp_client.get_network_status.assert_not_called() + tp_client.get_video_settings.assert_not_called() + tp_client.get_automation_state.assert_not_called() + tp_client.get_latest_release.assert_not_called() + tp_client.get_update_status.assert_not_called() diff --git a/tests/test_remove_device_ui.py b/tests/test_remove_device_ui.py index afa3522..5441a67 100644 --- a/tests/test_remove_device_ui.py +++ b/tests/test_remove_device_ui.py @@ -31,6 +31,107 @@ def test_device_card_open_webui_header_and_remove_label(): assert 'renderHeaderOpenWebUi(device)' in header -def test_theme_defines_danger_color(): +def test_theme_defines_danger_and_warning_colors(): source = THEME.read_text(encoding='utf-8') assert '--color-danger:' in source + assert '--color-warning:' in source + + +def test_dashboard_is_dark_only_without_theme_toggle(): + app = APP.read_text(encoding='utf-8') + index = (ROOT / 'app/templates/index.html').read_text(encoding='utf-8') + assert 'id="theme-toggle"' not in app + assert '_initThemeToggle' not in app + assert 'data-theme="dark"' in index + css = (ROOT / 'app/static/css/dashboard.css').read_text(encoding='utf-8') + assert '.subtitle.connected-status--caution' in css + + +def test_device_card_keeps_cached_preview_copy(): + source = CARD.read_text(encoding='utf-8') + assert 'Cached preview from' in source + assert ' · ' not in source + app = APP.read_text(encoding='utf-8') + assert ' · ' not in app + assert 'Could not capture a screenshot.' in source + assert "kind === 'cached'" in source + assert '_primeCachedScreenshot' in source + + +def test_tinypilot_device_heading_matches_target_system(): + source = CARD.read_text(encoding='utf-8') + assert 'class="connected-system-summary__heading">Target system' in source + assert ( + 'class="connected-system-summary__heading">TinyPilot device' + in source + ) + css = (ROOT / 'app/static/css/dashboard.css').read_text(encoding='utf-8') + assert '.tiny-device-body' in css + assert '.connected-system-body' in css + + +def test_tinypilot_snapshot_health_line_and_software_row(): + source = CARD.read_text(encoding='utf-8') + summary = source.split('function formatCollapsedSnapshotSummary', 1)[1] + summary = summary.split('function ', 1)[0] + assert 'Connected' in summary + assert 'Could not reach this TinyPilot' in summary + assert '${label}, last checked' in summary + assert 'class="status-copy"' in summary + assert 'Disconnected' not in summary + assert 'software_version' not in summary + assert 'device_url' not in summary + assert 'status-url' not in source + assert 'id="device-software-' not in source + assert 'id="device-update-banner-' not in source + card_remove = source.split('data-action="delete-device"', 1)[1] + card_remove = card_remove.split('', 1)[0] + assert 'button-danger-quiet' not in card_remove + view = (ROOT / 'app/static/js/lib/snapshot-view.js').read_text(encoding='utf-8') + assert "snapshotRow('Device URL'" in view + assert 'Software version' in view + assert 'data-action="start-device-update"' in view + assert "snapshotRow('Reachability'" not in view + assert 'Reachability' not in view + opening = source.split('', 1)[0] + assert 'device-details' in opening + assert 'hidden' in opening + refresh = source.split('async refreshSnapshot()', 1)[1] + refresh = refresh.split('async ', 1)[0] + assert '_setExpandedVisible' in refresh + assert 'collapsed?.online' in refresh + assert 'last_management_error' not in view + api = (ROOT / 'app/static/js/api.js').read_text(encoding='utf-8') + assert 'AbortSignal.timeout' in api + assert "error: 'Could not reach the dashboard.'" in api + app = (ROOT / 'app/static/js/dashboard-app.js').read_text(encoding='utf-8') + assert 'Promise.allSettled' in app + assert 'payload.error || !Array.isArray(payload.devices)' in app + assert 'document.documentElement.dataset.theme' not in app + assert 'UPDATE_POLL_MAX_ERRORS' in source + assert 'window.setTimeout(tick' in source + assert 'window.setInterval(async' not in source + + +def test_dashboard_buttons_match_cloud_navy(): + css = (ROOT / 'app/static/css/dashboard.css').read_text(encoding='utf-8') + assert 'background: var(--color-nav);' in css + assert 'border: 1px solid rgba(255, 255, 255, 0.45);' in css + assert 'linear-gradient(0deg' not in css + assert 'button.button-danger-quiet' in css + assert 'hsl(230, 16%, 55%)' not in css + + +def test_screenshot_click_opens_device_webui(): + source = CARD.read_text(encoding='utf-8') + assert 'Open latest screenshot in new tab' not in source + assert 'aria-label="Control ${escapeHtml(device.friendly_name)}"' in source + assert 'href="/api/devices/${id}/latest-screenshot"' not in source + assert 'latest-screenshot' in source + assert "device.base_url" in source + link = source.split('id="connected-screenshot-link-${id}"', 1)[1] + link = link.split('>', 1)[0] + assert 'target="_blank"' in link + activate = source.split('function activateScreenshotLink', 1)[1] + activate = activate.split('function ', 1)[0] + assert 'link.href' not in activate diff --git a/tests/test_tinypilot_client.py b/tests/test_tinypilot_client.py index 9919d5b..4b21e28 100644 --- a/tests/test_tinypilot_client.py +++ b/tests/test_tinypilot_client.py @@ -34,8 +34,11 @@ def test_get_version_sends_bearer_header(client): result = client.get_version() assert result['version'] == '3.2.0' + warmup_kwargs = mock_session.get.call_args_list[0][1] + assert warmup_kwargs['timeout'] == (3, 10) _args, kwargs = mock_session.get.call_args_list[1] assert kwargs['headers']['Authorization'] == 'Bearer test-key' + assert kwargs['timeout'] == (3, 10) def test_get_screenshot_uses_bearer_and_returns_bytes(client): @@ -50,6 +53,7 @@ def test_get_screenshot_uses_bearer_and_returns_bytes(client): _args, kwargs = mock_session.get.call_args assert kwargs['headers']['Authorization'] == 'Bearer test-key' assert _args[0].endswith('/api/v1/screenshot') + assert kwargs['timeout'] == (3, 15) def test_get_latest_release_uses_bearer(client): From 4213e2ebf79c5a328d00d2c5631c532e8479c859 Mon Sep 17 00:00:00 2001 From: Scott Halver <76669202+scott-tp@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:17:35 -0400 Subject: [PATCH 3/7] Make Dashboard dark-only, confirm firmware updates, and add CircleCI. Light-theme tokens were still the CSS default. Fold dark into :root, ask before starting a device update, and run pytest plus ShellCheck on CircleCI like the rest of TinyPilot. --- .circleci/config.yml | 54 +++++++++++++++++++ app/static/css/dashboard.css | 62 ++++------------------ app/static/css/theme.css | 27 +++------- app/static/js/components/device-card.js | 4 ++ app/static/js/dashboard-app.js | 70 ++++++++++++++++++++++++- app/templates/index.html | 2 +- tests/test_remove_device_ui.py | 24 +++++++++ 7 files changed, 170 insertions(+), 73 deletions(-) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..b7e87e2 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,54 @@ +version: 2.1 + +# CircleCI for TinyPilot Dashboard. Keep this slimmer than Cloud/Pro: Flask +# tests plus ShellCheck on deploy scripts. + +executors: + ubuntu: + docker: + - image: cimg/base:2024.02 + python: + docker: + - image: cimg/python:3.12 + +jobs: + check_python: + executor: python + steps: + - checkout + - run: + name: Install dependencies + command: | + python3 -m venv venv + . venv/bin/activate + pip install --upgrade pip + pip install --requirement requirements.txt + - run: + name: Run tests + command: | + . venv/bin/activate + python -m pytest -q + + check_bash: + executor: ubuntu + steps: + - checkout + - run: + name: Install ShellCheck + command: | + set -eux + readonly SHELLCHECK_VERSION='v0.9.0' + wget --quiet --output-document - \ + "https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" | + tar --extract --xz --verbose + sudo cp "./shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/bin + shellcheck --version + - run: + name: Run ShellCheck + command: shellcheck deploy/cloud-init.sh + +workflows: + test: + jobs: + - check_python + - check_bash diff --git a/app/static/css/dashboard.css b/app/static/css/dashboard.css index 5568d4a..d3631ce 100644 --- a/app/static/css/dashboard.css +++ b/app/static/css/dashboard.css @@ -78,7 +78,7 @@ padding: var(--space-4); margin-bottom: var(--space-4); border: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent); - box-shadow: 0 2px 6px rgba(15, 23, 42, 0.06); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35); } h1, @@ -151,13 +151,15 @@ button.button-danger-quiet:hover { border-color: var(--color-danger); } -.delete-device-actions { +.delete-device-actions, +.update-device-actions { margin-top: var(--space-2); align-items: center; gap: var(--space-2); } -.delete-device-actions button { +.delete-device-actions button, +.update-device-actions button { margin-top: 0; min-width: 0; min-height: 2rem; @@ -166,7 +168,8 @@ button.button-danger-quiet:hover { line-height: 1; } -.delete-device-actions #delete-device-cancel { +.delete-device-actions #delete-device-cancel, +.update-device-actions #update-device-cancel { background: transparent; color: var(--color-text); border: 1px solid color-mix(in srgb, var(--color-text) 28%, transparent); @@ -189,7 +192,7 @@ button.button-danger-quiet:hover { border-radius: var(--radius-md); padding: var(--space-3); margin-bottom: var(--space-3); - background: color-mix(in srgb, var(--color-panel) 94%, var(--color-text) 6%); + background: hsl(224, 22%, 17%); } .device-section h3 { @@ -399,7 +402,7 @@ input { margin-top: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); - background: color-mix(in srgb, var(--color-panel) 96%, var(--color-text) 4%); + background: hsl(224, 20%, 16%); border: 1px solid color-mix(in srgb, var(--color-text) 14%, transparent); color: var(--color-text); white-space: normal; @@ -742,13 +745,13 @@ a.theme-toggle:visited { border: 0; padding: 0; margin: 0; - background: rgba(15, 23, 42, 0.55); + background: rgba(0, 0, 0, 0.65); cursor: pointer; } .modal-backdrop:hover, .modal-backdrop:active { - background: rgba(15, 23, 42, 0.55); + background: rgba(0, 0, 0, 0.65); transform: none; } @@ -800,49 +803,6 @@ a.theme-toggle:visited { color: var(--color-text); } -:root[data-theme="dark"] .modal-backdrop { - background: rgba(0, 0, 0, 0.65); -} - -:root[data-theme="dark"] .modal-backdrop:hover, -:root[data-theme="dark"] .modal-backdrop:active { - background: rgba(0, 0, 0, 0.65); -} - -:root[data-theme="dark"] .modal-close { - border-color: rgba(255, 255, 255, 0.25); - color: hsl(220, 18%, 82%); -} - -:root[data-theme="dark"] .modal-close:hover { - color: hsl(220, 35%, 94%); -} - -:root[data-theme="dark"] .panel { - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35); -} - -:root[data-theme="dark"] .device-section { - background: hsl(224, 22%, 17%); -} - -:root[data-theme="dark"] .metrics-output { - background: hsl(224, 20%, 16%); -} - -:root[data-theme="dark"] .snapshot-section { - border-top-color: rgba(255, 255, 255, 0.12); -} - -:root[data-theme="dark"] .snapshot-iface + .snapshot-iface { - border-top-color: rgba(255, 255, 255, 0.1); -} - -:root[data-theme="dark"] .snapshot-row > span:first-child, -:root[data-theme="dark"] .status-meta { - color: hsl(220, 20%, 82%); -} - @media (max-width: 980px) { .topbar { height: 45px; diff --git a/app/static/css/theme.css b/app/static/css/theme.css index 4d0c409..3b9c218 100644 --- a/app/static/css/theme.css +++ b/app/static/css/theme.css @@ -5,26 +5,7 @@ body { } :root { - --color-bg: hsl(35, 60%, 97%); - --color-panel: #ffffff; - --color-panel-secondary: #f9fafb; - --color-text: #0f172a; - --color-muted: #475569; - --color-accent: #0e6b86; - --color-accent-hover: #0b5a71; - --color-danger: hsl(5, 57%, 44%); - --color-warning: hsl(42, 96%, 36%); - --color-nav: hsl(230, 33%, 33%); - --color-nav-text: #ffffff; - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-6: 24px; - --radius-md: 8px; -} - -:root[data-theme="dark"] { + color-scheme: dark; --color-bg: hsl(228, 22%, 10%); --color-panel: hsl(224, 22%, 15%); --color-panel-secondary: hsl(224, 20%, 19%); @@ -36,4 +17,10 @@ body { --color-warning: hsl(42, 96%, 62%); --color-nav: hsl(230, 33%, 30%); --color-nav-text: #ffffff; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --radius-md: 8px; } diff --git a/app/static/js/components/device-card.js b/app/static/js/components/device-card.js index 606f7d9..8a6b718 100644 --- a/app/static/js/components/device-card.js +++ b/app/static/js/components/device-card.js @@ -98,6 +98,10 @@ class DeviceCard extends HTMLElement { return this._device; } + get desiredUpdateVersion() { + return this._desiredUpdateVersion; + } + connectedCallback() { if (this._device) { this._render(); diff --git a/app/static/js/dashboard-app.js b/app/static/js/dashboard-app.js index 471ee17..51845a1 100644 --- a/app/static/js/dashboard-app.js +++ b/app/static/js/dashboard-app.js @@ -5,6 +5,7 @@ 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 UPDATE_DEVICE_MODAL_LABEL = 'Update TinyPilot'; const DEVICES_PER_PAGE = 4; const CONNECTED_STATUS_REFRESH_INTERVAL_MS = 30_000; @@ -15,6 +16,7 @@ class DashboardApp extends HTMLElement { this._allDevices = []; this._currentPage = 1; this._pendingDeleteDeviceId = null; + this._pendingUpdateCard = null; this._screenshotAutoRefreshTimers = new Map(); this._screenshotCapturedAtByDevice = new Map(); } @@ -84,6 +86,20 @@ class DashboardApp extends HTMLElement {

+ `; this._elements = { @@ -102,6 +118,12 @@ class DashboardApp extends HTMLElement { deleteDeviceForm: this.shadowRoot.getElementById('delete-device-form'), deleteDeviceCancel: this.shadowRoot.getElementById('delete-device-cancel'), deleteDeviceStatus: this.shadowRoot.getElementById('delete-device-status'), + updateDeviceModal: this.shadowRoot.getElementById('update-device-modal'), + updateDeviceModalClose: this.shadowRoot.getElementById('update-device-modal-close'), + updateDeviceModalBackdrop: this.shadowRoot.getElementById('update-device-modal-backdrop'), + updateDeviceCancel: this.shadowRoot.getElementById('update-device-cancel'), + updateDeviceConfirm: this.shadowRoot.getElementById('update-device-confirm'), + updateDeviceVersion: this.shadowRoot.getElementById('update-device-version'), }; this._bind(); @@ -124,11 +146,22 @@ class DashboardApp extends HTMLElement { e.deleteDeviceModalClose.addEventListener('click', () => this._closeDeleteDeviceModal()); e.deleteDeviceModalBackdrop.addEventListener('click', () => this._closeDeleteDeviceModal()); e.deleteDeviceCancel.addEventListener('click', () => this._closeDeleteDeviceModal()); + e.updateDeviceModalClose.addEventListener('click', () => this._closeUpdateDeviceModal()); + e.updateDeviceModalBackdrop.addEventListener('click', () => this._closeUpdateDeviceModal()); + e.updateDeviceCancel.addEventListener('click', () => this._closeUpdateDeviceModal()); + e.updateDeviceConfirm.addEventListener('click', () => { + void this._confirmUpdateDevice(); + }); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') { return; } + if (e.updateDeviceModal && !e.updateDeviceModal.hasAttribute('hidden')) { + this._closeUpdateDeviceModal(); + event.preventDefault(); + return; + } if (e.deleteDeviceModal && !e.deleteDeviceModal.hasAttribute('hidden')) { this._closeDeleteDeviceModal(); event.preventDefault(); @@ -216,6 +249,41 @@ class DashboardApp extends HTMLElement { document.body.style.overflow = ''; } + _openUpdateDeviceModal(card) { + const version = card?.desiredUpdateVersion; + if (!version) { + return; + } + const e = this._elements; + this._pendingUpdateCard = card; + if (e.updateDeviceVersion) { + e.updateDeviceVersion.textContent = version; + } + e.updateDeviceModal.removeAttribute('hidden'); + document.body.style.overflow = 'hidden'; + window.requestAnimationFrame(() => { + e.updateDeviceConfirm?.focus(); + }); + } + + _closeUpdateDeviceModal() { + const { updateDeviceModal } = this._elements; + if (!updateDeviceModal || updateDeviceModal.hasAttribute('hidden')) { + return; + } + updateDeviceModal.setAttribute('hidden', ''); + this._pendingUpdateCard = null; + document.body.style.overflow = ''; + } + + async _confirmUpdateDevice() { + const card = this._pendingUpdateCard; + this._closeUpdateDeviceModal(); + if (card) { + await card.startUpdate(); + } + } + _bindAddDeviceForm() { const { addDeviceForm, addDeviceStatus } = this._elements; if (!addDeviceForm) { @@ -340,7 +408,7 @@ class DashboardApp extends HTMLElement { return; } if (action === 'start-device-update') { - await card.startUpdate(); + this._openUpdateDeviceModal(card); } } diff --git a/app/templates/index.html b/app/templates/index.html index 98b270a..0895080 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,7 +1,7 @@ - {% set asset_v = '20261022-v030' %} + {% set asset_v = '20261022-v030-dark' %} TinyPilot Dashboard diff --git a/tests/test_remove_device_ui.py b/tests/test_remove_device_ui.py index 5441a67..bc39ba1 100644 --- a/tests/test_remove_device_ui.py +++ b/tests/test_remove_device_ui.py @@ -8,6 +8,19 @@ THEME = ROOT / 'app/static/css/theme.css' +def test_update_device_uses_in_page_confirm_modal(): + source = APP.read_text(encoding='utf-8') + assert 'update-device-modal' in source + assert '_openUpdateDeviceModal' in source + assert '_confirmUpdateDevice' in source + assert "action === 'start-device-update'" in source + start = source.split("action === 'start-device-update'", 1)[1] + start = start.split('}', 1)[0] + assert 'startUpdate()' not in start + assert '_openUpdateDeviceModal(card)' in start + assert 'window.confirm(' not in source + + def test_remove_device_uses_in_page_modal(): source = APP.read_text(encoding='utf-8') assert '_openDeleteDeviceModal' in source @@ -44,7 +57,12 @@ def test_dashboard_is_dark_only_without_theme_toggle(): assert '_initThemeToggle' not in app assert 'data-theme="dark"' in index css = (ROOT / 'app/static/css/dashboard.css').read_text(encoding='utf-8') + theme = THEME.read_text(encoding='utf-8') assert '.subtitle.connected-status--caution' in css + assert ':root[data-theme="dark"]' not in css + assert ':root[data-theme="dark"]' not in theme + assert 'color-scheme: dark' in theme + assert 'hsl(35, 60%, 97%)' not in theme def test_device_card_keeps_cached_preview_copy(): @@ -122,6 +140,12 @@ def test_dashboard_buttons_match_cloud_navy(): assert 'hsl(230, 16%, 55%)' not in css +def test_circleci_runs_pytest_and_shellcheck(): + config = (ROOT / '.circleci/config.yml').read_text(encoding='utf-8') + assert 'python -m pytest -q' in config + assert 'shellcheck deploy/cloud-init.sh' in config + + def test_screenshot_click_opens_device_webui(): source = CARD.read_text(encoding='utf-8') assert 'Open latest screenshot in new tab' not in source From 06fae067706e23561fa595388fcfc27acca7fb41 Mon Sep 17 00:00:00 2001 From: Scott Halver <76669202+scott-tp@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:23:40 -0400 Subject: [PATCH 4/7] Serve the dashboard with Waitress instead of Flask's development server. LAN Docker still binds 0.0.0.0:8080. Cloud can set DASHBOARD_BIND=127.0.0.1 on re-pin so the sidecar stays loopback-only behind the gateway. --- requirements.txt | 1 + run.py | 11 ++++++++++- tests/test_app_factory.py | 12 ++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 351d510..2f614cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ # alpha is tested against; the upper guard prevents accidental major-version # upgrades. Bump deliberately when reviewing release notes for security fixes. Flask>=3.0,<4.0 +waitress>=3.0,<4.0 requests>=2.31,<3.0 cryptography>=42.0,<46.0 urllib3>=2.0,<3.0 diff --git a/run.py b/run.py index dc76c6d..acb7ffb 100644 --- a/run.py +++ b/run.py @@ -1,7 +1,16 @@ +import os + +from waitress import serve + from app import create_app app = create_app() if __name__ == "__main__": - app.run(host="0.0.0.0", port=8080) + # LAN Docker: 0.0.0.0 so compose port mapping works. + # Cloud sidecar: set DASHBOARD_BIND=127.0.0.1 so the unauthenticated + # dashboard is loopback-only behind the gateway proxy. + host = os.environ.get("DASHBOARD_BIND", "0.0.0.0") + port = int(os.environ.get("DASHBOARD_PORT", "8080")) + serve(app, host=host, port=port) diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index febf195..986152f 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -1,7 +1,19 @@ +from pathlib import Path + from app import create_app +ROOT = Path(__file__).resolve().parents[1] + def test_create_app_returns_app(): app = create_app() assert app is not None + + +def test_run_uses_waitress_with_bind_override(): + source = (ROOT / 'run.py').read_text(encoding='utf-8') + assert 'from waitress import serve' in source + assert 'app.run(' not in source + assert 'DASHBOARD_BIND' in source + assert 'DASHBOARD_PORT' in source From 0fc5064c4463562db3d84aee81875bfae5ca4389 Mon Sep 17 00:00:00 2001 From: Scott Halver <76669202+scott-tp@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:25:35 -0400 Subject: [PATCH 5/7] Note Waitress (ZPL-2.1) in third-party licenses. --- THIRD_PARTY_NOTICES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 98714bd..4903b60 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,6 +5,7 @@ This project depends on the following third-party Python packages. Each is distr ## Runtime dependencies - **Flask** — BSD-3-Clause — +- **waitress** — ZPL-2.1 — - **requests** — Apache-2.0 — - **urllib3** — MIT — - **cryptography** — Apache-2.0 OR BSD-3-Clause — From 93070f903c6af305b0f1d37eca83a0a873a50502 Mon Sep 17 00:00:00 2001 From: Scott Halver <76669202+scott-tp@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:33:45 -0400 Subject: [PATCH 6/7] Add Cloud/Pro-style CircleCI gates and bump cryptography. Whitespace, ruff, JS syntax, Semgrep, and pip-audit now run on every push. cryptography 50.x clears the audit findings that 45.x could not. --- .circleci/config.yml | 82 +++++++++++++++++++++------ .ruff.toml | 34 +++++++++++ .semgrepignore | 9 +++ app/__init__.py | 3 +- app/api.py | 2 +- app/db.py | 7 ++- dev-scripts/check-bash | 27 +++++++++ dev-scripts/check-javascript | 11 ++++ dev-scripts/check-python | 33 +++++++++++ dev-scripts/check-security | 18 ++++++ dev-scripts/check-trailing-newline | 23 ++++++++ dev-scripts/check-trailing-whitespace | 19 +++++++ package.json | 4 ++ requirements.txt | 5 +- requirements_dev.txt | 3 + run.py | 9 ++- tests/conftest.py | 1 - tests/test_api_connections.py | 7 +-- tests/test_remove_device_ui.py | 17 ++++-- tests/test_tinypilot_client.py | 1 - 20 files changed, 275 insertions(+), 40 deletions(-) create mode 100644 .ruff.toml create mode 100644 .semgrepignore create mode 100755 dev-scripts/check-bash create mode 100755 dev-scripts/check-javascript create mode 100755 dev-scripts/check-python create mode 100755 dev-scripts/check-security create mode 100755 dev-scripts/check-trailing-newline create mode 100755 dev-scripts/check-trailing-whitespace create mode 100644 package.json create mode 100644 requirements_dev.txt diff --git a/.circleci/config.yml b/.circleci/config.yml index b7e87e2..098da7c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,8 @@ version: 2.1 -# CircleCI for TinyPilot Dashboard. Keep this slimmer than Cloud/Pro: Flask -# tests plus ShellCheck on deploy scripts. +# CircleCI for TinyPilot Dashboard. Jobs mirror Cloud/Pro gates that apply +# to this Flask app (tests, lint, ShellCheck, JS syntax, SAST, pip-audit). +# Appliance-only jobs (Debian/Packer/HCL/device e2e) are omitted. executors: ubuntu: @@ -10,24 +11,21 @@ executors: python: docker: - image: cimg/python:3.12 + node: + docker: + - image: cimg/node:24.13.1 jobs: - check_python: - executor: python + check_whitespace: + executor: ubuntu steps: - checkout - run: - name: Install dependencies - command: | - python3 -m venv venv - . venv/bin/activate - pip install --upgrade pip - pip install --requirement requirements.txt + name: Check for trailing whitespace + command: ./dev-scripts/check-trailing-whitespace - run: - name: Run tests - command: | - . venv/bin/activate - python -m pytest -q + name: Check that all text files end in a trailing newline + command: ./dev-scripts/check-trailing-newline check_bash: executor: ubuntu @@ -45,10 +43,62 @@ jobs: shellcheck --version - run: name: Run ShellCheck - command: shellcheck deploy/cloud-init.sh + command: ./dev-scripts/check-bash + + check_python: + executor: python + steps: + - checkout + - run: + name: Install Python requirements + command: | + python3 -m venv venv + . venv/bin/activate + pip install --upgrade pip + pip install \ + --requirement requirements.txt \ + --requirement requirements_dev.txt + - run: + name: Run check script + command: | + . venv/bin/activate + ./dev-scripts/check-python + + check_javascript: + executor: node + steps: + - checkout + - run: + name: Syntax-check JavaScript + command: ./dev-scripts/check-javascript + + check_security: + executor: python + steps: + - checkout + - run: + name: Create virtual environment + command: python3 -m venv venv + - run: + name: Install requirements + command: | + . venv/bin/activate + pip install --upgrade pip + pip install \ + --requirement requirements.txt \ + --requirement requirements_dev.txt + pip install 'semgrep==1.163.0' 'pip-audit>=2.7,<3.0' + - run: + name: Run Static Application Security Testing (SAST) + command: | + . venv/bin/activate + ./dev-scripts/check-security workflows: test: jobs: - - check_python + - check_whitespace - check_bash + - check_python + - check_javascript + - check_security diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..223339c --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,34 @@ +src = ["app", "tests"] +target-version = "py312" + +[lint] +select = [ + "F", # pyflakes + "I", # isort + "Q", # flake8-quotes + "S", # flake8-bandit (security) +] +ignore = [ + "D100", + "D101", + "D102", + "D103", + "D104", + "D105", + "D106", + "D107", +] + +[lint.flake8-quotes] +avoid-escape = false +docstring-quotes = "double" +inline-quotes = "single" +multiline-quotes = "double" + +[lint.isort] +force-single-line = true + +[lint.per-file-ignores] +"tests/**/*.py" = ["S101", "S105", "S106"] +"run.py" = ["S104"] +"app/__init__.py" = ["S106"] diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 0000000..d0f35ee --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,9 @@ +# Generated / local +node_modules/ +venv/ +.venv/ +.pytest_cache/ +data/ + +# Tests use fixtures and static source greps, not production request paths. +tests/ diff --git a/app/__init__.py b/app/__init__.py index d31bbc4..62a561b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -7,7 +7,8 @@ from flask import send_from_directory from app.api import api_blueprint -from app.db import close_db, init_db +from app.db import close_db +from app.db import init_db # Public product version. Version bands signal lifecycle stage to customers: # 0.1.x / 0.2.x / 0.3.x -> alpha diff --git a/app/api.py b/app/api.py index 7b95482..3355e0d 100644 --- a/app/api.py +++ b/app/api.py @@ -21,8 +21,8 @@ from flask import request from flask import send_file -from app.auth_store import encrypt_secret from app.auth_store import decrypt_secret +from app.auth_store import encrypt_secret from app.db import get_db from app.device_update import can_start_update from app.device_update import update_available diff --git a/app/db.py b/app/db.py index 79f6b6f..83120ca 100644 --- a/app/db.py +++ b/app/db.py @@ -9,7 +9,8 @@ import sqlite3 from pathlib import Path -from flask import current_app, g +from flask import current_app +from flask import g def get_db() -> sqlite3.Connection: @@ -95,8 +96,8 @@ def _ensure_column( ``table``, ``column``, and ``column_def`` come from constants in this module, never from user input, so the f-string ``ALTER TABLE`` is safe. """ - existing = db.execute(f"PRAGMA table_info({table})").fetchall() + existing = db.execute(f'PRAGMA table_info({table})').fetchall() existing_names = {row['name'] for row in existing} if column in existing_names: return - db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {column_def}") + db.execute(f'ALTER TABLE {table} ADD COLUMN {column} {column_def}') diff --git a/dev-scripts/check-bash b/dev-scripts/check-bash new file mode 100755 index 0000000..4b637da --- /dev/null +++ b/dev-scripts/check-bash @@ -0,0 +1,27 @@ +#!/bin/bash +# +# Run static analysis on shell scripts. + +set -e +set -x +set -u + +SHELL_SCRIPTS=() +while read -r filepath; do + if head -n 1 "${filepath}" | grep --quiet \ + --regexp '#!/bin/bash' \ + --regexp '#!/usr/bin/env bash' \ + --regexp '#!/bin/sh' \ + --regexp '#!/usr/bin/env sh' + then + SHELL_SCRIPTS+=("${filepath}") + fi +done < <(git ls-files) +readonly SHELL_SCRIPTS + +if (( "${#SHELL_SCRIPTS[@]}" == 0 )); then + echo 'No shell scripts found.' + exit 0 +fi + +shellcheck "${SHELL_SCRIPTS[@]}" diff --git a/dev-scripts/check-javascript b/dev-scripts/check-javascript new file mode 100755 index 0000000..42ae9ef --- /dev/null +++ b/dev-scripts/check-javascript @@ -0,0 +1,11 @@ +#!/bin/bash +# +# Syntax-check dashboard JavaScript (no bundler). + +set -e +set -x +set -u + +while read -r js; do + node --check "${js}" +done < <(git ls-files '*.js') diff --git a/dev-scripts/check-python b/dev-scripts/check-python new file mode 100755 index 0000000..3a5dfdf --- /dev/null +++ b/dev-scripts/check-python @@ -0,0 +1,33 @@ +#!/bin/bash +# +# Run tests and static analysis on Python files. + +set -e +set -x +set -u + +find . \ + -name '*.pyc' \ + -type f \ + -not -path './.venv/*' \ + -not -path './venv/*' \ + -delete + +./venv/bin/pytest -q + +INIT_FILES_OK=true +while read -r directory; do + if [[ ! -f "${directory}/__init__.py" ]]; then + printf "Directory missing __init__.py file: %s\n" "${directory}" >&2 + INIT_FILES_OK=false + fi +done < <( + find app \ + -type f \ + -name '*.py' \ + -exec dirname {} \; \ + | sort --unique +) +"${INIT_FILES_OK}" || exit 1 + +./venv/bin/ruff check app tests run.py diff --git a/dev-scripts/check-security b/dev-scripts/check-security new file mode 100755 index 0000000..e8a1e36 --- /dev/null +++ b/dev-scripts/check-security @@ -0,0 +1,18 @@ +#!/bin/bash +# +# Static Application Security Testing plus dependency audit. + +set -e +set -x +set -u + +semgrep scan --config p/python --config p/flask --text + +finding_count="$(semgrep scan --config p/python --config p/flask --json --quiet | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("results", [])))')" +readonly finding_count +if (( finding_count > 0 )); then + printf 'semgrep reported %s finding(s)\n' "${finding_count}" >&2 + exit 1 +fi + +pip-audit --requirement requirements.txt diff --git a/dev-scripts/check-trailing-newline b/dev-scripts/check-trailing-newline new file mode 100755 index 0000000..e73aa8b --- /dev/null +++ b/dev-scripts/check-trailing-newline @@ -0,0 +1,23 @@ +#!/bin/bash +# +# Verify that all text files end in a trailing newline. + +set -e +set -u + +success=0 + +while read -r line; do + if ! [[ -s "${line}" && -z "$(tail -c 1 "${line}")" ]]; then + printf "File must end in a trailing newline: %s\n" "${line}" >&2 + success=255 + fi +done < <(git ls-files \ + | xargs grep ".*" \ + --files-with-matches \ + --binary-files=without-match \ + --exclude="*.svg" \ + --exclude="*.png" \ + --exclude="*third-party*") + +exit "${success}" diff --git a/dev-scripts/check-trailing-whitespace b/dev-scripts/check-trailing-whitespace new file mode 100755 index 0000000..3ffeb67 --- /dev/null +++ b/dev-scripts/check-trailing-whitespace @@ -0,0 +1,19 @@ +#!/bin/bash +# +# Check for trailing whitespace + +set -e +set -u + +while read -r line; do + if grep \ + "\s$" \ + --line-number \ + --with-filename \ + --binary-files=without-match \ + --exclude="*third-party*" \ + "${line}"; then + echo "Found trailing whitespace" >&2 + exit 1 + fi +done < <(git ls-files) diff --git a/package.json b/package.json new file mode 100644 index 0000000..e986b24 --- /dev/null +++ b/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/requirements.txt b/requirements.txt index 2f614cc..5aeddf9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,5 @@ Flask>=3.0,<4.0 waitress>=3.0,<4.0 requests>=2.31,<3.0 -cryptography>=42.0,<46.0 +cryptography>=50.0,<51.0 urllib3>=2.0,<3.0 - -# Test dependency, also imported via conftest. -pytest>=8.0,<9.0 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..8220130 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,3 @@ +# CI / local test tooling. Runtime deps stay in requirements.txt. +pytest>=8.0,<10.0 +ruff==0.15.13 diff --git a/run.py b/run.py index acb7ffb..9239d4e 100644 --- a/run.py +++ b/run.py @@ -1,16 +1,15 @@ import os -from waitress import serve - from app import create_app +from waitress import serve app = create_app() -if __name__ == "__main__": +if __name__ == '__main__': # LAN Docker: 0.0.0.0 so compose port mapping works. # Cloud sidecar: set DASHBOARD_BIND=127.0.0.1 so the unauthenticated # dashboard is loopback-only behind the gateway proxy. - host = os.environ.get("DASHBOARD_BIND", "0.0.0.0") - port = int(os.environ.get("DASHBOARD_PORT", "8080")) + host = os.environ.get('DASHBOARD_BIND', '0.0.0.0') + port = int(os.environ.get('DASHBOARD_PORT', '8080')) serve(app, host=host, port=port) diff --git a/tests/conftest.py b/tests/conftest.py index 3b7d067..d08bc23 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,6 @@ """Pytest configuration and shared fixtures for tests.""" import pytest - from app import create_app diff --git a/tests/test_api_connections.py b/tests/test_api_connections.py index d882a07..0a62dc8 100644 --- a/tests/test_api_connections.py +++ b/tests/test_api_connections.py @@ -1,20 +1,19 @@ from unittest.mock import patch import requests - from app import create_app from app.resolution import resolution_from_automation_state def test_schema_bootstrap_creates_tables(tmp_path): - db_path = tmp_path / "dashboard.sqlite" - app = create_app({"DATABASE_PATH": str(db_path)}) + db_path = tmp_path / 'dashboard.sqlite' + app = create_app({'DATABASE_PATH': str(db_path)}) with app.app_context(): from app.db import list_tables tables = set(list_tables()) - assert {"devices", "device_auth", "device_runtime_state"} <= tables + assert {'devices', 'device_auth', 'device_runtime_state'} <= tables def test_list_devices_endpoint(client): diff --git a/tests/test_remove_device_ui.py b/tests/test_remove_device_ui.py index bc39ba1..69476b0 100644 --- a/tests/test_remove_device_ui.py +++ b/tests/test_remove_device_ui.py @@ -140,10 +140,19 @@ def test_dashboard_buttons_match_cloud_navy(): assert 'hsl(230, 16%, 55%)' not in css -def test_circleci_runs_pytest_and_shellcheck(): +def test_circleci_runs_core_and_security_jobs(): config = (ROOT / '.circleci/config.yml').read_text(encoding='utf-8') - assert 'python -m pytest -q' in config - assert 'shellcheck deploy/cloud-init.sh' in config + for job in ( + 'check_whitespace', + 'check_bash', + 'check_python', + 'check_javascript', + 'check_security', + ): + assert job in config + assert './dev-scripts/check-python' in config + assert './dev-scripts/check-security' in config + assert './dev-scripts/check-javascript' in config def test_screenshot_click_opens_device_webui(): @@ -152,7 +161,7 @@ def test_screenshot_click_opens_device_webui(): assert 'aria-label="Control ${escapeHtml(device.friendly_name)}"' in source assert 'href="/api/devices/${id}/latest-screenshot"' not in source assert 'latest-screenshot' in source - assert "device.base_url" in source + assert 'device.base_url' in source link = source.split('id="connected-screenshot-link-${id}"', 1)[1] link = link.split('>', 1)[0] assert 'target="_blank"' in link diff --git a/tests/test_tinypilot_client.py b/tests/test_tinypilot_client.py index 4b21e28..66fd2eb 100644 --- a/tests/test_tinypilot_client.py +++ b/tests/test_tinypilot_client.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock import pytest - from app.tinypilot_client import TinyPilotClient From 597f9f4864d1714e697fa3ddba63a6274d3670a2 Mon Sep 17 00:00:00 2001 From: Scott Halver <76669202+scott-tp@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:36:48 -0400 Subject: [PATCH 7/7] Say "checked" instead of "last checked" on the health line. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the heartbeat verb distinct from screenshot "updated", but match the rhythm of "Live preview, updated … ago." --- app/static/js/components/device-card.js | 2 +- app/static/js/dashboard-app.js | 4 ++-- app/templates/index.html | 2 +- tests/test_remove_device_ui.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/static/js/components/device-card.js b/app/static/js/components/device-card.js index 8a6b718..191cae0 100644 --- a/app/static/js/components/device-card.js +++ b/app/static/js/components/device-card.js @@ -16,7 +16,7 @@ function formatCollapsedSnapshotSummary(c) { return `
- ${label}, last checked ${escapeHtml(checkedText)}. + ${label}, checked ${escapeHtml(checkedText)}.
`; } diff --git a/app/static/js/dashboard-app.js b/app/static/js/dashboard-app.js index 51845a1..0102668 100644 --- a/app/static/js/dashboard-app.js +++ b/app/static/js/dashboard-app.js @@ -519,8 +519,8 @@ class DashboardApp extends HTMLElement { connectedStatus.textContent = `Live preview, updated ${formatRelativeTime(capturedAt)}.`; } } - // "Last checked" is baked in at snapshot time; refresh relative labels so - // they age past "just now" like screenshot status does. + // Relative "checked … ago" is baked in at snapshot time; refresh the + // labels so they age past "just now" like screenshot status does. for (const el of this._elements.deviceSections.querySelectorAll('.status-checked-at')) { const checkedAt = el.getAttribute('data-checked-at'); if (checkedAt) { diff --git a/app/templates/index.html b/app/templates/index.html index 0895080..d1ef6b1 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,7 +1,7 @@ - {% set asset_v = '20261022-v030-dark' %} + {% set asset_v = '20261022-v030-checked' %} TinyPilot Dashboard diff --git a/tests/test_remove_device_ui.py b/tests/test_remove_device_ui.py index 69476b0..8fd6c3b 100644 --- a/tests/test_remove_device_ui.py +++ b/tests/test_remove_device_ui.py @@ -94,7 +94,7 @@ def test_tinypilot_snapshot_health_line_and_software_row(): summary = summary.split('function ', 1)[0] assert 'Connected' in summary assert 'Could not reach this TinyPilot' in summary - assert '${label}
, last checked' in summary + assert '${label}, checked' in summary assert 'class="status-copy"' in summary assert 'Disconnected' not in summary assert 'software_version' not in summary