diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 0000000..95fb85e --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,15 @@ +name: ShellCheck + +on: + push: + pull_request: + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run ShellCheck + uses: ludeeus/action-shellcheck@master + with: + scandir: './deploy' diff --git a/README.md b/README.md index eed16e9..3575f34 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,66 @@ A self-hosted dashboard for monitoring and controlling multiple TinyPilot device - A [TinyPilot Automation License](https://tinypilotkvm.com/pages/automation) on each device — required for the live and on-demand screenshots the dashboard displays. - A modern browser (Chrome, Firefox, Safari, or Edge). -## Install +## Deploy on DigitalOcean + +For always-on access from anywhere on your Tailscale network, run the +dashboard on a DigitalOcean droplet. All droplet sizes run identical +software — choose based on your workload: + +| Use case | Size | Cost | +|---|---|---| +| Dashboard only, light use | `s-1vcpu-1gb` | $6/mo | +| Dashboard only, comfortable | `s-1vcpu-2gb` | $12/mo | +| Dashboard + heavier polling | `s-2vcpu-4gb` | $24/mo | +| Dashboard + AI agent (Cursor/Claude Code) | `s-4vcpu-8gb` | $48/mo | + +Droplets can be resized in the DigitalOcean control panel at any time +without data loss. + +### Steps + +1. Make sure you have an [SSH key added to your DigitalOcean account](https://cloud.digitalocean.com/account/security). + DigitalOcean requires one at droplet creation — you won't need to use it + day-to-day once Tailscale is running. +2. [Create a DigitalOcean droplet](https://cloud.digitalocean.com/droplets/new) + running **Ubuntu 24.04 LTS**. +3. In **Additional Options → Startup script**, paste the contents of + [`deploy/cloud-init.sh`](deploy/cloud-init.sh) from this repository. +4. At the top of the pasted script, set your Tailscale auth key + (see below). Leave it blank to skip Tailscale. +5. Click **Create Droplet**. The dashboard installs automatically on + first boot. Allow about 10 minutes (faster on larger droplets), then + open `https://..ts.net` in your browser — that + is your dashboard URL. (The machine name comes from the droplet name + you set in step 2.) + +### Tailscale (strongly recommended) + +[Generate a reusable auth key](https://login.tailscale.com/admin/settings/keys) +in your Tailscale admin console, then set it at the top of the +cloud-init script: + +``` +TAILSCALE_AUTH_KEY="tskey-auth-xxxxxxxx" +``` + +Once setup completes, the dashboard is available at +`https://..ts.net` — accessible only to devices +on your Tailscale network, with HTTPS provided automatically. + +**Without Tailscale:** the dashboard is localhost-only. Access it via +SSH tunnel (`ssh -L 8080:localhost:8080 root@`) and open +`http://localhost:8080`. For additional server hardening, see +DigitalOcean's [Initial Server Setup guide](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu). + +### Updates + +The droplet auto-updates the dashboard daily at 3am. No action required. + +## Run locally + +For running on a machine that is already on the same network as your +TinyPilot devices: 1. Clone or download this repository. 2. From the repository directory, start the dashboard: @@ -39,7 +98,9 @@ To stop the dashboard: docker compose down ``` -Compose binds to `127.0.0.1:8080` on the host by default, so the UI is reachable only from the machine running it. To expose it to other hosts on your trusted network, see [Deployment & security](#deployment--security). +Compose binds to `127.0.0.1:8080` on the host by default, so the UI is +reachable only from the machine running it. To expose it to other hosts +on your trusted network, see [Deployment & security](#deployment--security). ## Adding a device @@ -51,7 +112,7 @@ Compose binds to `127.0.0.1:8080` on the host by default, so the UI is reachable ## Deployment & security -The dashboard is designed to live **inside a network perimeter you already trust**. It does not authenticate inbound users and does not implement CSRF protection on its API; the deployment boundary *is* the security boundary. +The dashboard is designed to live **inside a network perimeter you already trust**. It does not authenticate inbound users — there are no login sessions or cookies — so standard CSRF attacks (which require a session to hijack) do not apply to this release. State-changing API requests require `Content-Type: application/json`, which triggers a browser CORS preflight that blocks cross-origin requests. If HTTP authentication is added in a future release, CSRF token protection will be required alongside it. ### Deploy behind a trusted perimeter — pick one diff --git a/app/__init__.py b/app/__init__.py index 8721b68..2f56620 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -14,7 +14,7 @@ # 0.5.x -> beta # 1.0.0 -> first stable release # Bump this on every customer-visible release. -__version__ = '0.1.0' +__version__ = '0.1.1' def create_app(test_config=None): diff --git a/app/api.py b/app/api.py index aa225c0..5bd3764 100644 --- a/app/api.py +++ b/app/api.py @@ -438,28 +438,6 @@ def refresh_csrf_token(device_id: int): return jsonify({'device_id': device_id, 'csrf_refreshed': True}) -@api_blueprint.get('/devices//device/metrics') -def get_device_metrics(device_id: int): - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - try: - client.refresh_csrf_token() - metrics = client.get_network_status() - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to fetch device metrics: {err}'}), 502 - return jsonify( - { - 'device_id': device_id, - 'source_base_url': row['base_url'], - 'metrics': metrics, - } - ) - def _safe_fetch(fetcher): try: @@ -561,7 +539,7 @@ def get_device_snapshot(device_id: int): 'reachability': {'status': status, 'error': status_error}, 'web_session': {'status': auth_status, 'error': auth_error}, 'version': {'status': version, 'error': version_error}, - 'network': {'interfaces': (network or {}).get('interfaces', []), 'error': network_error}, + 'network': {'data': network or {}, 'error': network_error}, 'https_requirement': {'status': requires_https, 'error': https_error}, 'video_settings': {'status': video, 'error': video_error}, 'connected_device_resolution': connected_resolution, @@ -576,3 +554,77 @@ def get_device_snapshot(device_id: int): 'expanded': expanded, } ) + + +@api_blueprint.get('/devices//media') +def get_device_media(device_id: int): + """Return current virtual media state (backing files + mount mode) for a device.""" + row = _device_row_with_web_auth(device_id) + if row is None: + return jsonify({'error': 'device not found'}), 404 + key_path = Path(current_app.config['SECRET_KEY_PATH']) + http_basic = _http_basic_from_auth_row(key_path, row) + client = TinyPilotClient(row['base_url'], http_basic=http_basic) + try: + result = client.get_mass_storage() + except Exception as err: # pylint: disable=broad-exception-caught + return jsonify({'error': f'failed to fetch media state: {err}'}), 502 + return jsonify(result) + + +@api_blueprint.post('/devices//media/fetch') +def fetch_device_media_from_url(device_id: int): + """Tell the device to download an image from a URL.""" + row = _device_row_with_web_auth(device_id) + if row is None: + return jsonify({'error': 'device not found'}), 404 + payload = request.get_json(silent=True) or {} + url = (payload.get('url') or '').strip() + if not url: + return jsonify({'error': 'url is required'}), 400 + key_path = Path(current_app.config['SECRET_KEY_PATH']) + http_basic = _http_basic_from_auth_row(key_path, row) + client = TinyPilotClient(row['base_url'], http_basic=http_basic) + try: + file_name = client.get_mass_storage_filename_from_url(url) + client.fetch_mass_storage_from_url(file_name, url) + except Exception as err: # pylint: disable=broad-exception-caught + return jsonify({'error': f'failed to fetch image from URL: {err}'}), 502 + return jsonify({'device_id': device_id, 'fileName': file_name}) + + +@api_blueprint.put('/devices//media/mount') +def mount_device_media(device_id: int): + """Mount a backing file on the device.""" + row = _device_row_with_web_auth(device_id) + if row is None: + return jsonify({'error': 'device not found'}), 404 + payload = request.get_json(silent=True) or {} + file_name = (payload.get('fileName') or '').strip() + mode = (payload.get('mode') or '').strip() + if not file_name or not mode: + return jsonify({'error': 'fileName and mode are required'}), 400 + key_path = Path(current_app.config['SECRET_KEY_PATH']) + http_basic = _http_basic_from_auth_row(key_path, row) + client = TinyPilotClient(row['base_url'], http_basic=http_basic) + try: + client.mount_mass_storage(file_name, mode) + except Exception as err: # pylint: disable=broad-exception-caught + return jsonify({'error': f'failed to mount image: {err}'}), 502 + return jsonify({'device_id': device_id, 'mounted': True, 'fileName': file_name, 'mode': mode}) + + +@api_blueprint.put('/devices//media/eject') +def eject_device_media(device_id: int): + """Eject the currently mounted image on the device.""" + row = _device_row_with_web_auth(device_id) + if row is None: + return jsonify({'error': 'device not found'}), 404 + key_path = Path(current_app.config['SECRET_KEY_PATH']) + http_basic = _http_basic_from_auth_row(key_path, row) + client = TinyPilotClient(row['base_url'], http_basic=http_basic) + try: + client.eject_mass_storage() + except Exception as err: # pylint: disable=broad-exception-caught + return jsonify({'error': f'failed to eject media: {err}'}), 502 + return jsonify({'device_id': device_id, 'ejected': True}) diff --git a/app/static/css/dashboard.css b/app/static/css/dashboard.css index 5c80106..8a32e45 100644 --- a/app/static/css/dashboard.css +++ b/app/static/css/dashboard.css @@ -701,3 +701,143 @@ button:active { padding: 0 1rem; } } + +/* Virtual media section */ + +.virtual-media-details { + border-top: 1px solid color-mix(in srgb, var(--color-text) 10%, transparent); + padding-top: var(--space-3); + margin-top: var(--space-2); +} + +.virtual-media-summary { + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + list-style: none; + cursor: pointer; + user-select: none; + padding: 0; +} + +.virtual-media-summary::-webkit-details-marker { + display: none; +} + +.virtual-media-summary__lead { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.virtual-media-summary__heading { + margin: 0; + font-size: 1.0625rem; + line-height: 1.3; + font-weight: 600; + color: var(--color-text); +} + +.virtual-media-summary-text { + margin: 0; + font-size: 0.9rem; + color: var(--color-text-muted); +} + +.virtual-media-summary__toggle { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 2rem; + min-height: 2.25rem; + flex-shrink: 0; + font-size: 1.35rem; + font-weight: 600; + line-height: 1; + color: var(--color-accent); + user-select: none; +} + +.virtual-media-details[open] .virtual-media-summary__toggle::after { + content: '\2212'; +} + +.virtual-media-details:not([open]) .virtual-media-summary__toggle::after { + content: '+'; +} + +.virtual-media-summary:hover .virtual-media-summary__toggle, +.virtual-media-summary:focus-visible .virtual-media-summary__toggle { + color: var(--color-accent-hover); +} + +.virtual-media-body { + margin-top: var(--space-3); +} + +.virtual-media-select, +.virtual-media-url-input { + display: block; + width: 100%; + margin-bottom: var(--space-2); + font-family: inherit; + font-size: 0.9rem; + box-sizing: border-box; +} + +.virtual-media-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + align-items: center; + flex-wrap: wrap; + margin-top: var(--space-1); +} + +.virtual-media-confirm-text { + font-size: 0.88rem; + margin-right: auto; +} + +.virtual-media-info { + display: grid; + grid-template-columns: 5rem 1fr; + gap: var(--space-1) var(--space-3); + margin: 0 0 var(--space-3) 0; + font-size: 0.88rem; +} + +.virtual-media-info dt { + color: var(--color-text-muted, #5a6770); +} + +.virtual-media-info dd { + margin: 0; + word-break: break-all; +} + +.virtual-media-hint { + margin: var(--space-1) 0 0 0; + font-size: 0.9rem; +} + +.virtual-media-hint a { + color: var(--color-accent); + text-decoration: none; + font-weight: 600; +} + +.virtual-media-hint a:hover, +.virtual-media-hint a:focus-visible { + color: var(--color-accent-hover); + text-decoration: none; +} + +.virtual-media-status { + font-size: 0.82rem; + color: var(--color-text-muted, #5a6770); + min-height: 1.2em; + margin: var(--space-2) 0 0 0; +} diff --git a/app/static/js/api.js b/app/static/js/api.js index 761c71b..7e2f3a8 100644 --- a/app/static/js/api.js +++ b/app/static/js/api.js @@ -35,6 +35,10 @@ async function getJson(path) { return fetchJson(path); } +async function putJson(path, body = {}) { + return fetchJson(path, { method: 'PUT', body }); +} + async function deleteJson(path) { return fetchJson(path, { method: 'DELETE' }); } @@ -43,4 +47,5 @@ window.dashboardApi = { deleteJson, getJson, postJson, + putJson, }; diff --git a/app/static/js/components/device-card.js b/app/static/js/components/device-card.js index fd31609..6aace3d 100644 --- a/app/static/js/components/device-card.js +++ b/app/static/js/components/device-card.js @@ -3,6 +3,16 @@ import { formatExpandedSnapshot } from '../lib/snapshot-view.js'; const AUTOMATION_LICENSE_INFO_URL = 'https://tinypilotkvm.com/pages/automation'; +const _MOUNT_MODE_LABELS = { + CDROM: 'CD-ROM', + FLASH_READ_ONLY: 'USB — Read only', + FLASH_READ_WRITE: 'USB — Read/write', +}; + +function _formatMountMode(mode) { + return _MOUNT_MODE_LABELS[mode] || mode || 'Unknown'; +} + function formatCollapsedSnapshotSummary(c) { const checkedText = formatRelativeTime(c.last_checked); return ` @@ -57,6 +67,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}`), + virtualMediaSection: this.querySelector(`#virtual-media-${id}`), }; } @@ -119,6 +130,16 @@ class DeviceCard extends HTMLElement {

+
+ +
+

Virtual media

+

Loading…

+
+ +
+
+

TinyPilot device

@@ -148,11 +169,8 @@ class DeviceCard extends HTMLElement { const id = this._device.id; const { screenshot, link, connectedStatus } = this._elements; - if (screenshot) { - screenshot.onload = null; - screenshot.onerror = null; - screenshot.removeAttribute('src'); - } + // Do NOT clear screenshot.src here — keep showing the current frame + // until the replacement is ready to avoid a visible blank state. deactivateScreenshotLink(link); const result = await window.dashboardApi.postJson(`/api/devices/${id}/refresh-screenshot`); @@ -182,26 +200,28 @@ class DeviceCard extends HTMLElement { link.href = busted; }; - screenshot.onload = () => { + // Pre-load into an off-screen Image. Only swap the displayed src + // after the browser has fully decoded the new frame so it never goes blank. + const preload = new Image(); + preload.onload = () => { + screenshot.src = busted; activateScreenshotLink(); + if (connectedStatus) { + connectedStatus.textContent = `Screenshot refreshed ${formatRelativeTime(result.captured_at)}.`; + } }; - - screenshot.onerror = () => { - screenshot.onerror = null; - screenshot.onload = null; - screenshot.removeAttribute('src'); + preload.onerror = () => { + // Keep the current screenshot; show a status note. if (connectedStatus) { connectedStatus.textContent = 'Screenshot failed to load (image error).'; } deactivateScreenshotLink(link); }; + preload.src = busted; - screenshot.src = busted; - if (link) { - link.href = busted; - } - - if (screenshot.complete && screenshot.naturalWidth > 0) { + // If already cached, onload may have fired synchronously — handle that case. + if (preload.complete && preload.naturalWidth > 0 && screenshot.src !== busted) { + screenshot.src = busted; activateScreenshotLink(); } } @@ -231,6 +251,102 @@ class DeviceCard extends HTMLElement { metricsOutput.innerHTML = formatExpandedSnapshot(snapshot); } + _renderVirtualMedia(mediaState) { + const id = this._device.id; + const section = this._elements.virtualMediaSection; + if (!section) { + return; + } + const backingFiles = mediaState.backingFiles || []; + const mountMode = mediaState.mountMode || ''; + const mountedFile = backingFiles.find(f => f.mounted) || null; + + const summaryText = section.querySelector('.virtual-media-summary-text'); + if (summaryText) { + summaryText.textContent = mountedFile + ? `${mountedFile.name} · ${_formatMountMode(mountMode)}` + : 'Not mounted'; + } + + const body = section.querySelector('.virtual-media-body'); + if (!body) { + return; + } + body.innerHTML = ''; + + if (backingFiles.length === 0) { + body.innerHTML = ` +

+ +
+ +
+
+

+ Or upload via the TinyPilot WebUI ↗ +

+

+ `; + } else if (!mountedFile) { + const options = backingFiles + .map(f => ``) + .join(''); + body.innerHTML = ` + + +
+ +
+

+ `; + } else { + body.innerHTML = ` +
+
Mounted
+
${escapeHtml(mountedFile.name)}
+
Mode
+
${escapeHtml(_formatMountMode(mountMode))}
+
+
+ +
+

+ `; + } + } + + async refreshMedia() { + if (!this._device) { + return; + } + const id = this._device.id; + const result = await window.dashboardApi.getJson(`/api/devices/${id}/media`); + const section = this._elements.virtualMediaSection; + if (result.error) { + if (section) { + const body = section.querySelector('.virtual-media-body'); + if (body) { + body.textContent = `Could not reach device: ${result.error}`; + } + } + return; + } + this._renderVirtualMedia(result); + } + } customElements.define('device-card', DeviceCard); diff --git a/app/static/js/dashboard-app.js b/app/static/js/dashboard-app.js index b739c58..824eb06 100644 --- a/app/static/js/dashboard-app.js +++ b/app/static/js/dashboard-app.js @@ -1,5 +1,5 @@ import './components/device-card.js'; -import { formatRelativeTime } from './lib/strings.js'; +import { escapeHtml, formatRelativeTime } from './lib/strings.js'; const DEVICES_PER_PAGE = 4; const CONNECTED_STATUS_REFRESH_INTERVAL_MS = 30_000; @@ -256,6 +256,89 @@ class DashboardApp extends HTMLElement { } if (action === 'fetch-device-snapshot') { await card.refreshSnapshot(); + return; + } + if (action === 'fetch-media') { + const statusEl = card.querySelector(`#virtual-media-status-${deviceId}`); + const urlInput = card.querySelector(`#virtual-media-url-${deviceId}`); + const url = urlInput ? urlInput.value.trim() : ''; + if (!url) { + if (statusEl) { + statusEl.textContent = 'Please enter a URL.'; + } + return; + } + if (statusEl) { + statusEl.textContent = 'Downloading…'; + } + const fetchResult = await window.dashboardApi.postJson( + `/api/devices/${deviceId}/media/fetch`, + { url }, + ); + if (fetchResult.error) { + if (statusEl) { + statusEl.textContent = `Failed: ${fetchResult.error}`; + } + } else { + await card.refreshMedia(); + } + return; + } + if (action === 'mount-media') { + const fileSelect = card.querySelector(`#virtual-media-file-${deviceId}`); + const modeSelect = card.querySelector(`#virtual-media-mode-${deviceId}`); + const statusEl = card.querySelector(`#virtual-media-status-${deviceId}`); + const fileName = fileSelect ? fileSelect.value : ''; + const mode = modeSelect ? modeSelect.value : 'CDROM'; + if (!fileName) { + if (statusEl) { + statusEl.textContent = 'Please select an image.'; + } + return; + } + const mountResult = await window.dashboardApi.putJson( + `/api/devices/${deviceId}/media/mount`, + { fileName, mode }, + ); + if (mountResult.error) { + if (statusEl) { + statusEl.textContent = `Mount failed: ${mountResult.error}`; + } + } else { + await card.refreshMedia(); + } + return; + } + if (action === 'eject-media') { + const ejectArea = card.querySelector(`#virtual-media-eject-area-${deviceId}`); + const mountedName = ejectArea + ? ejectArea.closest('.virtual-media-body').querySelector('dd')?.textContent || 'this image' + : 'this image'; + if (ejectArea) { + ejectArea.innerHTML = ` + Eject ${escapeHtml(mountedName)}? + + + `; + } + return; + } + if (action === 'eject-media-confirm') { + const statusEl = card.querySelector(`#virtual-media-status-${deviceId}`); + const ejectResult = await window.dashboardApi.putJson( + `/api/devices/${deviceId}/media/eject`, + ); + if (ejectResult.error) { + if (statusEl) { + statusEl.textContent = `Eject failed: ${ejectResult.error}`; + } + } else { + await card.refreshMedia(); + } + return; + } + if (action === 'eject-media-cancel') { + await card.refreshMedia(); } } @@ -371,6 +454,7 @@ class DashboardApp extends HTMLElement { } await card.refreshScreenshot(this._screenshotCapturedAtByDevice); await card.refreshSnapshot(); + await card.refreshMedia(); } } diff --git a/app/static/js/lib/snapshot-view.js b/app/static/js/lib/snapshot-view.js index b8dccee..878f68b 100644 --- a/app/static/js/lib/snapshot-view.js +++ b/app/static/js/lib/snapshot-view.js @@ -4,30 +4,6 @@ function snapshotRow(label, valueText) { return `
${escapeHtml(label)}${escapeHtml(valueText)}
`; } -function normalizeNetworkInterface(entry) { - if (!entry || typeof entry !== 'object') { - return null; - } - const name = - stringValue(entry.name) - || stringValue(entry.interface) - || stringValue(entry.device) - || 'unknown'; - const ip = - stringValue(entry.ipAddress) - || stringValue(entry.ip_address) - || stringValue(entry.ip) - || 'no ip'; - let connected = entry.isConnected; - if (connected === undefined) { - connected = entry.is_connected; - } - if (connected === undefined) { - connected = entry.connected; - } - return { name, ip, connected }; -} - function interfaceLinkLabel(connected) { if (connected === true) { return 'connected'; @@ -38,19 +14,50 @@ function interfaceLinkLabel(connected) { return 'unknown'; } -function formatInterfaces(interfaces) { - if (!Array.isArray(interfaces) || interfaces.length === 0) { - return snapshotRow('Interfaces', 'None reported'); - } - const normalized = interfaces - .map(normalizeNetworkInterface) - .filter(Boolean) - .sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' })); - return normalized.map((entry) => { - const link = interfaceLinkLabel(entry.connected); - const value = `${entry.ip} (${link})`; - return snapshotRow(entry.name, value); - }).join(''); +function formatNetworkInterface(label, iface) { + if (!iface || typeof iface !== 'object') { + return ''; + } + const ip = + stringValue(iface.ipAddress) + || stringValue(iface.ip_address) + || stringValue(iface.ip) + || 'no IP'; + const mac = + stringValue(iface.macAddress) + || stringValue(iface.mac_address) + || stringValue(iface.mac); + let connected = iface.isConnected; + if (connected === undefined) { + connected = iface.is_connected; + } + if (connected === undefined) { + connected = iface.connected; + } + const rows = [snapshotRow(`${label} IP`, ip)]; + if (mac) { + rows.push(snapshotRow(`${label} MAC`, mac)); + } + rows.push(snapshotRow(`${label} status`, interfaceLinkLabel(connected))); + return rows.join(''); +} + +function formatNetworkData(data) { + if (!data || typeof data !== 'object') { + return snapshotRow('Network', 'None reported'); + } + const { ethernet, wifi } = data; + const parts = []; + if (ethernet) { + parts.push(formatNetworkInterface('Ethernet', ethernet)); + } + if (wifi) { + parts.push(formatNetworkInterface('Wi-Fi', wifi)); + } + if (parts.length === 0) { + return snapshotRow('Network', 'None reported'); + } + return parts.join(''); } // TinyPilot ships two streaming modes today: H.264 and MJPEG. Normalize the @@ -156,7 +163,7 @@ export function formatExpandedSnapshot(snapshot) { const https = expanded.https_requirement?.status || {}; const video = expanded.video_settings?.status || {}; - const interfaces = expanded.network?.interfaces || []; + const networkData = expanded.network?.data || {}; const videoTuning = formatVideoTuning(video, status); const resolutionRow = snapshotRow( 'Connected device resolution', @@ -175,7 +182,7 @@ export function formatExpandedSnapshot(snapshot) {

Network interfaces

- ${formatInterfaces(interfaces)} + ${formatNetworkData(networkData)}

Video

diff --git a/app/templates/index.html b/app/templates/index.html index 8bd76d6..21ca0c4 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,7 +1,7 @@ - {% set asset_v = '20260511-15' %} + {% set asset_v = '20260615-02' %} TinyPilot Dashboard diff --git a/app/tinypilot_client.py b/app/tinypilot_client.py index 6dca662..4231f44 100644 --- a/app/tinypilot_client.py +++ b/app/tinypilot_client.py @@ -80,6 +80,25 @@ def get_network_status(self): response.raise_for_status() return response.json() + def _raise_for_status(self, response: requests.Response) -> None: + """Raise an HTTPError, including TinyPilot's error message when present. + + TinyPilot error responses carry ``{"message": "...", "code": ...}``. + Surfacing that message makes failures much easier to diagnose. + """ + if response.ok: + return + device_message = '' + try: + body = response.json() + device_message = body.get('message') or '' + except ValueError: + pass + detail = f'{response.status_code} {response.reason}' + if device_message: + detail = f'{detail}: {device_message}' + raise requests.HTTPError(detail, response=response) + def _get_json(self, path: str) -> dict[str, Any]: warmup = self.session.get(self.base_url, timeout=10) warmup.raise_for_status() @@ -88,9 +107,84 @@ def _get_json(self, path: str) -> dict[str, Any]: # Automatically recover from stale session/csrf by reloading the WebUI token once. self.refresh_csrf_token() response = self.session.get(f'{self.base_url}{path}', timeout=10) - response.raise_for_status() + self._raise_for_status(response) return response.json() + def _put_json( + self, + path: str, + body: Optional[dict] = None, + params: Optional[dict] = None, + ) -> dict[str, Any]: + """Send a PUT request with CSRF header. Retries once on 401/403.""" + warmup = self.session.get(self.base_url, timeout=10) + warmup.raise_for_status() + csrf_token = None + csrf_match = re.search( + r'', + warmup.text, + ) + if csrf_match: + csrf_token = csrf_match.group(1) + headers = {'X-CSRFToken': csrf_token} if csrf_token else {} + response = self.session.put( + f'{self.base_url}{path}', + json=body, + params=params, + headers=headers, + timeout=30, + ) + if response.status_code in (401, 403): + new_csrf = self.refresh_csrf_token() + retry_headers = ( + {'X-CSRFToken': new_csrf} + if new_csrf and new_csrf != 'session-cookie' + else {} + ) + response = self.session.put( + f'{self.base_url}{path}', + json=body, + params=params, + headers=retry_headers, + timeout=30, + ) + self._raise_for_status(response) + return response.json() if response.content else {} + + def get_mass_storage(self) -> dict[str, Any]: + """Return backing files, intermediate files, and current mount mode.""" + return self._get_json('/api/massStorage/backingFiles') + + def get_mass_storage_filename_from_url(self, url: str) -> str: + """Resolve or generate a backing file name from a download URL.""" + warmup = self.session.get(self.base_url, timeout=10) + warmup.raise_for_status() + response = self.session.get( + f'{self.base_url}/api/massStorage/retrieveFileNameFromUrl', + params={'url': url}, + timeout=10, + ) + response.raise_for_status() + return response.json()['fileName'] + + def fetch_mass_storage_from_url(self, filename: str, url: str) -> None: + """Tell the device to download an image from a URL and store it as `filename`.""" + self._put_json( + f'/api/massStorage/backingFiles/{filename}/fetchFromUrl', + body={'url': url}, + ) + + def mount_mass_storage(self, filename: str, mode: str) -> None: + """Mount `filename` in the given mode (CDROM, FLASH_READ_ONLY, FLASH_READ_WRITE).""" + self._put_json( + f'/api/massStorage/mount/{filename}', + params={'mode': mode}, + ) + + def eject_mass_storage(self) -> None: + """Eject the currently mounted image.""" + self._put_json('/api/massStorage/eject') + def get_status(self) -> dict[str, Any]: return self._get_json('/api/status') diff --git a/deploy/cloud-init.sh b/deploy/cloud-init.sh new file mode 100755 index 0000000..f1a17b4 --- /dev/null +++ b/deploy/cloud-init.sh @@ -0,0 +1,132 @@ +#!/bin/bash + +# TinyPilot Dashboard setup script for DigitalOcean. +# Paste the contents of this file into the "User Data" field when creating +# a DigitalOcean Ubuntu 24.04 droplet (Advanced Options → User Data). +# +# To enable Tailscale (strongly recommended), set TAILSCALE_AUTH_KEY to a +# reusable auth key. Generate one at: +# https://login.tailscale.com/admin/settings/keys +# +# With Tailscale: dashboard accessible at https://..ts.net +# Without Tailscale: dashboard accessible via SSH tunnel only +# (ssh -L 8080:localhost:8080 root@) + +set -e +set -u +set -x + +# Set your Tailscale auth key here. Leave blank to skip Tailscale setup. +TAILSCALE_AUTH_KEY="" +readonly TAILSCALE_AUTH_KEY + +readonly INSTALL_DIR="/opt/tinypilot-dashboard" +readonly UPDATE_SCRIPT="/usr/local/bin/tinypilot-dashboard-update" +readonly UPDATE_SERVICE="/etc/systemd/system/tinypilot-dashboard-update.service" +readonly UPDATE_TIMER="/etc/systemd/system/tinypilot-dashboard-update.timer" + +# Install Docker Engine from the official Docker apt repository. +apt-get update --quiet +apt-get install \ + --yes \ + --quiet \ + ca-certificates \ + curl \ + git +install --mode=0755 --directory /etc/apt/keyrings +curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --output /etc/apt/keyrings/docker.asc \ + https://download.docker.com/linux/ubuntu/gpg +chmod a+r /etc/apt/keyrings/docker.asc +# shellcheck disable=SC1091 +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ + https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "${UBUNTU_CODENAME:-${VERSION_CODENAME}}") stable" \ + | tee /etc/apt/sources.list.d/docker.list > /dev/null +apt-get update --quiet +apt-get install \ + --yes \ + --quiet \ + docker-ce \ + docker-ce-cli \ + containerd.io \ + docker-buildx-plugin \ + docker-compose-plugin + +# Clone the dashboard repository and build the Docker image. +git clone https://github.com/tiny-pilot/tinypilot-dashboard.git "${INSTALL_DIR}" +cd "${INSTALL_DIR}" +docker compose up --detach --build + +# Write the update script that customers can run manually or that the +# systemd timer invokes each night. +cat > "${UPDATE_SCRIPT}" << 'EOF' +#!/bin/bash + +# Updates TinyPilot Dashboard to the latest version from GitHub. +# The dashboard is unavailable for approximately 30 seconds while the new +# image builds. + +set -e +set -u +set -x + +readonly INSTALL_DIR="/opt/tinypilot-dashboard" + +cd "${INSTALL_DIR}" +git pull --ff-only +docker compose up --detach --build +EOF +chmod 0755 "${UPDATE_SCRIPT}" + +# Install a systemd service and daily timer that auto-update the dashboard. +cat > "${UPDATE_SERVICE}" << 'EOF' +[Unit] +Description=TinyPilot Dashboard auto-update +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/tinypilot-dashboard-update +EOF + +cat > "${UPDATE_TIMER}" << 'EOF' +[Unit] +Description=TinyPilot Dashboard daily auto-update timer + +[Timer] +OnCalendar=*-*-* 03:00:00 +Persistent=true + +[Install] +WantedBy=timers.target +EOF + +systemctl daemon-reload +systemctl enable --now tinypilot-dashboard-update.timer + +# Configure ufw: allow SSH, block everything else. Port 8080 is never +# opened on the public internet regardless of Tailscale choice. +ufw allow OpenSSH +ufw --force enable + +# Set up Tailscale if an auth key was provided. +if [[ -n "${TAILSCALE_AUTH_KEY}" ]]; then + curl \ + --fail \ + --silent \ + --show-error \ + --location \ + https://tailscale.com/install.sh \ + | sh + tailscale up \ + --authkey="${TAILSCALE_AUTH_KEY}" \ + --ssh + tailscale serve --bg http://localhost:8080 +fi diff --git a/docs/superpowers/plans/2026-05-19-digitalocean-deployment.md b/docs/superpowers/plans/2026-05-19-digitalocean-deployment.md new file mode 100644 index 0000000..6f1a4a4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-digitalocean-deployment.md @@ -0,0 +1,481 @@ +# DigitalOcean Deployment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a single cloud-init bash script customers paste into DigitalOcean droplet creation, which installs Docker, clones the repo, starts the dashboard, configures the firewall, optionally joins Tailscale, and enables a daily auto-update timer. + +**Architecture:** A standalone bash script (`deploy/cloud-init.sh`) is the only new file. It is pasted by the customer into DigitalOcean's User Data field at droplet creation. The script runs once on first boot as root. All infrastructure knowledge lives in this file; the Flask app and Docker Compose config are unchanged. + +**Tech Stack:** Bash (Google Shell style + TinyPilot conventions), Docker Compose, `ufw`, `systemd`, Tailscale CLI, GitHub Actions (ShellCheck). + +--- + +## Style constraints (TinyPilot shell conventions) + +Apply these throughout `deploy/cloud-init.sh` and any embedded scripts: + +- `set -e`, `set -u`, `set -x` at the top of every bash script. +- Long flag names: `--yes` not `-y`, `--detach` not `-d`, `--quiet` not `-q`. +- `UPPERCASE` constants declared with `readonly` just before first use. +- Comments: capital first letter, trailing period. +- `(( ... ))` for numerical comparisons. +- Here-documents use `EOF` as the delimiter. +- Error messages to `>&2 echo`; no "ERROR:" prefix; sentence casing; no trailing punctuation. +- ShellCheck must pass with no warnings. + +--- + +## File map + +| Action | Path | Purpose | +|---|---|---| +| Create | `deploy/cloud-init.sh` | User-data script for DigitalOcean droplet creation | +| Create | `.github/workflows/shellcheck.yml` | CI: lint all shell scripts on every push/PR | +| Modify | `README.md` | Replace flat `## Install` with `## Deploy on DigitalOcean` + `## Run locally` | + +--- + +## Task 1: Create `deploy/cloud-init.sh` + +**Files:** +- Create: `deploy/cloud-init.sh` + +- [ ] **Step 1: Create the deploy/ directory and write the script** + +```bash +mkdir deploy +``` + +Write `deploy/cloud-init.sh` with the following exact content: + +```bash +#!/bin/bash + +# TinyPilot Dashboard setup script for DigitalOcean. +# Paste the contents of this file into the "User Data" field when creating +# a DigitalOcean Ubuntu 24.04 droplet (Advanced Options → User Data). +# +# To enable Tailscale (strongly recommended), set TAILSCALE_AUTH_KEY to a +# reusable auth key. Generate one at: +# https://login.tailscale.com/admin/settings/keys +# +# With Tailscale: dashboard accessible at https://..ts.net +# Without Tailscale: dashboard accessible via SSH tunnel only +# (ssh -L 8080:localhost:8080 root@) + +set -e +set -u +set -x + +# Set your Tailscale auth key here. Leave blank to skip Tailscale setup. +TAILSCALE_AUTH_KEY="" +readonly TAILSCALE_AUTH_KEY + +readonly INSTALL_DIR="/opt/tinypilot-dashboard" +readonly UPDATE_SCRIPT="/usr/local/bin/tinypilot-dashboard-update" +readonly UPDATE_SERVICE="/etc/systemd/system/tinypilot-dashboard-update.service" +readonly UPDATE_TIMER="/etc/systemd/system/tinypilot-dashboard-update.timer" + +# Install Docker Engine from the official Docker apt repository. +apt-get update --quiet +apt-get install \ + --yes \ + --quiet \ + ca-certificates \ + curl \ + git +install --mode=0755 --directory /etc/apt/keyrings +curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --output /etc/apt/keyrings/docker.asc \ + https://download.docker.com/linux/ubuntu/gpg +chmod a+r /etc/apt/keyrings/docker.asc +# shellcheck disable=SC1091 +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ + https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" \ + | tee /etc/apt/sources.list.d/docker.list > /dev/null +apt-get update --quiet +apt-get install \ + --yes \ + --quiet \ + docker-ce \ + docker-ce-cli \ + containerd.io \ + docker-buildx-plugin \ + docker-compose-plugin + +# Clone the dashboard repository and build the Docker image. +git clone https://github.com/tiny-pilot/tinypilot-dashboard.git "${INSTALL_DIR}" +cd "${INSTALL_DIR}" +docker compose up --detach --build + +# Write the update script that customers can run manually or that the +# systemd timer invokes each night. +cat > "${UPDATE_SCRIPT}" << 'EOF' +#!/bin/bash + +# Updates TinyPilot Dashboard to the latest version from GitHub. +# The dashboard is unavailable for approximately 30 seconds while the new +# image builds. + +set -e +set -u + +readonly INSTALL_DIR="/opt/tinypilot-dashboard" + +cd "${INSTALL_DIR}" +git pull +docker compose up --detach --build +EOF +chmod 0755 "${UPDATE_SCRIPT}" + +# Install a systemd service and daily timer that auto-update the dashboard. +cat > "${UPDATE_SERVICE}" << 'EOF' +[Unit] +Description=TinyPilot Dashboard auto-update + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/tinypilot-dashboard-update +EOF + +cat > "${UPDATE_TIMER}" << 'EOF' +[Unit] +Description=TinyPilot Dashboard daily auto-update timer + +[Timer] +OnCalendar=*-*-* 03:00:00 +Persistent=true + +[Install] +WantedBy=timers.target +EOF + +systemctl daemon-reload +systemctl enable --now tinypilot-dashboard-update.timer + +# Configure ufw: allow SSH, block everything else. Port 8080 is never +# opened on the public internet regardless of Tailscale choice. +ufw allow OpenSSH +ufw --force enable + +# Set up Tailscale if an auth key was provided. +if [[ -n "${TAILSCALE_AUTH_KEY}" ]]; then + curl \ + --fail \ + --silent \ + --show-error \ + --location \ + https://tailscale.com/install.sh \ + | sh + tailscale up \ + --authkey="${TAILSCALE_AUTH_KEY}" \ + --ssh + tailscale serve http://localhost:8080 +fi +``` + +- [ ] **Step 2: Make the script executable** + +```bash +chmod 0755 deploy/cloud-init.sh +``` + +- [ ] **Step 3: Run ShellCheck locally to verify no warnings** + +```bash +shellcheck deploy/cloud-init.sh +``` + +Expected: no output (zero warnings, zero errors). + +If ShellCheck is not installed: +```bash +brew install shellcheck # macOS +# or +apt-get install shellcheck # Ubuntu +``` + +Common fixes if ShellCheck warns: +- SC2086 (word splitting): quote variables → `"${VAR}"` not `${VAR}` +- SC2046 (word splitting in command substitution): already handled by quoting +- SC1091 (not following sourced file): add `# shellcheck disable=SC1091` above the `/etc/os-release` source line + +- [ ] **Step 4: Commit** + +```bash +git add deploy/cloud-init.sh +git commit -m "Add DigitalOcean cloud-init setup script" +``` + +--- + +## Task 2: Add ShellCheck GitHub Actions workflow + +Per TinyPilot style guide: all repositories with shell scripts must run +ShellCheck in CI. + +**Files:** +- Create: `.github/workflows/shellcheck.yml` + +- [ ] **Step 1: Write the workflow** + +Write `.github/workflows/shellcheck.yml`: + +```yaml +name: ShellCheck + +on: + push: + pull_request: + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run ShellCheck + uses: ludeeus/action-shellcheck@master + with: + scandir: './deploy' +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/shellcheck.yml +git commit -m "Add ShellCheck CI for deploy/ shell scripts" +``` + +--- + +## Task 3: Update README.md + +Replace the flat `## Install` section with two parallel sections: +`## Deploy on DigitalOcean` and `## Run locally`. Both are first-class +options — no hierarchy, no "recommended" framing. + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Replace the `## Install` section** + +Find this block in `README.md`: + +```markdown +## Install + +1. Clone or download this repository. +2. From the repository directory, start the dashboard: + + ```bash + docker compose up -d + ``` + +3. Open **[http://localhost:8080](http://localhost:8080)** in your browser. + +To stop the dashboard: + +```bash +docker compose down +``` + +Compose binds to `127.0.0.1:8080` on the host by default, so the UI is reachable only from the machine running it. To expose it to other hosts on your trusted network, see [Deployment & security](#deployment--security). +``` + +Replace it with: + +```markdown +## Deploy on DigitalOcean + +For always-on access from anywhere on your Tailscale network, run the +dashboard on a DigitalOcean droplet. All droplet sizes run identical +software — choose based on your workload: + +| Use case | Size | Cost | +|---|---|---| +| Dashboard only, light use | `s-1vcpu-1gb` | $6/mo | +| Dashboard only, comfortable | `s-1vcpu-2gb` | $12/mo | +| Dashboard + heavier polling | `s-2vcpu-4gb` | $24/mo | +| Dashboard + AI agent (Cursor/Claude Code) | `s-4vcpu-8gb` | $48/mo | + +Droplets can be resized in the DigitalOcean control panel at any time +without data loss. + +### Steps + +1. [Create a DigitalOcean droplet](https://cloud.digitalocean.com/droplets/new) + running **Ubuntu 24.04 LTS**. +2. In **Advanced Options → User Data**, paste the contents of + [`deploy/cloud-init.sh`](deploy/cloud-init.sh) from this repository. +3. At the top of the pasted script, set your Tailscale auth key + (see below). Leave it blank to skip Tailscale. +4. Click **Create Droplet**. The dashboard starts automatically during + first boot (~5 minutes). + +### Tailscale (strongly recommended) + +[Generate a reusable auth key](https://login.tailscale.com/admin/settings/keys) +in your Tailscale admin console, then set it at the top of the +cloud-init script: + +``` +TAILSCALE_AUTH_KEY="tskey-auth-xxxxxxxx" +``` + +Once the droplet boots, the dashboard is available at +`https://..ts.net` — accessible only to devices +on your Tailscale network, with HTTPS provided automatically. + +**Without Tailscale:** the dashboard is localhost-only. Access it via +SSH tunnel (`ssh -L 8080:localhost:8080 root@`) and open +`http://localhost:8080`. For additional server hardening, see +DigitalOcean's [Initial Server Setup guide](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu). + +### Updates + +The droplet auto-updates the dashboard daily at 3am. No action required. + +## Run locally + +For running on a machine that is already on the same network as your +TinyPilot devices: + +1. Clone or download this repository. +2. From the repository directory, start the dashboard: + + ```bash + docker compose up -d + ``` + +3. Open **[http://localhost:8080](http://localhost:8080)** in your browser. + +To stop the dashboard: + +```bash +docker compose down +``` + +Compose binds to `127.0.0.1:8080` on the host by default, so the UI is +reachable only from the machine running it. To expose it to other hosts +on your trusted network, see [Deployment & security](#deployment--security). +``` + +- [ ] **Step 2: Verify the README renders correctly** + +Open `README.md` in a Markdown previewer (GitHub preview or your IDE's +preview) and confirm: + +- The sizing table renders correctly. +- The two code blocks (Tailscale auth key, `docker compose up -d`) both + render as code blocks and are not broken by the surrounding fenced + code blocks. +- All links resolve (hover to confirm). +- `## Deploy on DigitalOcean` and `## Run locally` appear as sibling + headings with no visual hierarchy between them. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "Add DigitalOcean deployment instructions to README" +``` + +--- + +## Task 4: Copy changes to staging repo and verify + +The public-facing code lives in the staging repo at +`/Users/shalver/Downloads/tinypilot-dashboard-public/`. Sync the new +files there and verify the tests still pass. + +- [ ] **Step 1: Copy new/changed files to the staging repo** + +```bash +cp -r \ + /Users/shalver/Downloads/tinypilot-dashboard/.worktrees/poc-dashboard/deploy \ + /Users/shalver/Downloads/tinypilot-dashboard-public/ + +cp \ + /Users/shalver/Downloads/tinypilot-dashboard/.worktrees/poc-dashboard/.github/workflows/shellcheck.yml \ + /Users/shalver/Downloads/tinypilot-dashboard-public/.github/workflows/ + +cp \ + /Users/shalver/Downloads/tinypilot-dashboard/.worktrees/poc-dashboard/README.md \ + /Users/shalver/Downloads/tinypilot-dashboard-public/ +``` + +- [ ] **Step 2: Run tests in the staging repo** + +```bash +cd /Users/shalver/Downloads/tinypilot-dashboard-public +pytest -q +``` + +Expected: all 33 tests pass, 1 warning. + +- [ ] **Step 3: Run ShellCheck in the staging repo** + +```bash +shellcheck /Users/shalver/Downloads/tinypilot-dashboard-public/deploy/cloud-init.sh +``` + +Expected: no output. + +- [ ] **Step 4: Commit and push to GitHub** + +```bash +cd /Users/shalver/Downloads/tinypilot-dashboard-public +git add deploy/ .github/workflows/shellcheck.yml README.md +git status # confirm only expected files are staged +git commit -m "Add DigitalOcean one-droplet deployment" +git push origin master +``` + +--- + +## Post-implementation smoke test (manual, on a real droplet) + +After the code is pushed, verify the full customer journey: + +1. Create a fresh DigitalOcean Ubuntu 24.04 droplet (any size). +2. Paste `deploy/cloud-init.sh` into User Data with a valid Tailscale + auth key. +3. Wait ~5 minutes after the droplet boots. +4. Confirm the droplet appears in your Tailscale admin console. +5. Open `https://..ts.net` in a browser — the + TinyPilot Dashboard should load and show the `v0.1.0` version badge. +6. Add a TinyPilot device and confirm a screenshot loads. +7. Check the auto-update timer is active: + ```bash + systemctl status tinypilot-dashboard-update.timer + ``` + Expected: `active (waiting)`. +8. Verify port 8080 is NOT reachable on the public IP: + ```bash + curl --max-time 5 http://:8080 + ``` + Expected: connection timeout or refused. + +--- + +## Self-review checklist + +- [x] `deploy/cloud-init.sh` covers all spec steps: Docker install, git + clone, docker compose up, update script, systemd timer, ufw, Tailscale. +- [x] Port 8080 is never opened in ufw in either Tailscale or non-Tailscale + paths. +- [x] Data volume (`/opt/tinypilot-dashboard/data/`) persists across + auto-updates (bind mount in existing docker-compose.yml is unchanged). +- [x] ShellCheck CI added per TinyPilot style guide requirement. +- [x] README presents both deployment options as equals. +- [x] Sizing table in README matches spec. +- [x] No Docker socket mounted (removed from earlier design iteration). +- [x] All bash follows TinyPilot conventions: `set -e/u/x`, long flags, + uppercase constants, comments with trailing punctuation. +- [x] No `INTERNAL.md` or `poc` references introduced. diff --git a/docs/superpowers/plans/2026-06-15-v012-virtual-media-and-flicker-fix.md b/docs/superpowers/plans/2026-06-15-v012-virtual-media-and-flicker-fix.md new file mode 100644 index 0000000..24c0d20 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-v012-virtual-media-and-flicker-fix.md @@ -0,0 +1,1053 @@ +# TinyPilot Dashboard v0.1.2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Virtual Media section to each device card (mount/eject/fetch-from-URL) and fix screenshot flicker by holding the last frame until the new one is pixel-ready. + +**Architecture:** Virtual media is a new collapsible `
` section in `device-card.js`, peer to "Connected System". Backend adds four new proxy routes in `api.py` that delegate to five new methods on `TinyPilotClient`. The flicker fix is a pure JS change: pre-load new screenshots into a temporary `Image` object before swapping ``. A cleanup task fixes a network-mapping bug and removes dead code. + +**Tech Stack:** Python/Flask (backend routes), `requests` (TinyPilot HTTP client), vanilla ES modules + web components (frontend), SQLite (unchanged — no schema changes needed). + +**Working directory:** `/Users/shalver/Downloads/tinypilot-dashboard/.worktrees/poc-dashboard` + +**Run tests with:** `pytest -q` (from working directory) + +--- + +## Task 1: Fix screenshot flicker + +**Files:** +- Modify: `app/static/js/components/device-card.js` (lines 144–207) + +The flicker is caused by `screenshot.removeAttribute('src')` at line 154, which blanks the image before the new one loads. The fix pre-loads the new image into an off-screen `Image` object and only swaps `screenshot.src` once the new image is ready. + +- [ ] **Step 1: Replace `refreshScreenshot` with the flicker-free version** + +Open `app/static/js/components/device-card.js` and replace the entire `refreshScreenshot` method (lines 144–207) with: + +```js +async refreshScreenshot(capturedAtByDevice) { + if (!this._device) { + return; + } + const id = this._device.id; + const { screenshot, link, connectedStatus } = this._elements; + + // Do NOT clear screenshot.src here — keep showing the current frame + // until the replacement is ready to avoid a visible blank state. + 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)}.`; + } + } + + if (result.error || !screenshot) { + return; + } + + 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; + }; + + // Pre-load into an off-screen Image. Only swap the displayed src + // after the browser has fully decoded the new frame so it never goes blank. + const preload = new Image(); + preload.onload = () => { + screenshot.src = busted; + activateScreenshotLink(); + if (connectedStatus) { + connectedStatus.textContent = `Screenshot refreshed ${formatRelativeTime(result.captured_at)}.`; + } + }; + preload.onerror = () => { + // Keep the current screenshot; show a status note. + if (connectedStatus) { + connectedStatus.textContent = 'Screenshot failed to load (image error).'; + } + 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(); + } +} +``` + +- [ ] **Step 2: Verify tests still pass** + +```bash +pytest -q +``` + +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add app/static/js/components/device-card.js +git commit -m "Fix screenshot flicker by pre-loading before swapping src" +``` + +--- + +## Task 2: TinyPilotClient — virtual media methods + +**Files:** +- Modify: `app/tinypilot_client.py` +- Create: `tests/test_tinypilot_client.py` + +Add five new methods to `TinyPilotClient` and a `_put_json` helper. All mass storage endpoints are Web UI session-based (ADMIN role). For the alpha (no Web UI passwords), CSRF tokens may not be strictly enforced, but we include a CSRF header as best practice. + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_tinypilot_client.py`: + +```python +"""Unit tests for TinyPilotClient virtual media methods.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from app.tinypilot_client import TinyPilotClient + + +@pytest.fixture +def client(): + return TinyPilotClient('https://device.local') + + +def _mock_session(client, responses): + """Attach a mock session that returns `responses` in order.""" + mock_session = MagicMock() + mock_session.get.return_value = responses[0] if len(responses) == 1 else MagicMock( + raise_for_status=MagicMock(), + text='', + status_code=200, + ) + mock_session.put.return_value = responses[-1] + client.session = mock_session + return mock_session + + +def make_response(json_data=None, status_code=200, content=b''): + r = MagicMock() + r.status_code = status_code + r.json.return_value = json_data or {} + r.content = content + r.raise_for_status = MagicMock() + return r + + +def test_get_mass_storage_returns_backing_files_and_mount_mode(client): + warmup = make_response() + warmup.text = '' + api_response = make_response({ + 'backingFiles': [{'name': 'ubuntu.iso', 'isMounted': True, 'loadedBytes': 1000, 'totalBytes': 1000}], + 'intermediateFiles': [], + 'mountMode': 'CDROM', + }) + mock_session = MagicMock() + mock_session.get.side_effect = [warmup, api_response] + client.session = mock_session + + result = client.get_mass_storage() + + assert result['mountMode'] == 'CDROM' + assert result['backingFiles'][0]['name'] == 'ubuntu.iso' + + +def test_get_mass_storage_filename_from_url_returns_filename(client): + warmup = make_response() + warmup.text = '' + api_response = make_response({'fileName': 'ubuntu-24.04.iso'}) + mock_session = MagicMock() + mock_session.get.side_effect = [warmup, api_response] + client.session = mock_session + + result = client.get_mass_storage_filename_from_url('https://example.com/ubuntu.iso') + + assert result == 'ubuntu-24.04.iso' + + +def test_mount_mass_storage_sends_correct_path_and_mode(client): + warmup = make_response() + warmup.text = '' + put_response = make_response({'success': True}) + mock_session = MagicMock() + mock_session.get.return_value = warmup + mock_session.put.return_value = put_response + client.session = mock_session + + client.mount_mass_storage('ubuntu.iso', 'CDROM') + + call_args = mock_session.put.call_args + assert 'massStorage/mount/ubuntu.iso' in call_args[0][0] + assert call_args[1]['params']['mode'] == 'CDROM' + + +def test_eject_mass_storage_calls_eject_endpoint(client): + warmup = make_response() + warmup.text = '' + put_response = make_response() + put_response.content = b'' + mock_session = MagicMock() + mock_session.get.return_value = warmup + mock_session.put.return_value = put_response + client.session = mock_session + + client.eject_mass_storage() + + call_args = mock_session.put.call_args + assert 'massStorage/eject' in call_args[0][0] +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +pytest tests/test_tinypilot_client.py -v +``` + +Expected: `AttributeError: 'TinyPilotClient' object has no attribute 'get_mass_storage'` (or similar). + +- [ ] **Step 3: Add `_put_json` helper and five new methods to `TinyPilotClient`** + +In `app/tinypilot_client.py`, add after `_get_json` (after line 92): + +```python +def _put_json( + self, + path: str, + body: Optional[dict] = None, + params: Optional[dict] = None, +) -> dict[str, Any]: + """Send a PUT request with CSRF header. Retries once on 401/403.""" + warmup = self.session.get(self.base_url, timeout=10) + warmup.raise_for_status() + csrf_token = None + csrf_match = re.search( + r'', + warmup.text, + ) + if csrf_match: + csrf_token = csrf_match.group(1) + headers = {'X-CSRFToken': csrf_token} if csrf_token else {} + response = self.session.put( + f'{self.base_url}{path}', + json=body, + params=params, + headers=headers, + timeout=30, + ) + if response.status_code in (401, 403): + self.refresh_csrf_token() + response = self.session.put( + f'{self.base_url}{path}', + json=body, + params=params, + headers=headers, + timeout=30, + ) + response.raise_for_status() + return response.json() if response.content else {} + +def get_mass_storage(self) -> dict[str, Any]: + """Return backing files, intermediate files, and current mount mode.""" + return self._get_json('/api/massStorage/backingFiles') + +def get_mass_storage_filename_from_url(self, url: str) -> str: + """Resolve or generate a backing file name from a download URL.""" + warmup = self.session.get(self.base_url, timeout=10) + warmup.raise_for_status() + response = self.session.get( + f'{self.base_url}/api/massStorage/retrieveFileNameFromUrl', + params={'url': url}, + timeout=10, + ) + response.raise_for_status() + return response.json()['fileName'] + +def fetch_mass_storage_from_url(self, filename: str, url: str) -> None: + """Tell the device to download an image from a URL and store it as `filename`.""" + self._put_json( + f'/api/massStorage/backingFiles/{filename}/fetchFromUrl', + body={'url': url}, + ) + +def mount_mass_storage(self, filename: str, mode: str) -> None: + """Mount `filename` in the given mode (CDROM, FLASH_READ_ONLY, FLASH_READ_WRITE).""" + self._put_json( + f'/api/massStorage/mount/{filename}', + params={'mode': mode}, + ) + +def eject_mass_storage(self) -> None: + """Eject the currently mounted image.""" + self._put_json('/api/massStorage/eject') +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +pytest tests/test_tinypilot_client.py -v +``` + +Expected: all 5 tests pass. + +- [ ] **Step 5: Run full test suite to confirm nothing regressed** + +```bash +pytest -q +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add app/tinypilot_client.py tests/test_tinypilot_client.py +git commit -m "Add virtual media methods to TinyPilotClient" +``` + +--- + +## Task 3: Dashboard backend — virtual media routes + +**Files:** +- Modify: `app/api.py` +- Modify: `tests/test_api_connections.py` + +Add four new routes: `GET /media`, `POST /media/fetch`, `PUT /media/mount`, `PUT /media/eject`. All use the same device-lookup + client pattern already established in `api.py`. + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_api_connections.py`: + +```python +def _create_device(client, base_url='https://192.168.1.200'): + """Helper: create a device with no automation token.""" + with patch('app.api.TinyPilotClient') as cls: + cls.return_value.refresh_automation_token.side_effect = RuntimeError('no device') + resp = client.post('/api/devices', json={ + 'friendly_name': 'Media Test Device', + 'base_url': base_url, + }) + return resp.json['device']['id'] + + +def test_get_media_returns_backing_files(client): + device_id = _create_device(client) + backing_files_response = { + 'backingFiles': [{'name': 'ubuntu.iso', 'isMounted': True, 'loadedBytes': 100, 'totalBytes': 100}], + 'intermediateFiles': [], + 'mountMode': 'CDROM', + } + with patch('app.api.TinyPilotClient') as cls: + cls.return_value.get_mass_storage.return_value = backing_files_response + response = client.get(f'/api/devices/{device_id}/media') + + assert response.status_code == 200 + assert response.json['backingFiles'][0]['name'] == 'ubuntu.iso' + assert response.json['mountMode'] == 'CDROM' + + +def test_get_media_returns_404_for_unknown_device(client): + response = client.get('/api/devices/9999/media') + assert response.status_code == 404 + + +def test_media_fetch_triggers_device_download(client): + device_id = _create_device(client) + with patch('app.api.TinyPilotClient') as cls: + cls.return_value.get_mass_storage_filename_from_url.return_value = 'ubuntu.iso' + cls.return_value.fetch_mass_storage_from_url.return_value = None + response = client.post( + f'/api/devices/{device_id}/media/fetch', + json={'url': 'https://example.com/ubuntu.iso'}, + ) + + assert response.status_code == 200 + assert response.json['fileName'] == 'ubuntu.iso' + + +def test_media_fetch_returns_400_without_url(client): + device_id = _create_device(client) + response = client.post(f'/api/devices/{device_id}/media/fetch', json={}) + assert response.status_code == 400 + + +def test_media_mount_calls_device(client): + device_id = _create_device(client) + with patch('app.api.TinyPilotClient') as cls: + cls.return_value.mount_mass_storage.return_value = None + response = client.put( + f'/api/devices/{device_id}/media/mount', + json={'fileName': 'ubuntu.iso', 'mode': 'CDROM'}, + ) + + assert response.status_code == 200 + cls.return_value.mount_mass_storage.assert_called_once_with('ubuntu.iso', 'CDROM') + + +def test_media_mount_returns_400_without_required_fields(client): + device_id = _create_device(client) + response = client.put(f'/api/devices/{device_id}/media/mount', json={'fileName': 'ubuntu.iso'}) + assert response.status_code == 400 + + +def test_media_eject_calls_device(client): + device_id = _create_device(client) + with patch('app.api.TinyPilotClient') as cls: + cls.return_value.eject_mass_storage.return_value = None + response = client.put(f'/api/devices/{device_id}/media/eject') + + assert response.status_code == 200 + cls.return_value.eject_mass_storage.assert_called_once() +``` + +- [ ] **Step 2: Run new tests to confirm they fail** + +```bash +pytest tests/test_api_connections.py -k "media" -v +``` + +Expected: `404 NOT FOUND` on all (routes don't exist yet). + +- [ ] **Step 3: Add four new routes to `app/api.py`** + +Append to `app/api.py` (before the final newline): + +```python +@api_blueprint.get('/devices//media') +def get_device_media(device_id: int): + """Return current virtual media state (backing files + mount mode) for a device.""" + row = _device_row_with_web_auth(device_id) + if row is None: + return jsonify({'error': 'device not found'}), 404 + key_path = Path(current_app.config['SECRET_KEY_PATH']) + http_basic = _http_basic_from_auth_row(key_path, row) + client = TinyPilotClient(row['base_url'], http_basic=http_basic) + try: + result = client.get_mass_storage() + except Exception as err: # pylint: disable=broad-exception-caught + return jsonify({'error': f'failed to fetch media state: {err}'}), 502 + return jsonify(result) + + +@api_blueprint.post('/devices//media/fetch') +def fetch_device_media_from_url(device_id: int): + """Tell the device to download an image from a URL.""" + row = _device_row_with_web_auth(device_id) + if row is None: + return jsonify({'error': 'device not found'}), 404 + payload = request.get_json(silent=True) or {} + url = (payload.get('url') or '').strip() + if not url: + return jsonify({'error': 'url is required'}), 400 + key_path = Path(current_app.config['SECRET_KEY_PATH']) + http_basic = _http_basic_from_auth_row(key_path, row) + client = TinyPilotClient(row['base_url'], http_basic=http_basic) + try: + file_name = client.get_mass_storage_filename_from_url(url) + client.fetch_mass_storage_from_url(file_name, url) + except Exception as err: # pylint: disable=broad-exception-caught + return jsonify({'error': f'failed to fetch image from URL: {err}'}), 502 + return jsonify({'device_id': device_id, 'fileName': file_name}) + + +@api_blueprint.put('/devices//media/mount') +def mount_device_media(device_id: int): + """Mount a backing file on the device.""" + row = _device_row_with_web_auth(device_id) + if row is None: + return jsonify({'error': 'device not found'}), 404 + payload = request.get_json(silent=True) or {} + file_name = (payload.get('fileName') or '').strip() + mode = (payload.get('mode') or '').strip() + if not file_name or not mode: + return jsonify({'error': 'fileName and mode are required'}), 400 + key_path = Path(current_app.config['SECRET_KEY_PATH']) + http_basic = _http_basic_from_auth_row(key_path, row) + client = TinyPilotClient(row['base_url'], http_basic=http_basic) + try: + client.mount_mass_storage(file_name, mode) + except Exception as err: # pylint: disable=broad-exception-caught + return jsonify({'error': f'failed to mount image: {err}'}), 502 + return jsonify({'device_id': device_id, 'mounted': True, 'fileName': file_name, 'mode': mode}) + + +@api_blueprint.put('/devices//media/eject') +def eject_device_media(device_id: int): + """Eject the currently mounted image on the device.""" + row = _device_row_with_web_auth(device_id) + if row is None: + return jsonify({'error': 'device not found'}), 404 + key_path = Path(current_app.config['SECRET_KEY_PATH']) + http_basic = _http_basic_from_auth_row(key_path, row) + client = TinyPilotClient(row['base_url'], http_basic=http_basic) + try: + client.eject_mass_storage() + except Exception as err: # pylint: disable=broad-exception-caught + return jsonify({'error': f'failed to eject media: {err}'}), 502 + return jsonify({'device_id': device_id, 'ejected': True}) +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +pytest tests/test_api_connections.py -k "media" -v +``` + +Expected: all 8 new tests pass. + +- [ ] **Step 5: Run full suite** + +```bash +pytest -q +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add app/api.py tests/test_api_connections.py +git commit -m "Add virtual media proxy routes to dashboard API" +``` + +--- + +## Task 4: Virtual Media frontend section + +**Files:** +- Modify: `app/static/js/components/device-card.js` +- Modify: `app/static/css/dashboard.css` +- Modify: `app/templates/index.html` (bump `asset_v`) + +Add a collapsible "Virtual media" section below "Connected system" in the device card. Uses the same `
` open/close pattern as Connected System. + +The section has three UI states: +- **No images:** URL input + "Add image" button + hint to TinyPilot WebUI +- **Images available, nothing mounted:** file `` + "Mount" button +- **Mounted:** filename + mode label + "Eject" button (with inline confirmation) + +- [ ] **Step 1: Add `_renderVirtualMedia` helper and extend `_cacheElements` in `device-card.js`** + +In `_cacheElements`, add `virtualMediaSection` to `this._elements`: + +```js +_cacheElements() { + const id = this._device.id; + this._elements = { + screenshot: this.querySelector(`#connected-screenshot-${id}`), + link: this.querySelector(`#connected-screenshot-link-${id}`), + connectedStatus: this.querySelector(`#connected-status-${id}`), + summaryOutput: this.querySelector(`#device-collapsed-summary-${id}`), + metricsOutput: this.querySelector(`#device-metrics-output-${id}`), + intervalInput: this.querySelector(`#screenshot-interval-${id}`), + virtualMediaSection: this.querySelector(`#virtual-media-${id}`), + }; +} +``` + +Add the `_renderVirtualMedia(mediaState)` method after `refreshSnapshot`: + +```js +_renderVirtualMedia(mediaState) { + const id = this._device.id; + const section = this._elements.virtualMediaSection; + if (!section) { + return; + } + const { backingFiles, mountMode } = mediaState; + const mountedFile = (backingFiles || []).find(f => f.isMounted); + const allFiles = (backingFiles || []); + + // Update collapsed summary. + const summary = section.querySelector('.virtual-media-summary-text'); + if (summary) { + summary.textContent = mountedFile + ? `${mountedFile.name} · ${_formatMountMode(mountMode)}` + : 'Not mounted'; + } + + // Render body. + const body = section.querySelector('.virtual-media-body'); + if (!body) { + return; + } + body.innerHTML = ''; + + if (allFiles.length === 0) { + body.innerHTML = ` +

+ No images on this device. + + Upload via the TinyPilot WebUI + . +

+
+ + +
+

+ `; + } else if (!mountedFile) { + const options = allFiles + .map(f => ``) + .join(''); + body.innerHTML = ` + + +
+ +
+

+ `; + } else { + body.innerHTML = ` +
+
Mounted
+
${escapeHtml(mountedFile.name)}
+
Mode
+
${escapeHtml(_formatMountMode(mountMode))}
+
+
+ +
+

+ `; + } +} +``` + +Add the module-level helper `_formatMountMode` at the top of `device-card.js` (after the imports): + +```js +const _MOUNT_MODE_LABELS = { + CDROM: 'CD-ROM', + FLASH_READ_ONLY: 'USB — Read only', + FLASH_READ_WRITE: 'USB — Read/write', +}; + +function _formatMountMode(mode) { + return _MOUNT_MODE_LABELS[mode] || mode || 'Unknown'; +} +``` + +- [ ] **Step 2: Add the Virtual Media HTML to `_render()`** + +In `_render()`, after the closing `
` of the Connected System section (after line 121), add the new section: + +```js +
+ +
+ +

Virtual media

+

Loading…

+
+ +
+
+
+``` + +- [ ] **Step 3: Add event handlers for virtual media actions** + +The `dashboard-app.js` dispatches button click events with `data-action`. Open `app/static/js/dashboard-app.js` and find where `data-action` values are handled. Add cases for the new actions: + +In the click handler (look for `refresh-screenshot`, `fetch-device-snapshot`, etc.), add: + +```js +case 'fetch-media': { + const statusEl = document.getElementById(`virtual-media-status-${deviceId}`); + const urlInput = document.getElementById(`virtual-media-url-${deviceId}`); + const url = urlInput ? urlInput.value.trim() : ''; + if (!url) { + if (statusEl) statusEl.textContent = 'Please enter a URL.'; + break; + } + if (statusEl) statusEl.textContent = 'Downloading…'; + const result = await window.dashboardApi.postJson(`/api/devices/${deviceId}/media/fetch`, { url }); + if (result.error) { + if (statusEl) statusEl.textContent = `Failed: ${result.error}`; + } else { + const card = this._deviceCards.get(deviceId); + if (card) await card.refreshMedia(); + } + break; +} +case 'mount-media': { + const fileSelect = document.getElementById(`virtual-media-file-${deviceId}`); + const modeSelect = document.getElementById(`virtual-media-mode-${deviceId}`); + const statusEl = document.getElementById(`virtual-media-status-${deviceId}`); + const fileName = fileSelect ? fileSelect.value : ''; + const mode = modeSelect ? modeSelect.value : 'CDROM'; + if (!fileName) { + if (statusEl) statusEl.textContent = 'Please select an image.'; + break; + } + const result = await window.dashboardApi.putJson(`/api/devices/${deviceId}/media/mount`, { fileName, mode }); + if (result.error) { + if (statusEl) statusEl.textContent = `Mount failed: ${result.error}`; + } else { + const card = this._deviceCards.get(deviceId); + if (card) await card.refreshMedia(); + } + break; +} +case 'eject-media': { + const ejectArea = document.getElementById(`virtual-media-eject-area-${deviceId}`); + if (ejectArea) { + ejectArea.innerHTML = ` + Eject this image? + + + `; + } + break; +} +case 'eject-media-confirm': { + const statusEl = document.getElementById(`virtual-media-status-${deviceId}`); + const result = await window.dashboardApi.putJson(`/api/devices/${deviceId}/media/eject`); + if (result.error) { + if (statusEl) statusEl.textContent = `Eject failed: ${result.error}`; + } else { + const card = this._deviceCards.get(deviceId); + if (card) await card.refreshMedia(); + } + break; +} +case 'eject-media-cancel': { + const card = this._deviceCards.get(deviceId); + if (card) await card.refreshMedia(); + break; +} +``` + +- [ ] **Step 4: Add `refreshMedia()` method and `putJson` to the API helper** + +In `device-card.js`, add after `refreshSnapshot`: + +```js +async refreshMedia() { + if (!this._device) { + return; + } + const id = this._device.id; + const result = await window.dashboardApi.getJson(`/api/devices/${id}/media`); + if (result.error) { + const section = this._elements.virtualMediaSection; + if (section) { + const body = section.querySelector('.virtual-media-body'); + if (body) body.textContent = `Could not reach device: ${result.error}`; + } + return; + } + this._renderVirtualMedia(result); +} +``` + +In `app/static/js/api.js`, add a `putJson` method alongside the existing `postJson`: + +```js +async putJson(path, body = {}) { + try { + const response = await fetch(path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return await response.json(); + } catch (err) { + return { error: String(err) }; + } +} +``` + +- [ ] **Step 5: Call `refreshMedia()` from the snapshot refresh loop** + +In `dashboard-app.js`, wherever `card.refreshSnapshot()` is called, add a follow-up call: + +```js +await card.refreshSnapshot(); +await card.refreshMedia(); +``` + +- [ ] **Step 6: Add CSS for the new section** + +In `app/static/css/dashboard.css`, append: + +```css +/* Virtual media section — mirrors .connected-system-* structure */ + +.virtual-media-details { + border-top: 1px solid color-mix(in srgb, var(--color-text) 10%, transparent); + padding-top: var(--space-3); + margin-top: var(--space-2); +} + +.virtual-media-summary { + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + list-style: none; + cursor: pointer; + user-select: none; + padding: 0; +} + +.virtual-media-summary::-webkit-details-marker { + display: none; +} + +.virtual-media-summary__lead { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.virtual-media-icon { + color: var(--color-text-muted, #5a6770); + margin-bottom: 2px; +} + +.virtual-media-summary__heading { + margin: 0; + font-size: 1.0625rem; + line-height: 1.3; + font-weight: 400; +} + +.virtual-media-summary-text { + margin: 0; + font-size: 0.82rem; + color: var(--color-text-muted, #5a6770); +} + +.virtual-media-summary__toggle { + display: flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + font-size: 1.1rem; + color: var(--color-text-muted, #5a6770); + padding-top: 0.15em; +} + +.virtual-media-details[open] .virtual-media-summary__toggle::after { + content: '\2212'; +} + +.virtual-media-details:not([open]) .virtual-media-summary__toggle::after { + content: '+'; +} + +.virtual-media-summary:hover .virtual-media-summary__toggle, +.virtual-media-summary:focus-visible .virtual-media-summary__toggle { + color: var(--color-accent-hover); +} + +.virtual-media-body { + margin-top: var(--space-3); +} + +.virtual-media-select { + display: block; + width: 100%; + margin-bottom: var(--space-2); + font-family: inherit; + font-size: 0.9rem; +} + +.virtual-media-url-input { + display: block; + width: 100%; + margin-bottom: var(--space-2); + font-family: inherit; + font-size: 0.9rem; +} + +.virtual-media-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + align-items: center; + flex-wrap: wrap; +} + +.virtual-media-confirm-text { + font-size: 0.88rem; + margin-right: auto; +} + +.virtual-media-info { + display: grid; + grid-template-columns: 5rem 1fr; + gap: var(--space-1) var(--space-3); + margin: 0 0 var(--space-3) 0; + font-size: 0.88rem; +} + +.virtual-media-info dt { + color: var(--color-text-muted, #5a6770); +} + +.virtual-media-info dd { + margin: 0; + word-break: break-all; +} + +.virtual-media-empty { + font-size: 0.85rem; + color: var(--color-text-muted, #5a6770); + margin: 0 0 var(--space-3) 0; +} + +.virtual-media-status { + font-size: 0.82rem; + color: var(--color-text-muted, #5a6770); + min-height: 1.2em; + margin: var(--space-2) 0 0 0; +} +``` + +- [ ] **Step 7: Bump `asset_v` in the HTML template** + +In `app/templates/index.html`, increment `asset_v` (e.g. `20260511-15` → `20260615-01`): + +```html +{% set asset_v = '20260615-01' %} +``` + +- [ ] **Step 8: Run full test suite** + +```bash +pytest -q +``` + +Expected: all tests pass. + +- [ ] **Step 9: Smoke-test locally with Docker** + +```bash +docker compose up --build --detach +``` + +Open `http://localhost:8080` and verify: +- The Virtual Media section appears below "Connected system" on each card. +- The `+` / `−` toggle collapses/expands the section. +- Collapsed state shows "Loading…" (before `refreshMedia` runs) then the mount status. + +- [ ] **Step 10: Commit** + +```bash +git add app/static/js/components/device-card.js \ + app/static/js/dashboard-app.js \ + app/static/js/api.js \ + app/static/css/dashboard.css \ + app/templates/index.html +git commit -m "Add Virtual Media section to device card" +``` + +--- + +## Task 5: Cleanup + +**Files:** +- Modify: `app/api.py` (remove dead endpoint, fix network mapping) + +Two targeted cleanup items touched during this work: + +1. **Fix network mapping bug** — `get_device_snapshot` maps `network` as `{'interfaces': network.get('interfaces', [])}` but TinyPilot returns `{ethernet: {...}, wifi: {...}}` — `interfaces` never exists. Fix to pass through the full network object. + +2. **Remove dead `get_device_metrics` endpoint** — superseded by the snapshot endpoint; not called by any frontend code. + +- [ ] **Step 1: Fix network mapping in `get_device_snapshot`** + +In `app/api.py`, find the `expanded` dict construction in `get_device_snapshot` (around line 560). Change: + +```python +'network': {'interfaces': (network or {}).get('interfaces', []), 'error': network_error}, +``` + +to: + +```python +'network': {'data': network or {}, 'error': network_error}, +``` + +- [ ] **Step 2: Update `snapshot-view.js` to use the new key** + +In `app/static/js/lib/snapshot-view.js`, find any reference to `snapshot.expanded.network.interfaces` and update it to read from `snapshot.expanded.network.data` (check for both `ethernet` and `wifi` keys): + +```js +// In the network section formatter, replace any .interfaces array walk with: +const networkData = (expanded.network || {}).data || {}; +const ethernet = networkData.ethernet; +const wifi = networkData.wifi; +// Render ethernet and wifi directly rather than iterating an interfaces array. +``` + +- [ ] **Step 3: Remove `get_device_metrics` from `app/api.py`** + +Delete the entire `get_device_metrics` function and its route decorator (lines 441–461): + +```python +@api_blueprint.get('/devices//device/metrics') +def get_device_metrics(device_id: int): + ... +``` + +- [ ] **Step 4: Run full test suite** + +```bash +pytest -q +``` + +Expected: all tests pass (no test references `get_device_metrics`). + +- [ ] **Step 5: Commit** + +```bash +git add app/api.py app/static/js/lib/snapshot-view.js +git commit -m "Fix network mapping in snapshot and remove dead metrics endpoint" +``` diff --git a/docs/superpowers/specs/2026-05-19-digitalocean-deployment-design.md b/docs/superpowers/specs/2026-05-19-digitalocean-deployment-design.md new file mode 100644 index 0000000..559a647 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-digitalocean-deployment-design.md @@ -0,0 +1,139 @@ +# Design: TinyPilot Dashboard — DigitalOcean Deployment + +**Date:** 2026-05-19 +**Status:** Approved for implementation + +--- + +## Overview + +Enable existing TinyPilot customers to deploy the TinyPilot Dashboard on a +DigitalOcean droplet with minimal effort. The droplet runs the dashboard as a +Docker service, secures it with Tailscale, and auto-updates daily from the +public GitHub repository. + +Self-hosted local deployment remains a fully supported, first-class option. +DigitalOcean is an additional path for customers who want always-on access +from anywhere on their Tailscale network. + +--- + +## Deliverables + +1. `deploy/cloud-init.yaml` — user-data script pasted into DigitalOcean droplet creation +2. Systemd timer unit (embedded in cloud-init) — auto-updates the dashboard daily +3. README updates — new "Deploy on DigitalOcean" and "Run locally" sections replacing the existing flat "Install" section + +--- + +## Droplet sizing + +One cloud-init script works on any Ubuntu droplet. Customers choose their own +size; there are no locked tiers. Recommended sizes: + +| Use case | Size | Cost | +|---|---|---| +| Dashboard only, light use | `s-1vcpu-1gb` | $6/mo | +| Dashboard only, comfortable | `s-1vcpu-2gb` | $12/mo (recommended) | +| Dashboard + heavier polling | `s-2vcpu-4gb` | $24/mo | +| Dashboard + AI agent (Cursor/Claude Code) | `s-4vcpu-8gb` | $48/mo | + +Note: The $6/mo tier works but the first-boot Docker image build may be slow +due to limited RAM. All tiers run identical software. Droplets can be resized +in the DigitalOcean control panel at any time without data loss. + +--- + +## Cloud-init script (`deploy/cloud-init.yaml`) + +Runs once on first boot. Steps in order: + +1. Update Ubuntu packages +2. Install Docker Engine + Docker Compose plugin (official Docker apt repo) +3. Clone `https://github.com/tiny-pilot/tinypilot-dashboard.git` to `/opt/tinypilot-dashboard/` +4. Build and start the dashboard: `docker compose up -d --build` +6. Configure `ufw`: allow OpenSSH, enable ufw (default deny incoming). Port 8080 is never opened. +7. **If `TAILSCALE_AUTH_KEY` is set:** + - Install Tailscale via official install script + - Authenticate: `tailscale up --authkey=$TAILSCALE_AUTH_KEY --ssh` + - Enable Tailscale Serve: `tailscale serve --bg http://localhost:8080` + - Dashboard is now accessible at `https://..ts.net` — Tailscale peers only, HTTPS with valid certificate +8. **If `TAILSCALE_AUTH_KEY` is empty:** skip Tailscale entirely. Dashboard is localhost-only; access via SSH tunnel. +9. Install systemd auto-update timer (see below) + +The `TAILSCALE_AUTH_KEY` variable appears at the top of the script as a +clearly labeled blank. Customers paste in their key or leave it empty. + +### Security posture + +- `ufw` allows SSH only. Port 8080 is never exposed on the public internet in + either path. +- **With Tailscale:** `tailscale serve` proxies localhost:8080 onto the + Tailscale interface with automatic HTTPS. No firewall rules needed beyond + the default deny. +- **Without Tailscale:** dashboard is reachable only via SSH tunnel + (`ssh -L 8080:localhost:8080 user@droplet-ip`). Customers who choose this + path are directed to DigitalOcean's + [Initial Server Setup guide](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu) + for additional hardening. No further security guidance is maintained in this + repo. +- Docker socket is NOT mounted. No container-to-host privilege escalation. + +--- + +## Auto-update + +A systemd timer unit (`tinypilot-dashboard-update.timer`) runs daily at 3am: + +```bash +cd /opt/tinypilot-dashboard +git pull +docker compose up -d --build +``` + +`git pull` is a no-op if no new commits exist — no unnecessary restarts. +When new code is pulled, the container rebuilds and restarts (~30s downtime). + +**Data persistence:** `data/` is bind-mounted from the host at +`/opt/tinypilot-dashboard/data/`. Device records, credentials, and +`secret.key` survive every update. Customers never need to re-add their +devices after an update. + +**No UI surface for updates.** The existing version badge (`v0.1.x`) in the +topbar is sufficient for bug report reference. Auto-update is silent and +invisible to the customer. This will be revisited for beta when manual update +control may be reintroduced. + +--- + +## README structure changes + +The existing flat `## Install` section is replaced with two parallel sections: + +### `## Deploy on DigitalOcean` +- When to choose this path (always-on, Tailscale network access) +- Sizing recommendations table +- Step-by-step: create Ubuntu 24.04 droplet, paste cloud-init script into + User Data field, click Create +- The cloud-init script as a copyable code block with `TAILSCALE_AUTH_KEY=""` + at the top +- Tailscale note: strongly recommended; link to Tailscale's [auth key docs](https://tailscale.com/kb/1085/auth-keys) for generating a key; link to DO hardening guide for those who skip it + +### `## Run locally` +- When to choose this path (LAN use, machine already on the network) +- Existing `docker compose up -d` steps, unchanged + +Both sections are presented without hierarchy — different use cases, same product. + +--- + +## Out of scope + +- Published Docker image / registry (Path B distribution): future work; will + eliminate the first-boot build step and make the $6/mo tier more reliable +- DigitalOcean Marketplace 1-click app: post-alpha; requires vendor application + (~4–8 weeks). The cloud-init script doubles as the basis for a Marketplace + submission when ready. +- Manual update button in the UI: deferred to beta. Auto-update is sufficient + for alpha. +- Multi-user auth on the dashboard: separate workstream, not in scope here. diff --git a/docs/superpowers/specs/2026-06-15-v012-design.md b/docs/superpowers/specs/2026-06-15-v012-design.md new file mode 100644 index 0000000..d92346d --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-v012-design.md @@ -0,0 +1,133 @@ +# TinyPilot Dashboard v0.1.2 Design + +## Overview + +Two features for v0.1.2: + +1. **Virtual Media** — A new collapsible card section that shows the mount status of a TinyPilot device's mass storage and allows the user to mount or eject an image without leaving the dashboard. +2. **Screenshot flicker fix** — The current screenshot must never be removed until a replacement is fully loaded, eliminating visible blank/loading gaps during polling refreshes. + +--- + +## Feature 1: Virtual Media + +### Where it lives + +A new collapsible section in the device card, rendered as a peer to "Connected System". Both sections appear in the card body below the card header. The Virtual Media section appears below Connected System. + +The section follows the same `
` / `+` / `−` pattern already used by Connected System. + +### Collapsed state + +Header shows: +- A disk icon (SVG inline) +- "Virtual media" heading +- A one-line summary: filename + mode (e.g. `ubuntu-24.04-server.iso · CD-ROM`) or "Not mounted" if nothing is mounted +- `+` or `−` toggle on the right + +### Expanded states + +**State A — No images on device** (device has zero backing files): + +``` +Virtual media − +[ Paste image URL… ] + [ Add image ] +``` + +- User pastes a direct URL to an ISO (e.g. `https://releases.ubuntu.com/.../ubuntu.iso`) +- Dashboard resolves the filename via `GET /api/massStorage/retrieveFileNameFromUrl?url=`, then triggers the device to download it via `PUT /api/massStorage/backingFiles//fetchFromUrl` +- While downloading, the section shows a progress/spinner state with "Downloading…" +- On success, the file list refreshes and moves to State B +- On failure, shows an inline error + +A small "Upload via the TinyPilot WebUI" hint link sits below the input for users who want to upload from their computer (deferred to v0.1.3). + +**State B — Images available, nothing mounted**: + +``` +Virtual media − +[ Select image… ▾ ] +[ CD-ROM ▾ ] + [ Mount ] +``` + +- File dropdown lists all `backingFiles` from `GET /api/massStorage/backingFiles` +- Mode dropdown: CD-ROM (default), USB — Read only, USB — Read/write +- Mount button disabled until a file is selected +- Mount calls `PUT /api/massStorage/mount/?mode=` + +**State C — Something mounted**: + +``` +Virtual media CD-ROM − +Mounted ubuntu-24.04-server.iso +Mode CD-ROM + [ Eject ] +``` + +- Eject button opens an inline confirmation: + ``` + Eject ubuntu-24.04-server.iso? + [ Cancel ] [ Eject ] + ``` +- Confirmed eject calls `PUT /api/massStorage/eject` + +### API calls (all proxied via the dashboard backend) + +| Action | TinyPilot endpoint | Auth | +|---|---|---| +| Get file list + current mount | `GET /api/massStorage/backingFiles` | Web UI session (ADMIN) | +| Resolve filename from URL | `GET /api/massStorage/retrieveFileNameFromUrl?url=` | Web UI session (ADMIN) | +| Fetch image from URL | `PUT /api/massStorage/backingFiles//fetchFromUrl` body: `{url}` | Web UI session (ADMIN) | +| Mount | `PUT /api/massStorage/mount/?mode=` | Web UI session (ADMIN) | +| Eject | `PUT /api/massStorage/eject` | Web UI session (ADMIN) | + +The dashboard backend must add four new proxy routes under `/api/devices//`: +- `GET /media` — fetches and returns `backingFiles` + `mountMode` from the device +- `POST /media/fetch` — accepts `{url}` body, resolves filename then triggers device download +- `PUT /media/mount` — accepts `{fileName, mode}` body, calls device mount endpoint +- `PUT /media/eject` — calls device eject endpoint + +Mount mode values passed to TinyPilot: `CDROM`, `FLASH_READ_ONLY`, `FLASH_READ_WRITE`. + +### Error handling + +- If the device is unreachable, show a subtle error in the section ("Could not reach device"). +- If mount/eject fails, show the error inline below the action area. +- No global error toast — errors stay within the section. + +### Data freshness + +Virtual media state is fetched as part of the device snapshot poll. The existing snapshot service is extended to include `massStorage` data. No separate polling loop. + +--- + +## Feature 2: Screenshot flicker fix + +### Problem + +When the dashboard polls for a new screenshot (`POST /api/devices//refresh-screenshot` or the periodic refresh), the UI currently clears the `` src before the new image is available, causing a visible blank/grey state for the duration of the network round-trip. + +### Fix + +In `device-card.js`, when a new screenshot URL becomes available: + +1. Create a temporary `Image` object in JavaScript (`new Image()`). +2. Set its `src` to the new screenshot URL. +3. Only update the displayed `` element's `src` after the temporary image fires `onload`. +4. If `onerror` fires instead, keep the current screenshot and show a subtle error indicator. + +This is a pure frontend change — no backend or API changes required. + +The displayed image never goes blank during normal operation. The old screenshot stays until the new one is pixel-ready. + +--- + +## Out of scope for v0.1.2 + +- Uploading ISO images from the user's computer (planned for v0.1.3) +- Tracking fetch-from-URL download progress (TinyPilot returns no progress events; show spinner only) +- Deleting backing files from the dashboard +- Wake-on-LAN, disk usage, hostname display, or other API additions from the v0.1.2 exploration +- Per-device screenshot polling interval UI diff --git a/tests/test_api_connections.py b/tests/test_api_connections.py index 8771dfc..92f74f5 100644 --- a/tests/test_api_connections.py +++ b/tests/test_api_connections.py @@ -178,11 +178,6 @@ def test_set_screenshot_refresh_interval_endpoint(client): assert device['screenshot_refresh_interval_minutes'] == 5 -def test_refresh_csrf_endpoint(client): - response = client.post('/api/devices/1/device/refresh-csrf') - assert response.status_code in (200, 404) - - def test_index_page_loads(client): response = client.get('/') assert response.status_code == 200 @@ -191,7 +186,7 @@ def test_index_page_loads(client): assert b'dashboard-app.js' in response.data -def test_refresh_csrf_and_device_metrics_endpoint(client): +def test_refresh_csrf_endpoint(client): create_payload = { 'friendly_name': 'Office Rack KVM', 'base_url': 'https://192.168.1.44', @@ -204,16 +199,13 @@ def test_refresh_csrf_and_device_metrics_endpoint(client): with patch('app.api.TinyPilotClient') as client_cls: tp_client = client_cls.return_value tp_client.refresh_csrf_token.return_value = 'csrf-abc' - tp_client.get_network_status.return_value = {'interfaces': [{'name': 'eth0'}]} csrf_response = client.post(f'/api/devices/{device_id}/device/refresh-csrf') assert csrf_response.status_code == 200 assert csrf_response.json['csrf_refreshed'] is True - metrics_response = client.get(f'/api/devices/{device_id}/device/metrics') - assert metrics_response.status_code == 200 - assert metrics_response.json['metrics'] == {'interfaces': [{'name': 'eth0'}]} - assert metrics_response.json['source_base_url'] == create_payload['base_url'] + metrics_response = client.get(f'/api/devices/{device_id}/device/metrics') + assert metrics_response.status_code == 404 def test_device_snapshot_endpoint_returns_collapsed_and_expanded_sections(client): @@ -233,7 +225,8 @@ def test_device_snapshot_endpoint_returns_collapsed_and_expanded_sections(client tp_client.get_auth_status.return_value = {'isAuthenticated': True, 'username': 'admin'} tp_client.get_version.return_value = {'version': '2.6.5'} tp_client.get_network_status.return_value = { - 'interfaces': [{'name': 'eth0', 'isConnected': True, 'ipAddress': '192.168.1.44'}] + 'ethernet': {'isConnected': True, 'ipAddress': '192.168.1.44', 'macAddress': 'aa:bb:cc:dd:ee:ff'}, + 'wifi': {'isConnected': False, 'ipAddress': ''}, } tp_client.get_requires_https.return_value = {'requiresHttps': True} tp_client.get_video_settings.return_value = {'h264Bitrate': 8000, 'streamingMode': 'MJPEG'} @@ -250,7 +243,7 @@ def test_device_snapshot_endpoint_returns_collapsed_and_expanded_sections(client assert payload['collapsed']['software_version'] == '2.6.5' assert payload['collapsed']['web_session_status'] == 'connected' assert 'connected_device_resolution' not in payload['collapsed'] - assert payload['expanded']['network']['interfaces'][0]['name'] == 'eth0' + assert payload['expanded']['network']['data']['ethernet']['ipAddress'] == '192.168.1.44' assert payload['expanded']['connected_device_resolution'] == '1920x1080' @@ -285,7 +278,7 @@ def test_device_snapshot_prefers_automation_state_resolution(client): tp_client.get_status.return_value = {'ok': True} tp_client.get_auth_status.return_value = {} tp_client.get_version.return_value = {'version': '3.0.2'} - tp_client.get_network_status.return_value = {'interfaces': []} + tp_client.get_network_status.return_value = {'ethernet': None, 'wifi': None} tp_client.get_requires_https.return_value = {'requiresHttps': False} tp_client.get_video_settings.return_value = {'h264Bitrate': 900} tp_client.get_automation_state.return_value = { @@ -355,3 +348,92 @@ def test_refresh_screenshot_retries_after_401_by_refreshing_token(client): assert response.status_code == 200 assert response.json['screenshot_refreshed'] is True + + +def _create_media_device(client): + """Helper: create a device with no automation token for media tests.""" + with patch('app.api.TinyPilotClient') as cls: + cls.return_value.refresh_automation_token.side_effect = RuntimeError('no device') + resp = client.post('/api/devices', json={ + 'friendly_name': 'Media Test Device', + 'base_url': 'https://192.168.1.200', + }) + return resp.json['device']['id'] + + +def test_get_media_returns_backing_files(client): + device_id = _create_media_device(client) + backing_files_response = { + 'backingFiles': [{'name': 'ubuntu.iso', 'mounted': True, 'loadedBytes': 100, 'totalBytes': 100}], + 'intermediateFiles': [], + 'mountMode': 'CDROM', + } + with patch('app.api.TinyPilotClient') as cls: + cls.return_value.get_mass_storage.return_value = backing_files_response + response = client.get(f'/api/devices/{device_id}/media') + + assert response.status_code == 200 + assert response.json['backingFiles'][0]['name'] == 'ubuntu.iso' + assert response.json['mountMode'] == 'CDROM' + + +def test_get_media_returns_404_for_unknown_device(client): + response = client.get('/api/devices/9999/media') + assert response.status_code == 404 + + +def test_media_fetch_triggers_device_download(client): + device_id = _create_media_device(client) + with patch('app.api.TinyPilotClient') as cls: + cls.return_value.get_mass_storage_filename_from_url.return_value = 'ubuntu.iso' + cls.return_value.fetch_mass_storage_from_url.return_value = None + response = client.post( + f'/api/devices/{device_id}/media/fetch', + json={'url': 'https://example.com/ubuntu.iso'}, + ) + + assert response.status_code == 200 + assert response.json['fileName'] == 'ubuntu.iso' + + +def test_media_fetch_returns_400_without_url(client): + device_id = _create_media_device(client) + response = client.post(f'/api/devices/{device_id}/media/fetch', json={}) + assert response.status_code == 400 + + +def test_media_mount_calls_device(client): + device_id = _create_media_device(client) + with patch('app.api.TinyPilotClient') as cls: + cls.return_value.mount_mass_storage.return_value = None + response = client.put( + f'/api/devices/{device_id}/media/mount', + json={'fileName': 'ubuntu.iso', 'mode': 'CDROM'}, + ) + + assert response.status_code == 200 + cls.return_value.mount_mass_storage.assert_called_once_with('ubuntu.iso', 'CDROM') + + +def test_media_mount_returns_400_without_required_fields(client): + device_id = _create_media_device(client) + response = client.put( + f'/api/devices/{device_id}/media/mount', + json={'fileName': 'ubuntu.iso'}, + ) + assert response.status_code == 400 + + +def test_media_eject_calls_device(client): + device_id = _create_media_device(client) + with patch('app.api.TinyPilotClient') as cls: + cls.return_value.eject_mass_storage.return_value = None + response = client.put(f'/api/devices/{device_id}/media/eject') + + assert response.status_code == 200 + cls.return_value.eject_mass_storage.assert_called_once() + + +def test_media_eject_returns_404_for_unknown_device(client): + response = client.put('/api/devices/9999/media/eject') + assert response.status_code == 404 diff --git a/tests/test_tinypilot_client.py b/tests/test_tinypilot_client.py new file mode 100644 index 0000000..7a6e8ce --- /dev/null +++ b/tests/test_tinypilot_client.py @@ -0,0 +1,117 @@ +"""Unit tests for TinyPilotClient virtual media methods.""" + +from unittest.mock import MagicMock + +import pytest + +from app.tinypilot_client import TinyPilotClient + + +@pytest.fixture +def client(): + return TinyPilotClient('https://device.local') + + +def make_response(json_data=None, status_code=200, content=b'{}'): + r = MagicMock() + r.status_code = status_code + r.json.return_value = json_data or {} + r.content = content + r.raise_for_status = MagicMock() + r.text = '' + return r + + +def test_get_mass_storage_returns_backing_files_and_mount_mode(client): + warmup = make_response() + api_response = make_response({ + 'backingFiles': [{'name': 'ubuntu.iso', 'mounted': True, 'loadedBytes': 1000, 'totalBytes': 1000}], + 'intermediateFiles': [], + 'mountMode': 'CDROM', + }) + mock_session = MagicMock() + mock_session.get.side_effect = [warmup, api_response] + client.session = mock_session + + result = client.get_mass_storage() + + assert result['mountMode'] == 'CDROM' + assert result['backingFiles'][0]['name'] == 'ubuntu.iso' + + +def test_get_mass_storage_filename_from_url_returns_filename(client): + warmup = make_response() + api_response = make_response({'fileName': 'ubuntu-24.04.iso'}) + mock_session = MagicMock() + mock_session.get.side_effect = [warmup, api_response] + client.session = mock_session + + result = client.get_mass_storage_filename_from_url('https://example.com/ubuntu.iso') + + assert result == 'ubuntu-24.04.iso' + + +def test_fetch_mass_storage_from_url_calls_correct_endpoint(client): + warmup = make_response() + put_response = make_response() + mock_session = MagicMock() + mock_session.get.return_value = warmup + mock_session.put.return_value = put_response + client.session = mock_session + + client.fetch_mass_storage_from_url('ubuntu.iso', 'https://example.com/ubuntu.iso') + + call_args = mock_session.put.call_args + assert 'backingFiles/ubuntu.iso/fetchFromUrl' in call_args[0][0] + assert call_args[1]['json'] == {'url': 'https://example.com/ubuntu.iso'} + + +def test_mount_mass_storage_sends_correct_path_and_mode(client): + warmup = make_response() + put_response = make_response({'success': True}) + mock_session = MagicMock() + mock_session.get.return_value = warmup + mock_session.put.return_value = put_response + client.session = mock_session + + client.mount_mass_storage('ubuntu.iso', 'CDROM') + + call_args = mock_session.put.call_args + assert 'massStorage/mount/ubuntu.iso' in call_args[0][0] + assert call_args[1]['params']['mode'] == 'CDROM' + + +def test_eject_mass_storage_calls_eject_endpoint(client): + warmup = make_response() + put_response = make_response(content=b'') + mock_session = MagicMock() + mock_session.get.return_value = warmup + mock_session.put.return_value = put_response + client.session = mock_session + + client.eject_mass_storage() + + call_args = mock_session.put.call_args + assert 'massStorage/eject' in call_args[0][0] + + +def test_put_json_retries_with_fresh_csrf_on_401(client): + warmup = make_response() + warmup.text = '' + first_put = make_response(status_code=401, content=b'') + first_put.raise_for_status = MagicMock() # Don't raise on 401 — retry handles it. + retry_warmup = make_response() + retry_warmup.text = '' + retry_put = make_response({'ok': True}) + + mock_session = MagicMock() + mock_session.get.side_effect = [warmup, retry_warmup] + mock_session.put.side_effect = [first_put, retry_put] + client.session = mock_session + + result = client._put_json('/api/massStorage/eject') + + assert mock_session.put.call_count == 2 + _args, second_kwargs = mock_session.put.call_args_list[1] + assert second_kwargs['headers'].get('X-CSRFToken') == 'tok2' + assert result == {'ok': True}