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();
+22 -2
View File
@@ -44,8 +44,14 @@
</dialog>
</section>
<section id="endpoint-panel" class="endpoint-panel hidden">
<div class="ep-title muted small">OpenAI-compatible endpoint</div>
<section id="endpoint-panel" class="endpoint-panel hidden collapsed">
<div class="ep-header">
<div class="ep-title muted small">OpenAI-compatible endpoint</div>
<button type="button" class="icon-btn ep-collapse-btn" id="ep-collapse" title="Show / hide endpoint details" aria-label="Toggle endpoint details">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
</div>
<div class="ep-body">
<div class="ep-row">
<span class="ep-label">Base URL</span>
<code class="ep-value copyable" id="ep-url" data-copy-self title="Click to copy"></code>
@@ -67,6 +73,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</details>
</div><!-- /.ep-body -->
</section>
<section id="swap-panel" class="swap-panel hidden">
@@ -89,6 +96,13 @@
</details>
</section>
<nav id="dashboard-tabs" class="dashboard-tabs hidden" role="tablist">
<button type="button" class="dashboard-tab" data-tab="llm" role="tab" aria-selected="true">LLM</button>
<button type="button" class="dashboard-tab" data-tab="audio" role="tab" aria-selected="false">Audio / Speech</button>
</nav>
<div class="tab-content" id="tab-audio" role="tabpanel" aria-labelledby="tab-audio-trigger">
<section id="services-panel" class="services hidden">
<div class="section-header">
<h2 class="section-title">Always-on services</h2>
@@ -176,6 +190,10 @@
</dialog>
</section>
</div><!-- /#tab-audio -->
<div class="tab-content" id="tab-llm" role="tabpanel" aria-labelledby="tab-llm-trigger">
<section id="models-section">
<div class="section-header">
<h2 class="section-title">LLM swap</h2>
@@ -328,6 +346,8 @@
</div>
</section>
</div><!-- /#tab-llm -->
<footer class="footer">
<div class="health">
<span class="health-item" id="h-vllm"><span class="dot"></span> vLLM</span>
+59 -10
View File
@@ -688,10 +688,17 @@ main {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 1.5;
display: inline-block;
}
.tag.mode-cluster { color: var(--info); border-color: rgba(96, 165, 250, 0.4); }
.tag.mode-solo { color: var(--accent); border-color: rgba(74, 222, 128, 0.4); }
.tag.cap { color: var(--muted); }
/* Semantic status pills — reuse .tag sizing so every pill on the page
renders at the same 11px / 2px×8px footprint. */
.tag.ok { color: var(--accent); border-color: rgba(74, 222, 128, 0.4); }
.tag.warn { color: var(--warn); border-color: rgba(245, 158, 11, 0.4); }
.tag.bad { color: var(--error); border-color: rgba(239, 68, 68, 0.4); }
.btn {
appearance: none;
@@ -792,16 +799,9 @@ main {
font-weight: 600;
color: var(--text);
}
.sm-pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-2);
}
.sm-pill.ok { color: var(--accent); border-color: rgba(74, 222, 128, 0.4); }
.sm-pill.warn { color: var(--warn); border-color: rgba(245, 158, 11, 0.4); }
.sm-pill.bad { color: var(--error); border-color: rgba(239, 68, 68, 0.4); }
/* .sm-pill removed in v0.11.0:1 — speech-models pills now reuse the shared
.tag styling (+ .tag.ok / .tag.warn / .tag.bad color modifiers) so every
pill on the page renders identically. */
.sm-models { display: flex; flex-direction: column; gap: 6px; }
.sm-model-row {
@@ -858,3 +858,52 @@ main {
font-weight: 600;
margin-top: 8px;
}
/* ===== Collapsible endpoint card (v0.11.0:1) ===== */
.endpoint-panel .ep-header {
display: flex;
align-items: center;
gap: 10px;
}
.endpoint-panel .ep-title { flex: 1; margin: 0; }
.endpoint-panel .ep-collapse-btn {
flex-shrink: 0;
transition: transform 0.2s;
}
.endpoint-panel.collapsed .ep-body { display: none; }
.endpoint-panel.collapsed .ep-collapse-btn svg { transform: rotate(-90deg); }
.endpoint-panel:not(.collapsed) .ep-header { margin-bottom: 10px; }
/* ===== Dashboard tabs (LLM / Audio) (v0.11.0:1) ===== */
.dashboard-tabs {
display: flex;
gap: 4px;
margin-top: 8px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
padding: 0 2px;
}
.dashboard-tab {
appearance: none;
background: transparent;
border: 1px solid transparent;
border-bottom: none;
color: var(--muted);
padding: 8px 16px;
border-radius: 6px 6px 0 0;
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 500;
margin-bottom: -1px;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.dashboard-tab:hover { color: var(--text); }
.dashboard-tab.active {
color: var(--text);
background: var(--surface);
border-color: var(--border);
border-bottom: 1px solid var(--surface);
}
.tab-content { display: none; }
.tab-content.active { display: block; }