v0.11.0:1 - dashboard polish: tabs, collapsible endpoint, pill consistency

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) <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-05-18 17:33:16 -05:00
parent 391117f705
commit 4aa6cf5046
4 changed files with 149 additions and 20 deletions
+66 -6
View File
@@ -556,13 +556,13 @@ async function renderSpeechModels() {
let statusPill;
if (status === 'in_sync') {
statusPill = `<span class="sm-pill ok">patches in sync</span>`;
statusPill = `<span class="tag ok">patches in sync</span>`;
} else if (status === 'drift') {
statusPill = `<span class="sm-pill warn">spark-control has newer patches</span>`;
statusPill = `<span class="tag warn">spark-control has newer patches</span>`;
} else if (status === 'missing') {
statusPill = `<span class="sm-pill bad">patches missing in container</span>`;
statusPill = `<span class="tag bad">patches missing in container</span>`;
} else {
statusPill = `<span class="sm-pill warn">unknown</span>`;
statusPill = `<span class="tag warn">unknown</span>`;
}
const asrLoaded = !!health.asr_loaded;
@@ -599,12 +599,12 @@ async function renderSpeechModels() {
<div class="sm-model-row">
<span class="sm-model-kind">Parakeet ASR</span>
<span class="sm-model-name">${asrModel}</span>
<span class="sm-model-loaded">${asrLoaded ? '<span class="sm-pill ok">loaded</span>' : '<span class="sm-pill bad">not loaded</span>'}</span>
<span class="sm-model-loaded">${asrLoaded ? '<span class="tag ok">loaded</span>' : '<span class="tag bad">not loaded</span>'}</span>
</div>
<div class="sm-model-row">
<span class="sm-model-kind">Sortformer diarizer</span>
<span class="sm-model-name">${diarModel}</span>
<span class="sm-model-loaded">${diarLoaded ? '<span class="sm-pill ok">loaded</span>' : '<span class="sm-pill bad">not loaded</span>'}</span>
<span class="sm-model-loaded">${diarLoaded ? '<span class="tag ok">loaded</span>' : '<span class="tag bad">not loaded</span>'}</span>
</div>
</div>
<div class="sm-files">${fileRows}</div>
@@ -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();