diff --git a/image/app/static/app.js b/image/app/static/app.js index 6fa0af5..4d5c25d 100644 --- a/image/app/static/app.js +++ b/image/app/static/app.js @@ -1,7 +1,4 @@ // spark-control front-end -// - polls /api/status every 5s for current model + health -// - lists models from /api/models as cards -// - POST /api/swap to start a swap, then opens SSE /api/swap/{id}/stream const state = { models: {}, @@ -9,116 +6,222 @@ const state = { 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, // 0–1 configured: true, + timer_handle: null, }; -function el(sel) { return document.querySelector(sel); } -function $(sel) { return document.querySelectorAll(sel); } +const el = (sel) => document.querySelector(sel); +const $$ = (sel) => document.querySelectorAll(sel); async function fetchJSON(url, opts) { const r = await fetch(url, opts); if (!r.ok) { - const text = await r.text().catch(() => ""); + 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 keys = Object.keys(state.models); - for (const key of keys) { + 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 isSwapping = !!state.swap_job_id; - const card = document.createElement("div"); - card.className = "card" + (isActive ? " active" : ""); + const card = document.createElement('div'); + card.className = 'card' + (isActive ? ' active' : ''); card.innerHTML = `
${m.display_name}
${m.mode} ${m.size_gb} GB - ${(m.capabilities || []).map(c => `${c}`).join("")} + ${(m.capabilities || []).map(c => `${c}`).join('')}
-
${m.repo}
+
${m.repo}
- `; root.appendChild(card); } - for (const btn of $(".card .btn")) { - btn.addEventListener("click", () => triggerSwap(btn.dataset.key)); + for (const btn of $$('.card .btn')) { + btn.addEventListener('click', () => triggerSwap(btn.dataset.key)); } } function renderCurrent(status) { - const c = el("#current"); - if (!status.configured) { - c.innerHTML = `not configured`; - return; - } - if (status.current_swap_job) { - c.innerHTML = `swap in progress`; - return; - } + const c = el('#current'); + if (!status.configured) { c.innerHTML = `not configured`; return; } + if (status.current_swap_job) { c.innerHTML = `swap in progress`; return; } const v = status.vllm || {}; - if (!v.ok) { - c.innerHTML = `vLLM unreachable`; - return; - } - const key = status.current_model_key; - const m = key ? state.models[key] : null; - const label = m ? m.display_name : (v.current_model || "(unknown)"); + if (!v.ok) { c.innerHTML = `vLLM unreachable`; 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 = `${label}`; } function renderHealth(status) { - function setDot(id, ok) { + 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(status[id.replace("#h-", "")] || {}, null, 2); + 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); - setDot("#h-parakeet", status.parakeet && status.parakeet.ok); - setDot("#h-magpie", status.magpie && status.magpie.ok); - el("#updated").textContent = `updated ${new Date().toLocaleTimeString()}`; + 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); + 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"); + const status = await fetchJSON('/api/status'); state.current_model_key = status.current_model_key; state.configured = status.configured; renderBanner(status); renderCurrent(status); renderHealth(status); if (status.current_swap_job && status.current_swap_job !== state.swap_job_id) { - attachToSwap(status.current_swap_job); + attachToSwap(status.current_swap_job, /*needsBackfill=*/true); } else if (!status.current_swap_job && state.swap_job_id && !state.swap_eventsource) { - // someone else's swap finished; clear local - state.swap_job_id = null; - el("#swap-panel").classList.add("hidden"); + // Foreign swap ended + detachSwap(); } renderCards(); } catch (e) { - console.error("status poll failed", e); + console.error('status poll failed', e); } } async function loadModels() { - const data = await fetchJSON("/api/models"); + const data = await fetchJSON('/api/models'); state.defaults = data.defaults || {}; state.models = data.models || {}; } @@ -126,63 +229,101 @@ async function loadModels() { async function triggerSwap(modelKey) { if (state.swap_job_id) return; try { - const r = await fetchJSON("/api/swap", { - method: "POST", - headers: { "content-type": "application/json" }, + const r = await fetchJSON('/api/swap', { + method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model_key: modelKey }), }); - attachToSwap(r.job_id); + attachToSwap(r.job_id, /*needsBackfill=*/false); } catch (e) { - alert("Failed to start swap: " + e.message); + alert('Failed to start swap: ' + e.message); } } -function attachToSwap(jobId) { +async function attachToSwap(jobId, needsBackfill) { if (state.swap_eventsource) { state.swap_eventsource.close(); state.swap_eventsource = null; } state.swap_job_id = jobId; - el("#swap-panel").classList.remove("hidden"); - el("#swap-log").textContent = ""; - el("#swap-state").textContent = "starting"; + 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.state) el("#swap-state").textContent = d.state; - if (d.line) appendLog(d.line); + 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", (ev) => { - try { - const d = JSON.parse(ev.data); - el("#swap-state").textContent = d.state + ` (rc=${d.returncode})`; - } catch {} - es.close(); - state.swap_eventsource = null; - state.swap_job_id = null; - setTimeout(() => { - el("#swap-panel").classList.add("hidden"); - pollStatus(); - }, 4000); - pollStatus(); + es.addEventListener('done', async (ev) => { + let d = {}; + try { d = JSON.parse(ev.data); } catch {} + handleSwapDone(d); }); es.onerror = () => { - // SSE drops happen on tab background; reconnect on next poll + // 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"; + const log = el('#swap-log'); + log.textContent += line + '\n'; log.scrollTop = log.scrollHeight; } diff --git a/image/app/static/index.html b/image/app/static/index.html index 91360ed..7710ee4 100644 --- a/image/app/static/index.html +++ b/image/app/static/index.html @@ -27,11 +27,21 @@
diff --git a/image/app/static/style.css b/image/app/static/style.css index 04f8ca5..cc78f82 100644 --- a/image/app/static/style.css +++ b/image/app/static/style.css @@ -13,7 +13,6 @@ } * { box-sizing: border-box; } - html, body { margin: 0; padding: 0; } body { @@ -64,38 +63,91 @@ main { } .banner em { font-style: normal; background: rgba(245, 158, 11, 0.15); padding: 2px 6px; border-radius: 4px; } +/* ===== Swap panel ===== */ + .swap-panel { background: var(--surface); border: 1px solid var(--info); border-radius: var(--radius); - padding: 14px 16px; + padding: 16px 18px; margin-bottom: 20px; } -.swap-header { display: flex; align-items: center; gap: 10px; } +.swap-header { display: flex; align-items: center; gap: 12px; } .swap-header #swap-title { font-weight: 600; color: var(--info); } +.timer { + font: 14px/1 ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + background: var(--surface-2); + border: 1px solid var(--border); + padding: 5px 10px; + border-radius: 6px; + color: var(--text); + letter-spacing: 0.04em; +} .spinner { width: 14px; height: 14px; border: 2px solid var(--info); border-right-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; + flex-shrink: 0; } @keyframes spin { to { transform: rotate(360deg); } } +.phase-row { + display: flex; + align-items: baseline; + gap: 10px; + margin-top: 14px; +} +.phase { + font-size: 16px; + font-weight: 500; + color: var(--text); +} +.phase-detail { font-size: 13px; } + +.phase-track { + margin-top: 10px; + height: 6px; + background: var(--surface-2); + border-radius: 3px; + overflow: hidden; +} +.phase-fill { + height: 100%; + width: 2%; + background: linear-gradient(90deg, var(--info), var(--accent)); + border-radius: 3px; + transition: width 0.5s ease-out; +} + +#swap-log-details { + margin-top: 14px; +} +#swap-log-details summary { + cursor: pointer; + user-select: none; + padding: 2px 0; +} +#swap-log-details summary:hover { color: var(--text); } +#swap-log-details[open] summary { margin-bottom: 8px; } + .log { background: #08080b; border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; - margin: 10px 0 0; + margin: 0; font: 12px/1.55 ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; color: #c7c7d1; - max-height: 280px; + max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-word; } +/* ===== Cards ===== */ + .cards { display: grid; gap: 14px; @@ -118,6 +170,7 @@ main { } .card .name { font-weight: 600; font-size: 15px; } .card .meta { display: flex; flex-wrap: wrap; gap: 6px; font-size: 12px; color: var(--muted); } +.card .repo { word-break: break-all; } .tag { background: var(--surface-2); border: 1px solid var(--border);