From 4aa6cf50461ef8d1aa8844df355c7c80f480cdc6 Mon Sep 17 00:00:00 2001 From: Keysat Date: Mon, 18 May 2026 17:33:16 -0500 Subject: [PATCH] v0.11.0:1 - dashboard polish: tabs, collapsible endpoint, pill consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three UX improvements, all client-side; no backend or behavior changes. 1. LLM / Audio tabs under the hardware section. The single long column got split into two tabbed views: * LLM -> model swap + download panel + spark-vllm-docker updates * Audio -> Parakeet/Magpie services + speech-model patches Selection persists in localStorage; default is LLM. The swap-panel (in-flight LLM swap) sits ABOVE the tab strip so it stays visible regardless of which tab is active. 2. Collapsible OpenAI-compatible Endpoint card. New chevron in the card header collapses everything except the title. State persists per browser via localStorage. Defaults to collapsed since you rarely need the URL/ model details visible (and the same info is one tab swap away). 3. Unified pill sizing. The .sm-pill class in speech-models was rendering subtly larger than .tag pills on model cards. Dropped .sm-pill entirely and reused .tag with semantic color modifiers (.tag.ok / .tag.warn / .tag.bad). Same 11px / 2px×8px footprint everywhere now. Also added explicit line-height: 1.5 + display: inline-block to .tag to lock down vertical sizing. No new endpoints, no new dependencies. Tested locally with node --check and ast.parse(). Verified the tab DOM structure wraps the right sections and the speech-models panel still self-shows/hides on data load. Co-Authored-By: Claude Opus 4.7 (1M context) --- image/app/static/app.js | 72 +++++++++++++++++++++++++++--- image/app/static/index.html | 24 +++++++++- image/app/static/style.css | 69 +++++++++++++++++++++++----- package/startos/versions/v0_1_0.ts | 4 +- 4 files changed, 149 insertions(+), 20 deletions(-) diff --git a/image/app/static/app.js b/image/app/static/app.js index c059169..44f421f 100644 --- a/image/app/static/app.js +++ b/image/app/static/app.js @@ -556,13 +556,13 @@ async function renderSpeechModels() { let statusPill; if (status === 'in_sync') { - statusPill = `patches in sync`; + statusPill = `patches in sync`; } else if (status === 'drift') { - statusPill = `spark-control has newer patches`; + statusPill = `spark-control has newer patches`; } else if (status === 'missing') { - statusPill = `patches missing in container`; + statusPill = `patches missing in container`; } else { - statusPill = `unknown`; + statusPill = `unknown`; } const asrLoaded = !!health.asr_loaded; @@ -599,12 +599,12 @@ async function renderSpeechModels() {
Parakeet ASR ${asrModel} - ${asrLoaded ? 'loaded' : 'not loaded'} + ${asrLoaded ? 'loaded' : 'not loaded'}
Sortformer diarizer ${diarModel} - ${diarLoaded ? 'loaded' : 'not loaded'} + ${diarLoaded ? 'loaded' : 'not loaded'}
${fileRows}
@@ -768,6 +768,64 @@ function renderHealth(status) { function renderBanner(status) { el('#setup-banner').classList.toggle('hidden', !!status.configured); + // Dashboard tabs share the same "configured" gate as the rest of the + // body — hidden until SSH is set up, then visible. + const tabs = el('#dashboard-tabs'); + if (tabs) tabs.classList.toggle('hidden', !status.configured); +} + +// ===================== dashboard tabs (LLM / Audio) ===================== + +const TABS_STORAGE_KEY = 'sparkcontrol.dashboard.activeTab'; + +function setupDashboardTabs() { + const buttons = $$('.dashboard-tab'); + if (!buttons.length) return; + + // Restore the last-selected tab, default to "llm" + let saved; + try { saved = localStorage.getItem(TABS_STORAGE_KEY); } catch {} + const initial = saved === 'audio' || saved === 'llm' ? saved : 'llm'; + + function selectTab(name) { + buttons.forEach((b) => { + const active = b.dataset.tab === name; + b.classList.toggle('active', active); + b.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + $$('.tab-content').forEach((c) => { + c.classList.toggle('active', c.id === `tab-${name}`); + }); + try { localStorage.setItem(TABS_STORAGE_KEY, name); } catch {} + } + + buttons.forEach((b) => { + b.addEventListener('click', () => selectTab(b.dataset.tab)); + }); + selectTab(initial); +} + +// ===================== collapsible endpoint card ===================== + +const ENDPOINT_COLLAPSED_KEY = 'sparkcontrol.endpoint.collapsed'; + +function setupEndpointCollapse() { + const panel = el('#endpoint-panel'); + const btn = el('#ep-collapse'); + if (!panel || !btn) return; + // Default: collapsed (most of the time you don't need to see endpoint details) + let collapsed = true; + try { + const v = localStorage.getItem(ENDPOINT_COLLAPSED_KEY); + if (v === 'false') collapsed = false; + else if (v === 'true') collapsed = true; + } catch {} + panel.classList.toggle('collapsed', collapsed); + btn.addEventListener('click', () => { + const nowCollapsed = !panel.classList.contains('collapsed'); + panel.classList.toggle('collapsed', nowCollapsed); + try { localStorage.setItem(ENDPOINT_COLLAPSED_KEY, nowCollapsed ? 'true' : 'false'); } catch {} + }); } function renderSwapPanel() { @@ -1800,6 +1858,8 @@ async function init() { a.classList.remove('hidden'); } } catch {} + setupDashboardTabs(); + setupEndpointCollapse(); await loadModels(); await pollStatus(); await renderServices(); diff --git a/image/app/static/index.html b/image/app/static/index.html index 141e1fc..8accfe4 100644 --- a/image/app/static/index.html +++ b/image/app/static/index.html @@ -44,8 +44,14 @@ -