// 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: {}, defaults: {}, current_model_key: null, swap_job_id: null, swap_eventsource: null, configured: true, }; function el(sel) { return document.querySelector(sel); } function $(sel) { return document.querySelectorAll(sel); } 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(); } function renderCards() { const root = el("#cards"); root.innerHTML = ""; const keys = Object.keys(state.models); for (const key of keys) { 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" : ""); card.innerHTML = `
${m.display_name}
${m.mode} ${m.size_gb} GB ${(m.capabilities || []).map(c => `${c}`).join("")}
${m.repo}
`; root.appendChild(card); } 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 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)"); c.innerHTML = `${label}`; } function renderHealth(status) { function setDot(id, ok) { 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); } 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()}`; } function renderBanner(status) { el("#setup-banner").classList.toggle("hidden", !!status.configured); } 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); renderHealth(status); if (status.current_swap_job && status.current_swap_job !== state.swap_job_id) { attachToSwap(status.current_swap_job); } 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"); } 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); } catch (e) { alert("Failed to start swap: " + e.message); } } function attachToSwap(jobId) { 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"; 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); } 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.onerror = () => { // SSE drops happen on tab background; reconnect on next poll es.close(); state.swap_eventsource = null; }; renderCards(); } function appendLog(line) { const log = el("#swap-log"); log.textContent += line + "\n"; log.scrollTop = log.scrollHeight; } async function init() { await loadModels(); await pollStatus(); setInterval(pollStatus, 5000); } init();