v0.11.0:0 - Speech model patches panel (lifecycle for v0.10.0 overlays)

Folds the image/parakeet_patches/apply.sh script into a one-click
dashboard action and adds drift detection so you can see at a glance
whether the parakeet-asr container has the latest Sortformer overlays
that spark-control ships.

Backend:
  * image/app/speech_models.py - SpeechModelsManager: reads /health from
    Parakeet, sha256s the local overlay files inside spark-control's
    Docker image (/app/parakeet_patches), sha256s the same files inside
    the parakeet-asr container via `docker exec ... sha256sum`, surfaces
    in_sync / drift / missing status per file.
  * GET  /api/speech-models           - status payload
  * POST /api/speech-models/reapply   - copies overlays into container,
                                         verifies python syntax, restarts,
                                         polls /health for ~120s, returns
                                         step-by-step result
  * POST /api/speech-models/restart   - plain `docker restart parakeet-asr`

Dockerfile: now COPY parakeet_patches into the image at /app/parakeet_patches
so the runtime can read them. Future spark-control releases auto-carry
newer overlay versions; the panel surfaces drift after upgrade.

Frontend: new "Speech model patches" section on the dashboard with
  * Status pill (in sync / drift / missing)
  * Per-file SHA comparison (local vs container)
  * Loaded-models pills (ASR + diarizer)
  * Reapply + Restart buttons (both with confirmation modals)
  * Live progress display during reapply with per-step ✓/✗

Verified post-install against the running cluster:
  GET /api/speech-models shows both files in_sync (SHAs match) and both
  models loaded ready on Spark 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-05-18 15:58:13 -05:00
parent fda23088fe
commit 391117f705
7 changed files with 620 additions and 2 deletions
+135
View File
@@ -532,6 +532,138 @@ async function onDeepHealthRun(name, btn) {
}
}
// ===================== speech-model patches (v0.11) =====================
async function renderSpeechModels() {
const panel = el('#speech-models-panel');
const card = el('#speech-models-card');
if (!panel || !card) return;
let data;
try {
data = await fetchJSON('/api/speech-models');
} catch (e) {
// If parakeet host isn't even configured, hide the section entirely
panel.classList.add('hidden');
return;
}
if (!data || !data.patches) { panel.classList.add('hidden'); return; }
panel.classList.remove('hidden');
const patches = data.patches || {};
const health = data.container_health || {};
const status = patches.status || 'unknown';
let statusPill;
if (status === 'in_sync') {
statusPill = `<span class="sm-pill ok">patches in sync</span>`;
} else if (status === 'drift') {
statusPill = `<span class="sm-pill warn">spark-control has newer patches</span>`;
} else if (status === 'missing') {
statusPill = `<span class="sm-pill bad">patches missing in container</span>`;
} else {
statusPill = `<span class="sm-pill warn">unknown</span>`;
}
const asrLoaded = !!health.asr_loaded;
const diarLoaded = !!health.diarizer_loaded;
const asrModel = escapeHtml(health.model || '—');
const diarModel = escapeHtml(health.diarizer_model || '—');
const fileRows = (patches.files || []).map((f) => {
const sync = f.in_sync
? '<span class="sm-file-ok">✓ in sync</span>'
: f.remote_sha == null
? '<span class="sm-file-bad">✗ missing</span>'
: '<span class="sm-file-warn">⚠ drift</span>';
const local = f.local_sha ? `<code>${escapeHtml(f.local_sha)}</code>` : '<span class="muted">—</span>';
const remote = f.remote_sha ? `<code>${escapeHtml(f.remote_sha)}</code>` : '<span class="muted">—</span>';
return `
<div class="sm-file-row">
<span class="sm-file-name"><code>${escapeHtml(f.name)}</code></span>
<span class="sm-file-sync">${sync}</span>
<span class="sm-file-sha muted small">local ${local} → remote ${remote}</span>
</div>
`;
}).join('');
const lastReapply = patches.last_reapply_at ? new Date(patches.last_reapply_at).toLocaleString() : 'never (since spark-control boot)';
const lastRestart = patches.last_restart_at ? new Date(patches.last_restart_at).toLocaleString() : 'never (since spark-control boot)';
card.innerHTML = `
<div class="sm-header">
<div class="sm-title">parakeet-asr container</div>
${statusPill}
</div>
<div class="sm-models">
<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>
</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>
</div>
</div>
<div class="sm-files">${fileRows}</div>
<div class="sm-meta muted small">
Last reapply: ${escapeHtml(lastReapply)} · Last manual restart: ${escapeHtml(lastRestart)}
</div>
<div class="sm-actions">
<button class="btn primary" id="sm-reapply">Reapply patches</button>
<button class="btn" id="sm-restart">Restart container</button>
</div>
`;
el('#sm-reapply').addEventListener('click', onSpeechModelsReapply);
el('#sm-restart').addEventListener('click', onSpeechModelsRestart);
}
async function onSpeechModelsReapply() {
if (!confirm('Reapply Sortformer patches to the parakeet-asr container? The container will restart and both ASR + diarizer will be unavailable for ~60120 seconds.')) return;
const dlg = el('#speech-models-progress-dialog');
const steps = el('#sm-prog-steps');
const closeBtn = el('#sm-prog-close');
steps.innerHTML = '<div class="muted small">Starting…</div>';
closeBtn.disabled = true;
closeBtn.onclick = () => dlg.close();
dlg.showModal();
try {
const r = await fetchJSON('/api/speech-models/reapply', { method: 'POST' });
steps.innerHTML = (r.steps || []).map((s) => {
const mark = s.ok ? '<span class="sm-file-ok">✓</span>' : '<span class="sm-file-bad">✗</span>';
const extra = s.error ? `<div class="muted small">${escapeHtml(s.error)}</div>` : '';
return `<div class="sm-prog-step">${mark} <strong>${escapeHtml(s.step)}</strong>${s.name ? ` (${escapeHtml(s.name)})` : ''}${extra}</div>`;
}).join('') + `<div class="sm-prog-done sm-file-ok">Done — both models reloaded.</div>`;
} catch (e) {
let parsed = null;
try { parsed = JSON.parse(e.message.split(':').slice(2).join(':').trim()); } catch {}
const stepHtml = parsed && parsed.result && parsed.result.steps
? parsed.result.steps.map((s) => {
const mark = s.ok ? '<span class="sm-file-ok">✓</span>' : '<span class="sm-file-bad">✗</span>';
return `<div class="sm-prog-step">${mark} <strong>${escapeHtml(s.step)}</strong>${s.name ? ` (${escapeHtml(s.name)})` : ''}${s.error ? `<div class="muted small">${escapeHtml(s.error)}</div>` : ''}</div>`;
}).join('')
: `<div class="sm-file-bad">${escapeHtml(e.message)}</div>`;
steps.innerHTML = stepHtml + `<div class="sm-prog-done sm-file-bad">Failed.</div>`;
} finally {
closeBtn.disabled = false;
try { await renderSpeechModels(); } catch {}
}
}
async function onSpeechModelsRestart() {
if (!confirm('Restart parakeet-asr container? STT + diarization will be unavailable for ~30 seconds.')) return;
try {
await fetchJSON('/api/speech-models/restart', { method: 'POST' });
} catch (e) {
alert('Restart failed: ' + e.message);
} finally {
try { await renderSpeechModels(); } catch {}
}
}
async function onServiceAction(key) {
if (state.service_action_in_flight) return;
const [name, action] = key.split(':');
@@ -1675,10 +1807,13 @@ async function init() {
pollUpdates();
// Disk-status probe runs after first paint — slow over SSH and not blocking.
loadDiskStatus();
// Speech-model patches panel — slow over SSH, runs after first paint.
renderSpeechModels();
setInterval(pollStatus, 5000);
setInterval(pollHardware, 8000); // every 8s
setInterval(pollUpdates, 300000); // every 5 min
setInterval(loadDiskStatus, 60000); // every 60s — disk state changes rarely
setInterval(renderSpeechModels, 120000); // every 2 min — patches change rarely
}
init();
+24
View File
@@ -152,6 +152,30 @@
</dialog>
</section>
<section id="speech-models-panel" class="speech-models hidden">
<div class="section-header">
<h2 class="section-title">Speech model patches</h2>
</div>
<p class="muted small sm-blurb">
Spark Control adds Sortformer speaker diarization to the third-party Parakeet ASR
container via two Python overlays (<code>diarizer.py</code> + a patched <code>main.py</code>).
Overlays survive container restart but not a fresh redeploy — if the parakeet container is
ever rebuilt, click <strong>Reapply patches</strong> below to restore them.
</p>
<div id="speech-models-card" class="speech-models-card"></div>
<dialog id="speech-models-progress-dialog" class="modal">
<form method="dialog" class="modal-form">
<h3>Reapplying speech-model patches…</h3>
<p class="muted small">Copying overlays into the parakeet container, verifying syntax, restarting, waiting for both models to load. Takes ~60120 s.</p>
<div id="sm-prog-steps" class="sm-prog-steps"></div>
<div class="modal-actions">
<button type="button" id="sm-prog-close" class="btn" disabled>Close</button>
</div>
</form>
</dialog>
</section>
<section id="models-section">
<div class="section-header">
<h2 class="section-title">LLM swap</h2>
+94
View File
@@ -764,3 +764,97 @@ main {
main { padding: 16px 14px 80px; }
.cards { grid-template-columns: 1fr; }
}
/* ===== Speech model patches (v0.11) ===== */
.speech-models { margin-top: 28px; }
.sm-blurb { max-width: 880px; margin-bottom: 14px; }
.sm-blurb code {
background: var(--surface-2);
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
}
.speech-models-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.sm-header {
display: flex;
align-items: center;
gap: 10px;
}
.sm-title {
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-models { display: flex; flex-direction: column; gap: 6px; }
.sm-model-row {
display: grid;
grid-template-columns: 160px 1fr auto;
align-items: center;
gap: 12px;
padding: 6px 0;
border-top: 1px solid var(--border);
}
.sm-model-row:first-child { border-top: none; }
.sm-model-kind { color: var(--muted); font-size: 13px; }
.sm-model-name { font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
.sm-files { display: flex; flex-direction: column; gap: 4px; }
.sm-file-row {
display: grid;
grid-template-columns: 160px 100px 1fr;
gap: 12px;
font-size: 12px;
padding: 4px 0;
}
.sm-file-name code {
background: var(--surface-2);
padding: 1px 6px;
border-radius: 4px;
}
.sm-file-ok { color: var(--accent); }
.sm-file-warn { color: var(--warn); }
.sm-file-bad { color: var(--error); }
.sm-file-sha code {
background: var(--surface-2);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
.sm-meta { margin-top: 4px; }
.sm-actions { display: flex; gap: 10px; }
.sm-prog-steps {
display: flex;
flex-direction: column;
gap: 6px;
margin: 12px 0;
font-size: 13px;
}
.sm-prog-step {
padding: 6px 10px;
background: var(--surface-2);
border-radius: 6px;
}
.sm-prog-done {
font-weight: 600;
margin-top: 8px;
}