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 @@
-
- OpenAI-compatible endpoint
+
+
+
Base URL
—
@@ -67,6 +73,7 @@
+
+
+
+