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:
@@ -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 ~60–120 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();
|
||||
|
||||
Reference in New Issue
Block a user