Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions app/static/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ summary {
}

.device-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin: calc(-1 * var(--space-4));
margin-bottom: var(--space-3);
padding: var(--space-3) var(--space-4);
Expand All @@ -107,6 +111,67 @@ summary {
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
}

.device-card-header .device-card-title {
min-width: 0;
}

.header-open-webui {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0.4rem 1rem;
background: #fff;
color: var(--color-nav);
border: 1px solid #fff;
border-radius: var(--radius-md);
font-weight: 600;
text-decoration: none;
}

.header-open-webui:hover,
.header-open-webui:focus-visible {
background: color-mix(in srgb, #fff 86%, var(--color-nav));
border-color: color-mix(in srgb, #fff 86%, var(--color-nav));
color: var(--color-nav);
text-decoration: none;
}

button.button-danger-quiet {
background: transparent;
color: var(--color-danger);
border: 1px solid color-mix(in srgb, var(--color-danger) 55%, transparent);
font-weight: 500;
}

button.button-danger-quiet:hover {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: var(--color-danger);
border-color: var(--color-danger);
}

.delete-device-actions {
margin-top: var(--space-2);
align-items: center;
gap: var(--space-2);
}

.delete-device-actions button {
margin-top: 0;
min-width: 0;
min-height: 2rem;
height: 2rem;
padding: 0 1rem;
line-height: 1;
}

.delete-device-actions #delete-device-cancel {
background: transparent;
color: var(--color-text);
border: 1px solid color-mix(in srgb, var(--color-text) 28%, transparent);
}

.device-section > h3,
.device-card-title {
margin: 0;
Expand Down Expand Up @@ -646,14 +711,22 @@ button:active {

.modal-close {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 1.75rem;
height: 1.75rem;
margin: 0;
padding: 0.15rem 0.45rem;
padding: 0;
line-height: 1;
font-size: 1.35rem;
font-size: 1.25rem;
font-weight: 500;
background: transparent;
border: 1px solid color-mix(in srgb, var(--color-text) 25%, transparent);
color: var(--color-muted);
/* × sits high in the em box; nudge for optical center. */
transform: translateY(1px);
}

.modal-close:hover {
Expand Down
2 changes: 2 additions & 0 deletions app/static/css/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ body {
--color-muted: #475569;
--color-accent: #0e6b86;
--color-accent-hover: #0b5a71;
--color-danger: hsl(5, 57%, 44%);
--color-nav: hsl(230, 33%, 33%);
--color-nav-text: #ffffff;
--space-1: 4px;
Expand All @@ -30,6 +31,7 @@ body {
--color-muted: hsl(220, 18%, 76%);
--color-accent: hsl(205, 65%, 52%);
--color-accent-hover: hsl(205, 72%, 60%);
--color-danger: hsl(5, 57%, 58%);
--color-nav: hsl(230, 33%, 30%);
--color-nav-text: #ffffff;
}
17 changes: 13 additions & 4 deletions app/static/js/components/device-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ function deactivateScreenshotLink(link) {
link.setAttribute('aria-disabled', 'true');
}

function renderHeaderOpenWebUi(device) {
const url = String(device.base_url || '').trim();
if (!url) {
return '';
}
return (
`<a class="header-open-webui" href="${escapeHtml(url)}" ` +
`target="_blank" rel="noopener noreferrer">Open WebUI</a>`
);
}

class DeviceCard extends HTMLElement {
constructor() {
super();
Expand Down Expand Up @@ -86,6 +97,7 @@ class DeviceCard extends HTMLElement {
<section class="panel device-card">
<header class="device-card-header">
<h3 class="device-card-title">${escapeHtml(device.friendly_name)}</h3>
${renderHeaderOpenWebUi(device)}
</header>
<section class="device-section connected-system">
<div class="connected-system-header">
Expand Down Expand Up @@ -133,12 +145,9 @@ class DeviceCard extends HTMLElement {
</section>
<section class="device-section tiny-device">
<h3>TinyPilot device</h3>
<p>
<a class="launch-link" href="${escapeHtml(device.base_url)}" target="_blank" rel="noopener noreferrer">Launch WebUI ↗</a>
</p>
<div class="actions">
<button type="button" data-action="fetch-device-snapshot" data-device-id="${id}">Refresh device snapshot</button>
<button type="button" data-action="delete-device" data-device-id="${id}">Delete device</button>
<button type="button" data-action="delete-device" data-device-id="${id}">Remove device</button>
</div>
<div id="device-collapsed-summary-${id}" class="metrics-output">
Retrieving device snapshot...
Expand Down
114 changes: 105 additions & 9 deletions app/static/js/dashboard-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { formatRelativeTime } from './lib/strings.js';
const ADD_DEVICE_TOGGLE_LABEL = 'Add a device';
const ADD_DEVICE_MODAL_LABEL = 'Add a TinyPilot device';
const ADD_DEVICE_SUBMIT_LABEL = 'Add device';
const DELETE_DEVICE_MODAL_LABEL = 'Remove device';
const DEVICES_PER_PAGE = 4;
const CONNECTED_STATUS_REFRESH_INTERVAL_MS = 30_000;

Expand All @@ -13,6 +14,7 @@ class DashboardApp extends HTMLElement {
this._elements = {};
this._allDevices = [];
this._currentPage = 1;
this._pendingDeleteDeviceId = null;
this._screenshotAutoRefreshTimers = new Map();
this._screenshotCapturedAtByDevice = new Map();
}
Expand Down Expand Up @@ -62,6 +64,27 @@ class DashboardApp extends HTMLElement {
<p id="add-device-status" class="subtitle"></p>
</section>
</div>
<div id="delete-device-modal" class="modal" hidden role="dialog" aria-modal="true" aria-labelledby="delete-device-heading">
<button type="button" class="modal-backdrop" id="delete-device-modal-backdrop" tabindex="-1" aria-label="Close dialog"></button>
<section class="modal-panel panel">
<header class="modal-header">
<h2 id="delete-device-heading">${DELETE_DEVICE_MODAL_LABEL}</h2>
<button type="button" class="modal-close" id="delete-device-modal-close" aria-label="Close">&times;</button>
</header>
<p class="subtitle">Type REMOVE to remove this device from your Dashboard.<br>You will need to add it again from the device.</p>
<form id="delete-device-form" class="form-grid">
<label>
Confirmation
<input name="confirm" required autocomplete="off" spellcheck="false" placeholder="REMOVE">
</label>
<div class="actions delete-device-actions">
<button type="button" id="delete-device-cancel">Cancel</button>
<button type="submit" class="button-danger-quiet">Remove device</button>
</div>
</form>
<p id="delete-device-status" class="subtitle"></p>
</section>
</div>
`;

this._elements = {
Expand All @@ -75,6 +98,12 @@ class DashboardApp extends HTMLElement {
addDeviceModalBackdrop: this.shadowRoot.getElementById('add-device-modal-backdrop'),
addDeviceForm: this.shadowRoot.getElementById('add-device-form'),
addDeviceStatus: this.shadowRoot.getElementById('add-device-status'),
deleteDeviceModal: this.shadowRoot.getElementById('delete-device-modal'),
deleteDeviceModalClose: this.shadowRoot.getElementById('delete-device-modal-close'),
deleteDeviceModalBackdrop: this.shadowRoot.getElementById('delete-device-modal-backdrop'),
deleteDeviceForm: this.shadowRoot.getElementById('delete-device-form'),
deleteDeviceCancel: this.shadowRoot.getElementById('delete-device-cancel'),
deleteDeviceStatus: this.shadowRoot.getElementById('delete-device-status'),
};

this._bind();
Expand All @@ -94,11 +123,19 @@ class DashboardApp extends HTMLElement {
});
e.addDeviceModalClose.addEventListener('click', () => this._closeAddDeviceModal());
e.addDeviceModalBackdrop.addEventListener('click', () => this._closeAddDeviceModal());
e.deleteDeviceModalClose.addEventListener('click', () => this._closeDeleteDeviceModal());
e.deleteDeviceModalBackdrop.addEventListener('click', () => this._closeDeleteDeviceModal());
e.deleteDeviceCancel.addEventListener('click', () => this._closeDeleteDeviceModal());

document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') {
return;
}
if (e.deleteDeviceModal && !e.deleteDeviceModal.hasAttribute('hidden')) {
this._closeDeleteDeviceModal();
event.preventDefault();
return;
}
if (e.addDeviceModal && !e.addDeviceModal.hasAttribute('hidden')) {
this._closeAddDeviceModal();
event.preventDefault();
Expand All @@ -107,6 +144,7 @@ class DashboardApp extends HTMLElement {

this._initThemeToggle();
this._bindAddDeviceForm();
this._bindDeleteDeviceForm();
e.deviceSections.addEventListener('click', (event) => {
void this._onDeviceSectionClick(event);
});
Expand Down Expand Up @@ -173,6 +211,34 @@ class DashboardApp extends HTMLElement {
document.body.style.overflow = '';
}

_openDeleteDeviceModal(deviceId) {
const e = this._elements;
this._pendingDeleteDeviceId = String(deviceId);
e.deleteDeviceForm?.reset();
if (e.deleteDeviceStatus) {
e.deleteDeviceStatus.textContent = '';
}
e.deleteDeviceModal.removeAttribute('hidden');
document.body.style.overflow = 'hidden';
window.requestAnimationFrame(() => {
e.deleteDeviceForm?.querySelector('input[name="confirm"]')?.focus();
});
}

_closeDeleteDeviceModal() {
const { deleteDeviceModal } = this._elements;
if (!deleteDeviceModal || deleteDeviceModal.hasAttribute('hidden')) {
return;
}
deleteDeviceModal.setAttribute('hidden', '');
this._pendingDeleteDeviceId = null;
this._elements.deleteDeviceForm?.reset();
if (this._elements.deleteDeviceStatus) {
this._elements.deleteDeviceStatus.textContent = '';
}
document.body.style.overflow = '';
}

_bindAddDeviceForm() {
const { addDeviceForm, addDeviceStatus } = this._elements;
if (!addDeviceForm) {
Expand Down Expand Up @@ -203,6 +269,40 @@ class DashboardApp extends HTMLElement {
});
}

_bindDeleteDeviceForm() {
const { deleteDeviceForm, deleteDeviceStatus } = this._elements;
if (!deleteDeviceForm) {
return;
}
deleteDeviceForm.addEventListener('submit', async (event) => {
event.preventDefault();
const deviceId = this._pendingDeleteDeviceId;
if (!deviceId) {
return;
}
const typed = String(
new FormData(deleteDeviceForm).get('confirm') || '',
).trim();
if (typed !== 'REMOVE') {
if (deleteDeviceStatus) {
deleteDeviceStatus.textContent = 'Type REMOVE exactly to confirm.';
}
return;
}
const result = await window.dashboardApi.deleteJson(
`/api/devices/${deviceId}`,
);
if (result.error) {
if (deleteDeviceStatus) {
deleteDeviceStatus.textContent = `Could not remove device: ${result.error}`;
}
return;
}
this._closeDeleteDeviceModal();
await this._refreshDeviceList();
});
}

_totalPages() {
return Math.max(1, Math.ceil(this._allDevices.length / DEVICES_PER_PAGE));
}
Expand All @@ -223,6 +323,11 @@ class DashboardApp extends HTMLElement {
if (!deviceId) {
return;
}
if (action === 'delete-device') {
// In-page modal: native confirm/prompt dialogs are suppressed in some browsers.
this._openDeleteDeviceModal(deviceId);
return;
}
const card = this._deviceCard(deviceId);
if (!card) {
return;
Expand Down Expand Up @@ -253,15 +358,6 @@ class DashboardApp extends HTMLElement {
}
return;
}
if (action === 'delete-device') {
const confirmed = window.confirm('Delete this device from the dashboard?');
if (!confirmed) {
return;
}
await window.dashboardApi.deleteJson(`/api/devices/${deviceId}`);
await this._refreshDeviceList();
return;
}
if (action === 'fetch-device-snapshot') {
await card.refreshSnapshot();
return;
Expand Down
2 changes: 1 addition & 1 deletion app/templates/index.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!doctype html>
<html lang="en">
<head>
{% set asset_v = '20260917-02' %}
{% set asset_v = '20261008-remove-open-webui' %}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TinyPilot Dashboard</title>
Expand Down
36 changes: 36 additions & 0 deletions tests/test_remove_device_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Static checks for Remove device modal + Open WebUI header."""

from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
APP = ROOT / 'app/static/js/dashboard-app.js'
CARD = ROOT / 'app/static/js/components/device-card.js'
THEME = ROOT / 'app/static/css/theme.css'


def test_remove_device_uses_in_page_modal():
source = APP.read_text(encoding='utf-8')
assert '_openDeleteDeviceModal' in source
assert 'delete-device-modal' in source
assert "typed !== 'REMOVE'" in source
assert 'window.confirm(' not in source
assert 'window.prompt(' not in source
assert 'button-danger-quiet' in source


def test_device_card_open_webui_header_and_remove_label():
source = CARD.read_text(encoding='utf-8')
assert 'function renderHeaderOpenWebUi' in source
assert 'class="header-open-webui"' in source
assert '>Open WebUI</a>' in source
assert 'Launch WebUI' not in source
assert '>Remove device</button>' in source
assert 'Delete device' not in source
header = source.split('class="device-card-header"', 1)[1]
header = header.split('</header>', 1)[0]
assert 'renderHeaderOpenWebUi(device)' in header


def test_theme_defines_danger_color():
source = THEME.read_text(encoding='utf-8')
assert '--color-danger:' in source
Loading