Files
spark-control/image/app/static/app.js
T
Grant 75fd0846b4 v0.2.3 - Per-model Advanced settings + catalog-add for downloaded models
Backend:
- overrides.py: read/write /data/models-overrides.yaml (knobs + custom entries)
- apply_knobs_to_args(): strip matching flags from bundled vllm_args and append knob values, so knob changes properly override bundled defaults
- extract_knobs_from_args(): seed UI knob values from bundled args so the Advanced dialog has correct starting state
- models.py: load_catalog merges overrides on top of bundled yaml
- GET /api/models returns effective_knobs per model
- PUT /api/models/{key}/knobs persists knob changes
- POST /api/models adds a custom catalog entry
- DELETE /api/models/{key} removes a custom entry (bundled models cannot be deleted)
- swap_manager.reload_catalog() called after each mutation so swaps see latest

Frontend:
- New 'Advanced' button on every card opens a modal dialog: max-model-len input, gpu-memory-utilization slider, three optimization checkboxes (fastsafetensors, prefix caching, FP8 KV cache). Save persists; Cancel discards. Custom models also have a Delete button.
- After a successful download, automatically open the 'Add to catalog' dialog pre-filled with the repo, with the same knob defaults — user just enters key, display name, and clicks Save.
- Custom catalog entries are tagged with a blue 'custom' pill on the card.

Package: bump 0.2.3:0; main.ts sets MODELS_OVERRIDES=/data/models-overrides.yaml so overrides persist on the StartOS volume.
2026-05-12 11:30:47 -05:00

907 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// spark-control front-end
const state = {
models: {},
defaults: {},
current_model_key: null,
swap_job_id: null,
swap_eventsource: null,
swap_started_at: null,
swap_lines: [], // local accumulator for phase detection
swap_phase: 'Starting…',
swap_phase_detail: '',
swap_progress: 0, // 01
services: {},
service_action_in_flight: null, // e.g. "parakeet:restart"
configured: true,
timer_handle: null,
};
const el = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
async function fetchJSON(url, opts) {
const r = await fetch(url, opts);
if (!r.ok) {
const text = await r.text().catch(() => '');
throw new Error(`${r.status} ${r.statusText}: ${text}`);
}
return r.json();
}
// ===================== rendering =====================
function renderCards() {
const root = el('#cards');
root.innerHTML = '';
const isSwapping = !!state.swap_job_id;
for (const key of Object.keys(state.models)) {
const m = state.models[key];
const isActive = key === state.current_model_key;
const card = document.createElement('div');
card.className = 'card' + (isActive ? ' active' : '');
const desc = m.description
? `<div class="desc">${escapeHtml(m.description)}</div>`
: '';
const customPill = m.custom ? `<span class="tag custom-pill">custom</span>` : '';
card.innerHTML = `
<div class="name">${escapeHtml(m.display_name)}</div>
<div class="meta">
<span class="tag mode-${m.mode}">${m.mode}</span>
<span class="tag">${m.size_gb} GB</span>
${customPill}
${(m.capabilities || []).map(c => `<span class="tag cap">${escapeHtml(c)}</span>`).join('')}
</div>
${desc}
<div class="muted small repo">${escapeHtml(m.repo)}</div>
<div class="spacer"></div>
<div class="card-actions">
<button class="btn ${isActive ? '' : 'primary'}" data-swap-key="${key}" ${isActive || isSwapping ? 'disabled' : ''}>
${isActive ? 'Current' : 'Switch to this'}
</button>
<button class="btn adv-btn" data-adv-key="${key}" title="Advanced settings">Advanced</button>
</div>
`;
root.appendChild(card);
}
for (const btn of root.querySelectorAll('[data-swap-key]')) {
btn.addEventListener('click', () => triggerSwap(btn.dataset.swapKey));
}
for (const btn of root.querySelectorAll('[data-adv-key]')) {
btn.addEventListener('click', () => openAdvanced(btn.dataset.advKey));
}
}
function renderCurrent(status) {
const c = el('#current');
if (!status.configured) { c.innerHTML = `<span class="muted">not configured</span>`; return; }
if (status.current_swap_job) { c.innerHTML = `<span class="muted">swap in progress</span>`; return; }
const v = status.vllm || {};
if (!v.ok) { c.innerHTML = `<span class="muted">vLLM unreachable</span>`; return; }
const m = status.current_model_key ? state.models[status.current_model_key] : null;
const label = m ? m.display_name : (v.current_model || '(unknown)');
c.innerHTML = `<strong>${label}</strong>`;
}
function classifyService(s) {
// returns one of: running | unhealthy | missing | unconfigured | starting
if (!s.host) return 'unconfigured';
if (s.docker_state === 'missing') return 'missing';
if (s.docker_state === 'restarting') return 'unhealthy';
if (s.docker_state === 'exited') return 'unhealthy';
if (s.docker_state === 'running' && !s.http_ready) return 'starting';
if (s.docker_state === 'running' && s.http_ready) return 'running';
return s.docker_state || 'unknown';
}
function statusLabel(cls) {
return {
running: 'Healthy',
unhealthy: 'Unhealthy',
starting: 'Starting…',
missing: 'Not installed',
unconfigured: 'Not configured',
unknown: 'Unknown',
}[cls] || cls;
}
async function renderServices() {
let services = state.services;
// First render: fetch.
if (!services || Object.keys(services).length === 0) {
try {
services = await fetchJSON('/api/services');
state.services = services;
} catch (e) { console.error('services fetch failed', e); return; }
}
const panel = el('#services-panel');
const grid = el('#services-grid');
const entries = Object.entries(services);
if (entries.length === 0) { panel.classList.add('hidden'); return; }
panel.classList.remove('hidden');
grid.innerHTML = '';
for (const [name, s] of entries) {
const cls = classifyService(s);
const card = document.createElement('div');
card.className = `service-card ${cls}`;
const inFlight = state.service_action_in_flight && state.service_action_in_flight.startsWith(name + ':');
const disable = (action) => {
// Disable buttons that don't make sense for the current state
if (inFlight) return true;
if (cls === 'unconfigured' || cls === 'missing') return true;
if (action === 'start' && (cls === 'running' || cls === 'starting')) return true;
if (action === 'stop' && cls !== 'running' && cls !== 'starting' && cls !== 'unhealthy') return true;
return false;
};
const hostRow = s.host
? `<div class="row"><span class="k">Host</span><span class="v">${escapeHtml(s.host)}:${s.port}</span></div>`
: `<div class="row"><span class="k">Host</span><span class="v muted-v">not configured</span></div>`;
const modelRow = s.model
? `<div class="row"><span class="k">Model</span><span class="v">${escapeHtml(s.model)}</span></div>`
: '';
const restartsRow = s.restart_count != null && s.restart_count > 1
? `<div class="row"><span class="k">Restarts</span><span class="v">${s.restart_count}</span></div>`
: '';
card.innerHTML = `
<div class="head">
<span class="name">${escapeHtml(name)}</span>
<span class="kind">${escapeHtml(s.kind || '')}</span>
<span class="status">${statusLabel(cls)}</span>
</div>
${hostRow}
${modelRow}
${restartsRow}
<div class="service-actions">
<button class="btn" data-svc-action="${name}:start" ${disable('start') ? 'disabled' : ''}>Start</button>
<button class="btn" data-svc-action="${name}:restart" ${disable('restart') ? 'disabled' : ''}>Restart</button>
<button class="btn danger" data-svc-action="${name}:stop" ${disable('stop') ? 'disabled' : ''}>Stop</button>
</div>
`;
grid.appendChild(card);
}
for (const btn of grid.querySelectorAll('.btn[data-svc-action]')) {
btn.addEventListener('click', () => onServiceAction(btn.dataset.svcAction));
}
}
async function onServiceAction(key) {
if (state.service_action_in_flight) return;
const [name, action] = key.split(':');
state.service_action_in_flight = key;
renderServices();
try {
await fetchJSON(`/api/services/${name}/${action}`, { method: 'POST' });
} catch (e) {
alert(`${action} ${name} failed: ${e.message}`);
} finally {
state.service_action_in_flight = null;
// Refresh services state
try {
state.services = await fetchJSON('/api/services');
} catch {}
renderServices();
pollStatus();
}
}
function renderEndpoint(status) {
const v = status.vllm || {};
const panel = el('#endpoint-panel');
const ready = v.ok && v.current_model && v.base_url;
panel.classList.toggle('hidden', !ready);
if (!ready) return;
el('#ep-url').textContent = v.base_url;
el('#ep-model').textContent = v.current_model;
const snippet =
`curl -s ${v.base_url}/chat/completions \\
-H 'content-type: application/json' \\
-d '{
"model": "${v.current_model}",
"messages": [{"role": "user", "content": "Hello"}]
}'`;
el('#ep-curl-snippet').textContent = snippet;
}
function setupCopyButtons() {
document.body.addEventListener('click', async (e) => {
const btn = e.target.closest('.copy-btn');
if (!btn) return;
const targetSel = btn.dataset.copy;
if (!targetSel) return;
const target = el(targetSel);
if (!target) return;
const text = target.textContent;
try {
await navigator.clipboard.writeText(text);
const original = btn.textContent;
btn.classList.add('copied');
btn.textContent = 'Copied';
setTimeout(() => {
btn.classList.remove('copied');
btn.textContent = original;
}, 1400);
} catch {
// Clipboard API may fail over plain HTTP; fall back to selection
const range = document.createRange();
range.selectNode(target);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
});
}
function renderHealth(status) {
function setDot(id, ok, payload) {
const item = el(id);
if (!item) return;
const dot = item.querySelector('.dot');
dot.classList.remove('ok', 'bad', 'warn');
if (ok === true) dot.classList.add('ok');
else if (ok === false) dot.classList.add('bad');
else dot.classList.add('warn');
item.title = JSON.stringify(payload || {}, null, 2);
}
setDot('#h-vllm', status.vllm && status.vllm.ok, status.vllm);
setDot('#h-parakeet', status.parakeet && status.parakeet.ok, status.parakeet);
setDot('#h-magpie', status.magpie && status.magpie.ok, status.magpie);
el('#updated').textContent = `updated ${new Date().toLocaleTimeString()}`;
}
function renderBanner(status) {
el('#setup-banner').classList.toggle('hidden', !!status.configured);
}
function renderSwapPanel() {
el('#swap-phase').textContent = state.swap_phase;
el('#swap-phase-detail').textContent = state.swap_phase_detail;
el('#swap-phase-fill').style.width = `${Math.max(2, Math.round(state.swap_progress * 100))}%`;
}
// ===================== phase detection =====================
const PHASE_ORDER = [
['Stopping current model…', 0.08],
['Starting new model…', 0.16],
['Joining Ray cluster…', 0.22],
['Loading weights…', 0.30],
['Compiling kernels…', 0.78],
['Warming up…', 0.88],
['Starting API server…', 0.94],
['Ready ✓', 1.00],
['Failed', 1.00],
];
function phaseProgress(name) {
const found = PHASE_ORDER.find(([n]) => n === name);
return found ? found[1] : 0.05;
}
function deriveSwapPhase(serverState, lines) {
// Default phase from server state
let phase = ({
starting: 'Starting…',
stopping: 'Stopping current model…',
launching: 'Starting new model…',
tailing: 'Loading weights…',
ready: 'Ready ✓',
failed: 'Failed',
})[serverState] || 'Working…';
let detail = '';
// Refine from log content (search recent lines first)
const tail = lines.slice(-40);
for (let i = tail.length - 1; i >= 0; i--) {
const line = tail[i];
if (line.includes('Application startup complete')) {
phase = 'Ready ✓';
break;
}
if (line.includes('Started server process')) {
phase = 'Starting API server…';
break;
}
if (line.includes('Initial profiling/warmup') || line.includes('init engine (profile, create kv cache, warmup model)')) {
phase = 'Warming up…';
break;
}
if (line.match(/Capturing CUDA graphs|Compiling a graph|torch\.compile took|Graph capturing/)) {
phase = 'Compiling kernels…';
break;
}
const shard = line.match(/Loading safetensors checkpoint shards:\s+(\d+)%\s+Completed\s+\|\s+(\d+)\/(\d+)/);
if (shard) {
phase = 'Loading weights…';
detail = `${shard[2]} of ${shard[3]} shards (${shard[1]}%)`;
const innerProgress = parseInt(shard[2], 10) / parseInt(shard[3], 10);
// Map shard progress 0..1 into the 0.30..0.78 band
state.swap_progress = 0.30 + (0.78 - 0.30) * innerProgress;
state.swap_phase = phase;
state.swap_phase_detail = detail;
return;
}
if (line.includes('Connecting to existing Ray cluster')) {
phase = 'Joining Ray cluster…';
break;
}
if (line.includes('Resolved architecture') || line.match(/launch-cluster\.sh.*exec vllm serve/)) {
phase = 'Starting new model…';
break;
}
if (line.match(/launch-cluster\.sh stop/)) {
phase = 'Stopping current model…';
break;
}
}
state.swap_phase = phase;
state.swap_phase_detail = detail;
state.swap_progress = phaseProgress(phase);
}
// ===================== timer =====================
function startTimer(startedAtMillis) {
state.swap_started_at = startedAtMillis;
if (state.timer_handle) clearInterval(state.timer_handle);
const tick = () => {
if (!state.swap_started_at) return;
const sec = Math.max(0, Math.floor((Date.now() - state.swap_started_at) / 1000));
const m = Math.floor(sec / 60);
const s = sec % 60;
el('#swap-elapsed').textContent = `${m}:${s.toString().padStart(2, '0')}`;
};
tick();
state.timer_handle = setInterval(tick, 500);
}
function stopTimer() {
if (state.timer_handle) { clearInterval(state.timer_handle); state.timer_handle = null; }
}
// ===================== polling + SSE =====================
async function pollStatus() {
try {
const status = await fetchJSON('/api/status');
state.current_model_key = status.current_model_key;
state.configured = status.configured;
renderBanner(status);
renderCurrent(status);
renderEndpoint(status);
renderHealth(status);
// Refresh services state lazily — every 5s poll triggers this too.
try {
state.services = await fetchJSON('/api/services');
renderServices();
} catch {}
if (status.current_swap_job && status.current_swap_job !== state.swap_job_id) {
attachToSwap(status.current_swap_job, /*needsBackfill=*/true);
} else if (!status.current_swap_job && state.swap_job_id && !state.swap_eventsource) {
// Foreign swap ended
detachSwap();
}
renderCards();
} catch (e) {
console.error('status poll failed', e);
}
}
async function loadModels() {
const data = await fetchJSON('/api/models');
state.defaults = data.defaults || {};
state.models = data.models || {};
}
async function triggerSwap(modelKey) {
if (state.swap_job_id) return;
try {
const r = await fetchJSON('/api/swap', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model_key: modelKey }),
});
attachToSwap(r.job_id, /*needsBackfill=*/false);
} catch (e) {
alert('Failed to start swap: ' + e.message);
}
}
async function attachToSwap(jobId, needsBackfill) {
if (state.swap_eventsource) {
state.swap_eventsource.close();
state.swap_eventsource = null;
}
state.swap_job_id = jobId;
state.swap_lines = [];
state.swap_phase = 'Starting…';
state.swap_phase_detail = '';
state.swap_progress = 0.05;
el('#swap-log').textContent = '';
el('#swap-panel').classList.remove('hidden');
renderSwapPanel();
// Backfill (if joining mid-swap) — fetch the snapshot so we have started_at + history
try {
const snap = await fetchJSON(`/api/swap/${jobId}`);
const ts = Date.parse(snap.started_at);
if (!isNaN(ts)) startTimer(ts);
state.swap_lines = snap.lines || [];
for (const line of state.swap_lines) appendLog(line);
deriveSwapPhase(snap.state, state.swap_lines);
renderSwapPanel();
if (snap.returncode !== null && snap.returncode !== undefined) {
// Already finished — close panel after a beat
handleSwapDone(snap);
return;
}
} catch (e) {
if (!needsBackfill) startTimer(Date.now());
console.warn('backfill failed', e);
}
const es = new EventSource(`/api/swap/${jobId}/stream`);
state.swap_eventsource = es;
es.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.line !== undefined) {
state.swap_lines.push(d.line);
appendLog(d.line);
deriveSwapPhase(d.state, state.swap_lines);
renderSwapPanel();
} else if (d.state) {
deriveSwapPhase(d.state, state.swap_lines);
renderSwapPanel();
}
} catch {}
};
es.addEventListener('done', async (ev) => {
let d = {};
try { d = JSON.parse(ev.data); } catch {}
handleSwapDone(d);
});
es.onerror = () => {
// Tab backgrounded or network blip — close; status poll will reattach
es.close();
state.swap_eventsource = null;
};
renderCards();
}
function handleSwapDone(d) {
if (state.swap_eventsource) { state.swap_eventsource.close(); state.swap_eventsource = null; }
const finalState = d.state || 'ready';
state.swap_phase = finalState === 'failed' ? 'Failed' : 'Ready ✓';
state.swap_phase_detail = d.returncode !== undefined ? `exit code ${d.returncode}` : '';
state.swap_progress = 1.0;
renderSwapPanel();
setTimeout(() => detachSwap(), 4000);
pollStatus();
}
function detachSwap() {
state.swap_job_id = null;
if (state.swap_eventsource) { state.swap_eventsource.close(); state.swap_eventsource = null; }
stopTimer();
el('#swap-panel').classList.add('hidden');
renderCards();
}
function appendLog(line) {
const log = el('#swap-log');
log.textContent += line + '\n';
log.scrollTop = log.scrollHeight;
}
// ===================== model downloads =====================
const dlState = {
job_id: null,
eventsource: null,
started_at: null,
timer_handle: null,
};
function openDownloadForm() {
el('#download-panel').classList.remove('hidden');
el('#download-form').classList.remove('hidden');
el('#download-progress').classList.add('hidden');
el('#dl-repo').focus();
}
function closeDownloadPanel() {
el('#download-panel').classList.add('hidden');
el('#download-form').classList.remove('hidden');
el('#download-progress').classList.add('hidden');
el('#dl-repo').value = '';
}
function dlTimerStart(startedAt) {
dlState.started_at = startedAt;
if (dlState.timer_handle) clearInterval(dlState.timer_handle);
const tick = () => {
if (!dlState.started_at) return;
const sec = Math.max(0, Math.floor((Date.now() - dlState.started_at) / 1000));
const m = Math.floor(sec / 60);
const s = sec % 60;
el('#dl-elapsed').textContent = `${m}:${s.toString().padStart(2, '0')}`;
};
tick();
dlState.timer_handle = setInterval(tick, 500);
}
function dlTimerStop() {
if (dlState.timer_handle) { clearInterval(dlState.timer_handle); dlState.timer_handle = null; }
}
async function startDownload() {
const repo = el('#dl-repo').value.trim();
const mode = document.querySelector('input[name="dl-mode"]:checked').value;
if (!repo || !repo.includes('/')) {
alert('Enter a HuggingFace repo in the form "org/name", e.g. RedHatAI/Qwen3.6-35B-A3B-NVFP4');
return;
}
dlState.last_repo = repo;
dlState.last_mode = mode;
try {
const r = await fetchJSON('/api/download', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ repo, mode }),
});
attachToDownload(r.job_id);
} catch (e) {
alert('Failed to start download: ' + e.message);
}
}
function renderDownloadProgress(p) {
el('#dl-phase').textContent = p.phase || 'Working…';
const statsParts = [];
if (p.downloaded && p.total) statsParts.push(`${p.downloaded} / ${p.total}`);
if (p.rate) statsParts.push(p.rate);
if (p.eta) statsParts.push(`ETA ${p.eta}`);
el('#dl-stats').textContent = statsParts.join(' · ');
const pct = Math.max(2, Math.min(100, p.percent || 2));
el('#dl-progress-fill').style.width = `${pct}%`;
el('#dl-phase-detail').textContent = p.percent > 0 ? `${p.percent.toFixed(1)}%` : '';
}
function dlAppendLog(line) {
const log = el('#dl-log');
log.textContent += line + '\n';
log.scrollTop = log.scrollHeight;
}
async function attachToDownload(jobId) {
if (dlState.eventsource) { dlState.eventsource.close(); dlState.eventsource = null; }
dlState.job_id = jobId;
el('#download-form').classList.add('hidden');
el('#download-progress').classList.remove('hidden');
el('#dl-log').textContent = '';
el('#dl-title').textContent = 'Downloading…';
try {
const snap = await fetchJSON(`/api/download/${jobId}`);
dlTimerStart(Date.parse(snap.started_at));
for (const line of snap.lines || []) dlAppendLog(line);
renderDownloadProgress(snap.progress);
if (snap.returncode !== null && snap.returncode !== undefined) {
handleDownloadDone(snap);
return;
}
} catch (e) {
console.warn('download backfill failed', e);
dlTimerStart(Date.now());
}
const es = new EventSource(`/api/download/${jobId}/stream`);
dlState.eventsource = es;
es.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.line !== undefined) dlAppendLog(d.line);
} catch {}
};
es.addEventListener('progress', (ev) => {
try { renderDownloadProgress(JSON.parse(ev.data)); } catch {}
});
es.addEventListener('done', (ev) => {
let d = {};
try { d = JSON.parse(ev.data); } catch {}
handleDownloadDone(d);
});
es.onerror = () => { es.close(); dlState.eventsource = null; };
}
function handleDownloadDone(d) {
if (dlState.eventsource) { dlState.eventsource.close(); dlState.eventsource = null; }
dlTimerStop();
if (d.state === 'failed') {
el('#dl-title').textContent = `Failed (rc=${d.returncode})`;
el('#dl-phase').textContent = 'Failed';
} else {
el('#dl-title').textContent = 'Done';
el('#dl-phase').textContent = 'Done ✓';
el('#dl-progress-fill').style.width = '100%';
// Offer to add to catalog
const repo = dlState.last_repo;
const mode = dlState.last_mode;
if (repo) {
setTimeout(() => openCatalogDialog(repo, mode), 600);
}
}
dlState.job_id = null;
}
// ===================== Advanced / Add to catalog =====================
function openAdvanced(key) {
const m = state.models[key];
if (!m) return;
const dlg = el('#advanced-dialog');
el('#adv-title').textContent = `Advanced — ${m.display_name}`;
const k = m.effective_knobs || {};
el('#adv-mml').value = k.max_model_len ?? '';
el('#adv-gmu').value = k.gpu_memory_utilization ?? 0.85;
el('#adv-gmu-out').value = parseFloat(el('#adv-gmu').value).toFixed(2);
el('#adv-fst').checked = !!k.fastsafetensors;
el('#adv-pcache').checked = !!k.prefix_caching;
el('#adv-fp8').checked = k.kv_cache_dtype === 'fp8';
const del = el('#adv-delete');
del.classList.toggle('hidden', !m.custom);
del.onclick = async () => {
if (!confirm(`Delete "${m.display_name}" from the catalog? The model weights on disk are NOT deleted.`)) return;
try {
await fetchJSON(`/api/models/${encodeURIComponent(key)}`, { method: 'DELETE' });
dlg.close();
await loadModels();
pollStatus();
} catch (e) { alert('Delete failed: ' + e.message); }
};
const form = el('#advanced-form');
form.onsubmit = async (e) => {
e.preventDefault();
const knobs = {};
const mml = parseInt(el('#adv-mml').value, 10);
if (Number.isFinite(mml) && mml > 0) knobs.max_model_len = mml;
const gmu = parseFloat(el('#adv-gmu').value);
if (Number.isFinite(gmu)) knobs.gpu_memory_utilization = gmu;
if (el('#adv-fst').checked) knobs.fastsafetensors = true; else knobs.fastsafetensors = false;
if (el('#adv-pcache').checked) knobs.prefix_caching = true; else knobs.prefix_caching = false;
knobs.kv_cache_dtype = el('#adv-fp8').checked ? 'fp8' : 'auto';
try {
await fetchJSON(`/api/models/${encodeURIComponent(key)}/knobs`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ knobs }),
});
dlg.close();
await loadModels();
pollStatus();
} catch (e) { alert('Save failed: ' + e.message); }
};
dlg.showModal();
}
function openCatalogDialog(repo, mode) {
const dlg = el('#catalog-dialog');
const key = repo.split('/').pop().toLowerCase().replace(/[^a-z0-9_-]/g, '-');
el('#cd-key').value = key;
el('#cd-name').value = repo.split('/').pop();
el('#cd-repo').value = repo;
el('#cd-size').value = '';
el('#cd-mode').value = mode || 'solo';
el('#cd-desc').value = '';
el('#cd-mml').value = 32768;
el('#cd-gmu').value = 0.85;
el('#cd-gmu-out').value = '0.85';
el('#cd-fst').checked = true;
el('#cd-pcache').checked = true;
el('#cd-fp8').checked = true;
dlg.showModal();
}
function setupCatalogDialog() {
el('#cd-cancel').addEventListener('click', () => el('#catalog-dialog').close());
el('#cd-gmu').addEventListener('input', (e) => { el('#cd-gmu-out').value = parseFloat(e.target.value).toFixed(2); });
el('#catalog-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = {
key: el('#cd-key').value.trim(),
display_name: el('#cd-name').value.trim(),
repo: el('#cd-repo').value.trim(),
size_gb: parseFloat(el('#cd-size').value) || 0,
mode: el('#cd-mode').value,
description: el('#cd-desc').value.trim() || null,
vllm_args: [],
knobs: {
max_model_len: parseInt(el('#cd-mml').value, 10) || 32768,
gpu_memory_utilization: parseFloat(el('#cd-gmu').value),
fastsafetensors: el('#cd-fst').checked,
prefix_caching: el('#cd-pcache').checked,
kv_cache_dtype: el('#cd-fp8').checked ? 'fp8' : 'auto',
},
};
try {
await fetchJSON('/api/models', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
el('#catalog-dialog').close();
closeDownloadPanel();
await loadModels();
pollStatus();
} catch (e) { alert('Add to catalog failed: ' + e.message); }
});
}
function setupAdvancedDialog() {
el('#adv-cancel').addEventListener('click', () => el('#advanced-dialog').close());
el('#adv-gmu').addEventListener('input', (e) => { el('#adv-gmu-out').value = parseFloat(e.target.value).toFixed(2); });
}
// ===================== updates (spark-vllm-docker) =====================
const updState = {
info: null,
job_id: null,
eventsource: null,
started_at: null,
timer_handle: null,
};
async function pollUpdates() {
try {
const info = await fetchJSON('/api/updates');
updState.info = info;
renderUpdateBanner();
} catch (e) {
console.warn('updates poll failed', e);
}
}
function renderUpdateBanner() {
const banner = el('#update-banner');
const info = updState.info;
const text = el('#ub-text');
const details = el('#ub-details');
const apply = el('#ub-apply');
const list = el('#ub-list');
const log = el('#ub-log');
if (!info || !info.ok) {
banner.classList.add('hidden');
return;
}
banner.classList.remove('hidden');
const behind = info.behind || 0;
const dirty = info.dirty || 0;
banner.classList.toggle('up-to-date', behind === 0 && !dirty);
banner.classList.toggle('warn', !!dirty);
if (dirty > 0) {
text.textContent = `${dirty} local change${dirty === 1 ? '' : 's'} in ~/spark-vllm-docker. Resolve before updating.`;
details.classList.add('hidden');
apply.classList.add('hidden');
} else if (behind === 0) {
text.textContent = `spark-vllm-docker is up to date (${info.current || ''})`;
details.classList.add('hidden');
apply.classList.add('hidden');
list.classList.add('hidden');
} else {
text.textContent = `${behind} commit${behind === 1 ? '' : 's'} behind upstream`;
details.classList.remove('hidden');
apply.classList.remove('hidden');
log.textContent = (info.log || []).join('\n') || '(no log)';
}
}
function ubTimerStart(startedAt) {
updState.started_at = startedAt;
if (updState.timer_handle) clearInterval(updState.timer_handle);
const tick = () => {
if (!updState.started_at) return;
const sec = Math.max(0, Math.floor((Date.now() - updState.started_at) / 1000));
const m = Math.floor(sec / 60);
const s = sec % 60;
el('#ub-elapsed').textContent = `${m}:${s.toString().padStart(2, '0')}`;
};
tick();
updState.timer_handle = setInterval(tick, 500);
}
async function applyUpdate() {
if (!confirm('This pulls the latest spark-vllm-docker and rebuilds the vLLM container. Can take 540 minutes; the cluster is unaffected until you swap to a different model. Continue?')) return;
try {
const r = await fetchJSON('/api/updates/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'cluster' }),
});
attachToUpdate(r.job_id);
} catch (e) {
alert('Failed to start update: ' + e.message);
}
}
async function attachToUpdate(jobId) {
updState.job_id = jobId;
el('#ub-progress').classList.remove('hidden');
el('#ub-apply').classList.add('hidden');
el('#ub-stream').textContent = '';
el('#ub-phase').textContent = 'Starting…';
try {
const snap = await fetchJSON(`/api/updates/${jobId}`);
ubTimerStart(Date.parse(snap.started_at));
el('#ub-phase').textContent = snap.phase || 'Working…';
el('#ub-stream').textContent = (snap.lines || []).join('\n');
if (snap.returncode !== null) { handleUpdateDone(snap); return; }
} catch (e) {
ubTimerStart(Date.now());
}
const es = new EventSource(`/api/updates/${jobId}/stream`);
updState.eventsource = es;
es.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.line !== undefined) {
const log = el('#ub-stream');
log.textContent += d.line + '\n';
log.scrollTop = log.scrollHeight;
}
} catch {}
};
es.addEventListener('phase', (ev) => {
try { el('#ub-phase').textContent = JSON.parse(ev.data).phase; } catch {}
});
es.addEventListener('done', (ev) => {
let d = {}; try { d = JSON.parse(ev.data); } catch {}
handleUpdateDone(d);
});
es.onerror = () => { es.close(); updState.eventsource = null; };
}
function handleUpdateDone(d) {
if (updState.eventsource) { updState.eventsource.close(); updState.eventsource = null; }
if (updState.timer_handle) { clearInterval(updState.timer_handle); updState.timer_handle = null; }
el('#ub-phase').textContent = d.state === 'failed' ? `Failed (rc=${d.returncode})` : 'Done ✓ — re-check from the banner.';
setTimeout(pollUpdates, 2000);
}
async function init() {
setupCopyButtons();
el('#open-download').addEventListener('click', openDownloadForm);
el('#dl-cancel').addEventListener('click', closeDownloadPanel);
el('#dl-start').addEventListener('click', startDownload);
el('#dl-repo').addEventListener('keydown', (e) => { if (e.key === 'Enter') startDownload(); });
el('#ub-details').addEventListener('click', () => {
const list = el('#ub-list');
list.classList.toggle('hidden');
list.open = !list.open;
});
el('#ub-apply').addEventListener('click', applyUpdate);
setupCatalogDialog();
setupAdvancedDialog();
await loadModels();
await pollStatus();
await renderServices();
pollUpdates();
setInterval(pollStatus, 5000);
setInterval(pollUpdates, 300000); // every 5 min
}
init();