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:
+66
-6
@@ -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();
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user