v0.26.0:0 - disk-driven model menu (scan sparks; recipes; needs-setup)
The dashboard menu is now the set of models actually downloaded on the Sparks, not a hard-coded catalog. models.yaml + overrides are reframed as launch recipes matched to an on-disk model by repo; an on-disk model with no recipe is flagged needs_setup and its launch settings are inferred from its config.json for a one-time operator confirmation (discovery.py). - delete now removes weights AND the menu card (delete_from_disk sweeps all hosts; the delete endpoint resolves keys via the live menu) - new GET /api/models/suggest; /api/models returns the menu + a recipes list (download autocomplete); GET /api/models/disk-status removed - dropped the two legacy Qwen recipes (235B FP8, 2.5 72B) - tests: +test_discovery.py (cache parsing, infer_recipe, build_menu merge)
This commit is contained in:
+158
-121
@@ -19,8 +19,8 @@ const state = {
|
||||
configured: true,
|
||||
timer_handle: null,
|
||||
deep_health: {},
|
||||
disk_status: {}, // keyed by model key: { on_disk, total_bytes, per_host }
|
||||
disk_status_loaded: false,
|
||||
models_loaded: false, // true once the first disk scan (/api/models) returns
|
||||
recipes: [], // known launch recipes (for the download autocomplete)
|
||||
lock: { held: false }, // GPU swap reservation (coordination layer)
|
||||
schedules: [], // schedules external automation has registered
|
||||
};
|
||||
@@ -65,67 +65,69 @@ function renderCards() {
|
||||
const lockTip = locked
|
||||
? `Reserved by ${state.lock.holder || 'automation'}${state.lock.expires_at ? ' until ' + fmtClock(state.lock.expires_at) : ''}`
|
||||
: '';
|
||||
for (const key of Object.keys(state.models)) {
|
||||
const keys = Object.keys(state.models);
|
||||
if (keys.length === 0) {
|
||||
// The menu is the disk: nothing downloaded (or the scan hasn't returned yet).
|
||||
root.innerHTML = state.models_loaded
|
||||
? `<div class="empty-menu muted">No models downloaded on the Sparks yet. Use <strong>+ Download a new model</strong> above to fetch one — it'll appear here when it's done.</div>`
|
||||
: `<div class="empty-menu muted">Scanning the Sparks for downloaded models…</div>`;
|
||||
return;
|
||||
}
|
||||
for (const key of keys) {
|
||||
const m = state.models[key];
|
||||
const isActive = key === state.current_model_key;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card' + (isActive ? ' active' : '');
|
||||
card.className = 'card' + (isActive ? ' active' : '') + (m.needs_setup ? ' needs-setup' : '');
|
||||
const desc = m.description
|
||||
? `<div class="desc">${escapeHtml(m.description)}</div>`
|
||||
: '';
|
||||
const customPill = m.custom ? `<span class="tag custom-pill">custom</span>` : '';
|
||||
const localPill = m.local_path ? `<span class="tag local-pill" title="Served from a directory on the Spark, not Hugging Face">local</span>` : '';
|
||||
// Disk-presence pill + trash button. Until /api/models/disk-status comes back,
|
||||
// we don't know — render a neutral placeholder.
|
||||
const disk = state.disk_status[key];
|
||||
let diskPill = '';
|
||||
if (state.disk_status_loaded) {
|
||||
if (disk && disk.on_disk) {
|
||||
const gb = (disk.total_bytes / 1e9);
|
||||
diskPill = `<span class="tag on-disk" title="Weights present on disk">on disk · ${gb.toFixed(1)} GB</span>`;
|
||||
} else {
|
||||
diskPill = `<span class="tag not-on-disk" title="Weights not downloaded">not downloaded</span>`;
|
||||
}
|
||||
}
|
||||
// Trash button — hidden if not on disk; disabled (with tooltip) if currently loaded.
|
||||
// Every card on the menu is on disk by definition — show its real size.
|
||||
const gb = (m.total_bytes || 0) / 1e9;
|
||||
const diskPill = gb > 0
|
||||
? `<span class="tag on-disk" title="Weights present on the Spark(s)">on disk · ${gb.toFixed(1)} GB</span>`
|
||||
: '';
|
||||
const setupPill = m.needs_setup
|
||||
? `<span class="tag setup-pill" title="On disk, but Spark Control hasn't been told how to launch it">needs setup</span>`
|
||||
: '';
|
||||
// Trash = remove weights from disk AND from the menu. Disabled if active / mid-swap.
|
||||
// Never offered for local models: their directory is hand-placed training output,
|
||||
// not a re-downloadable HF cache (the server refuses the delete too).
|
||||
let trashBtn = '';
|
||||
if (state.disk_status_loaded && disk && disk.on_disk && !m.local_path) {
|
||||
if (!m.local_path) {
|
||||
const disabled = isActive || isSwapping;
|
||||
const tip = isActive
|
||||
? 'Currently loaded — switch to another model first'
|
||||
: isSwapping
|
||||
? 'A swap is in progress'
|
||||
: 'Delete weights from disk';
|
||||
trashBtn = `<button class="icon-btn danger" data-disk-del-key="${key}" title="${escapeHtml(tip)}" aria-label="Delete from disk" ${disabled ? 'disabled' : ''}>${trashIcon}</button>`;
|
||||
: 'Remove weights from disk & menu';
|
||||
trashBtn = `<button class="icon-btn danger" data-disk-del-key="${key}" title="${escapeHtml(tip)}" aria-label="Remove from disk and menu" ${disabled ? 'disabled' : ''}>${trashIcon}</button>`;
|
||||
}
|
||||
// Primary card action: "Switch to this" (green) when on disk; "Download" (blue) when not.
|
||||
// Before disk-status loads we render the swap button as a sensible default.
|
||||
const isOnDisk = !state.disk_status_loaded || (disk && disk.on_disk);
|
||||
const dlInFlight = !!(typeof dlState !== 'undefined' && dlState && dlState.job_id);
|
||||
// Primary action: "Current" / "Switch to this", or "Set up & switch" for a
|
||||
// model on disk that has no launch recipe yet.
|
||||
const swapBlocked = isSwapping || locked;
|
||||
const lockTipAttr = locked ? ` title="${escapeHtml(lockTip)}"` : '';
|
||||
let primaryBtn = '';
|
||||
if (isActive) {
|
||||
primaryBtn = `<button class="btn" disabled>Current</button>`;
|
||||
} else if (isOnDisk) {
|
||||
const swapBlocked = isSwapping || locked;
|
||||
const tip = locked ? ` title="${escapeHtml(lockTip)}"` : '';
|
||||
primaryBtn = `<button class="btn primary" data-swap-key="${key}"${tip} ${swapBlocked ? 'disabled' : ''}>Switch to this</button>`;
|
||||
} else if (m.local_path) {
|
||||
// A local model can't be "downloaded" — its directory has to exist on the Spark.
|
||||
primaryBtn = `<button class="btn" disabled title="Directory not found on the Spark — create it there, then refresh">Not found on Spark</button>`;
|
||||
} else if (m.needs_setup) {
|
||||
primaryBtn = `<button class="btn primary" data-setup-key="${key}"${lockTipAttr} ${swapBlocked ? 'disabled' : ''}>Set up & switch</button>`;
|
||||
} else {
|
||||
const tip = dlInFlight ? 'A download is already in progress' : 'Download weights to the Spark(s)';
|
||||
primaryBtn = `<button class="btn info" data-download-key="${key}" title="${escapeHtml(tip)}" ${dlInFlight ? 'disabled' : ''}>Download</button>`;
|
||||
primaryBtn = `<button class="btn primary" data-swap-key="${key}"${lockTipAttr} ${swapBlocked ? 'disabled' : ''}>Switch to this</button>`;
|
||||
}
|
||||
// The Test/Advanced controls need a saved recipe; hide them until setup is done.
|
||||
const recipeActions = m.needs_setup ? '' : `
|
||||
<button class="btn test-btn" data-test-key="${key}" title="Pre-flight check the launch command without starting the engine">Test</button>
|
||||
<button class="btn adv-btn" data-adv-key="${key}" title="Advanced settings">Advanced</button>`;
|
||||
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>
|
||||
${diskPill}
|
||||
${setupPill}
|
||||
${customPill}
|
||||
${localPill}
|
||||
${diskPill}
|
||||
${(m.capabilities || []).map(c => `<span class="tag cap">${escapeHtml(c)}</span>`).join('')}
|
||||
</div>
|
||||
${desc}
|
||||
@@ -136,9 +138,7 @@ function renderCards() {
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="card-actions">
|
||||
${primaryBtn}
|
||||
<button class="btn test-btn" data-test-key="${key}" title="Pre-flight check the launch command without starting the engine">Test</button>
|
||||
<button class="btn adv-btn" data-adv-key="${key}" title="Advanced settings">Advanced</button>
|
||||
${primaryBtn}${recipeActions}
|
||||
${trashBtn}
|
||||
</div>
|
||||
<div class="test-result hidden" data-test-result-for="${key}"></div>
|
||||
@@ -148,8 +148,8 @@ function renderCards() {
|
||||
for (const btn of root.querySelectorAll('[data-swap-key]')) {
|
||||
btn.addEventListener('click', () => triggerSwap(btn.dataset.swapKey));
|
||||
}
|
||||
for (const btn of root.querySelectorAll('[data-download-key]')) {
|
||||
btn.addEventListener('click', () => triggerDownloadForKey(btn.dataset.downloadKey));
|
||||
for (const btn of root.querySelectorAll('[data-setup-key]')) {
|
||||
btn.addEventListener('click', () => openSetupForKey(btn.dataset.setupKey));
|
||||
}
|
||||
for (const btn of root.querySelectorAll('[data-adv-key]')) {
|
||||
btn.addEventListener('click', () => openAdvanced(btn.dataset.advKey));
|
||||
@@ -1170,24 +1170,44 @@ async function pollStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
let menuLoadInFlight = false;
|
||||
|
||||
async function loadModels() {
|
||||
const data = await fetchJSON('/api/models');
|
||||
state.defaults = data.defaults || {};
|
||||
state.models = data.models || {};
|
||||
// The menu is whatever's downloaded on the Sparks — /api/models does the scan
|
||||
// (SSH), so this is the slower model call. Best-effort: a transient failure
|
||||
// leaves the previous menu in place rather than blanking the dashboard.
|
||||
// Guard against overlap: init() fires this un-awaited and pollStatus()'s
|
||||
// empty-menu fallback may call it again before the scan returns.
|
||||
if (menuLoadInFlight) return;
|
||||
menuLoadInFlight = true;
|
||||
try {
|
||||
const data = await fetchJSON('/api/models');
|
||||
state.defaults = data.defaults || {};
|
||||
state.models = data.models || {};
|
||||
state.recipes = data.recipes || [];
|
||||
state.models_loaded = true;
|
||||
populateDownloadSuggestions();
|
||||
renderCards();
|
||||
} catch (e) {
|
||||
console.warn('model menu load failed:', e.message);
|
||||
} finally {
|
||||
menuLoadInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiskStatus() {
|
||||
// Probes each catalog model's HF cache over SSH; takes a beat. Best-effort.
|
||||
try {
|
||||
const r = await fetchJSON('/api/models/disk-status');
|
||||
if (r && r.models) {
|
||||
state.disk_status = r.models;
|
||||
state.disk_status_loaded = true;
|
||||
renderCards();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent — pills just won't render. Don't block dashboard.
|
||||
console.warn('disk-status probe failed:', e.message);
|
||||
// Populate the download box's autocomplete with known recipes not currently on
|
||||
// disk — so common/bundled models stay discoverable without phantom menu cards.
|
||||
function populateDownloadSuggestions() {
|
||||
const dl = el('#dl-suggestions');
|
||||
if (!dl) return;
|
||||
const onDiskRepos = new Set(Object.values(state.models).map(m => m.repo).filter(Boolean));
|
||||
dl.innerHTML = '';
|
||||
for (const r of state.recipes || []) {
|
||||
if (onDiskRepos.has(r.repo)) continue;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = r.repo;
|
||||
opt.label = `${r.display_name} (${r.mode})`;
|
||||
dl.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1201,14 +1221,12 @@ function fmtBytesShort(n) {
|
||||
|
||||
function openDiskDeleteDialog(key) {
|
||||
const m = state.models[key];
|
||||
const disk = state.disk_status[key];
|
||||
if (!m || !disk || !disk.on_disk) return;
|
||||
if (!m || !m.on_disk) return;
|
||||
const dlg = el('#disk-delete-dialog');
|
||||
el('#dd-summary').innerHTML = `Free <strong>${fmtBytesShort(disk.total_bytes)}</strong> by removing <strong>${escapeHtml(m.display_name)}</strong> (<code>${escapeHtml(m.repo)}</code>) from disk.`;
|
||||
el('#dd-summary').innerHTML = `Free <strong>${fmtBytesShort(m.total_bytes)}</strong> by removing <strong>${escapeHtml(m.display_name)}</strong> (<code>${escapeHtml(m.repo)}</code>) from the Sparks. This also takes it off the menu.`;
|
||||
const hostsEl = el('#dd-hosts');
|
||||
hostsEl.innerHTML = '';
|
||||
for (const h of (disk.per_host || [])) {
|
||||
if (!h.on_disk) continue;
|
||||
for (const h of (m.per_host || [])) {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<code>${escapeHtml(h.host)}</code> — ${fmtBytesShort(h.size_bytes)}`;
|
||||
hostsEl.appendChild(li);
|
||||
@@ -1227,20 +1245,19 @@ function openDiskDeleteDialog(key) {
|
||||
try {
|
||||
const r = await fetchJSON(`/api/models/${encodeURIComponent(key)}/disk`, { method: 'DELETE' });
|
||||
dlg.close();
|
||||
// Optimistically clear local disk state for this key, then refresh.
|
||||
delete state.disk_status[key];
|
||||
// Optimistically drop the card, then re-scan the menu (it's gone from disk).
|
||||
delete state.models[key];
|
||||
renderCards();
|
||||
// Eagerly re-probe so size is accurate (and shows "not downloaded" pill).
|
||||
loadDiskStatus();
|
||||
await loadModels();
|
||||
const freed = r && typeof r.bytes_freed === 'number' ? fmtBytesShort(r.bytes_freed) : '';
|
||||
console.log(`Deleted ${m.display_name} from disk${freed ? ` — freed ${freed}` : ''}.`);
|
||||
console.log(`Removed ${m.display_name} from disk${freed ? ` — freed ${freed}` : ''}.`);
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message || 'Delete failed';
|
||||
errEl.classList.remove('hidden');
|
||||
} finally {
|
||||
confirm.disabled = false;
|
||||
cancel.disabled = false;
|
||||
confirm.textContent = 'Delete from disk';
|
||||
confirm.textContent = 'Remove from disk & menu';
|
||||
}
|
||||
};
|
||||
cancel.onclick = onCancel;
|
||||
@@ -1341,38 +1358,6 @@ async function releaseLock() {
|
||||
pollCoordination();
|
||||
}
|
||||
|
||||
async function triggerDownloadForKey(modelKey) {
|
||||
const m = state.models[modelKey];
|
||||
if (!m) return;
|
||||
if (dlState.job_id) {
|
||||
alert('A download is already in progress; wait for it to finish.');
|
||||
return;
|
||||
}
|
||||
// Pick the download target from the model's mode:
|
||||
// solo -> spark1 only
|
||||
// cluster -> both Sparks (fetch on Spark 1, rsync to Spark 2 in parallel)
|
||||
const dlMode = m.mode === 'cluster' ? 'cluster' : 'spark1';
|
||||
const sizeNote = m.size_gb ? ` (~${m.size_gb} GB)` : '';
|
||||
const target = m.mode === 'cluster' ? 'both Sparks' : 'Spark 1';
|
||||
if (!confirm(`Download "${m.display_name}"${sizeNote} to ${target}? Large models can take a while; you can watch progress in the download panel.`)) {
|
||||
return;
|
||||
}
|
||||
dlState.last_repo = m.repo;
|
||||
dlState.last_mode = dlMode;
|
||||
try {
|
||||
const r = await fetchJSON('/api/download', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ repo: m.repo, mode: dlMode }),
|
||||
});
|
||||
// Open the download panel + attach to progress stream
|
||||
openDownloadForm();
|
||||
attachToDownload(r.job_id);
|
||||
} catch (e) {
|
||||
alert('Failed to start download: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function attachToSwap(jobId, needsBackfill) {
|
||||
if (state.swap_eventsource) {
|
||||
state.swap_eventsource.close();
|
||||
@@ -1603,12 +1588,14 @@ function handleDownloadDone(d) {
|
||||
el('#dl-title').textContent = 'Done';
|
||||
el('#dl-phase').textContent = 'Done ✓';
|
||||
el('#dl-progress-fill').style.width = '100%';
|
||||
// Offer to add to catalog
|
||||
// The new model now appears on the menu (the menu is the disk). If it matched
|
||||
// a known recipe it's ready to switch to; if not, offer to set it up.
|
||||
const repo = dlState.last_repo;
|
||||
const mode = dlState.last_mode;
|
||||
if (repo) {
|
||||
setTimeout(() => openCatalogDialog(repo, mode), 600);
|
||||
}
|
||||
loadModels().then(() => {
|
||||
if (!repo) return;
|
||||
const entry = Object.values(state.models).find(m => m.repo === repo);
|
||||
if (entry && entry.needs_setup) setTimeout(() => openSetupDialog(repo, { thenSwap: false }), 600);
|
||||
});
|
||||
}
|
||||
dlState.job_id = null;
|
||||
}
|
||||
@@ -1721,21 +1708,67 @@ function openAdvanced(key) {
|
||||
dlg.showModal();
|
||||
}
|
||||
|
||||
function openCatalogDialog(repo, mode) {
|
||||
// Context carried from openSetupDialog -> the submit handler: the inferred
|
||||
// launch flags (parsers/MoE backend) and whether to swap right after saving.
|
||||
let setupCtx = { key: '', repo: '', vllm_args: [], thenSwap: false };
|
||||
|
||||
// "Set up & switch" on a needs-setup card.
|
||||
async function openSetupForKey(key) {
|
||||
const m = state.models[key];
|
||||
if (!m) return;
|
||||
if (state.lock && state.lock.held) {
|
||||
const until = state.lock.expires_at ? ' until ' + fmtClock(state.lock.expires_at) : '';
|
||||
alert(`The GPU swap path is reserved by ${state.lock.holder || 'automation'}${until}. Use "Release" on the reservation banner to override.`);
|
||||
return;
|
||||
}
|
||||
await openSetupDialog(m.repo, { thenSwap: true });
|
||||
}
|
||||
|
||||
// Open the "set up this model" dialog, prefilled from inference (config.json +
|
||||
// size). The operator confirms once; on save the recipe persists and (if
|
||||
// thenSwap) we switch to it.
|
||||
async function openSetupDialog(repo, opts = {}) {
|
||||
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();
|
||||
let sug = null;
|
||||
try {
|
||||
sug = await fetchJSON(`/api/models/suggest?repo=${encodeURIComponent(repo)}`);
|
||||
} catch (e) {
|
||||
console.warn('recipe suggestion failed:', e.message);
|
||||
}
|
||||
const fallbackKey = repo.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
setupCtx = {
|
||||
key: (sug && sug.key) || fallbackKey,
|
||||
repo,
|
||||
vllm_args: (sug && sug.vllm_args) || [],
|
||||
thenSwap: !!opts.thenSwap,
|
||||
};
|
||||
el('#cd-key').value = setupCtx.key;
|
||||
el('#cd-name').value = (sug && sug.display_name) || repo.split('/').pop();
|
||||
el('#cd-repo').value = repo;
|
||||
el('#cd-size').value = '';
|
||||
el('#cd-mode').value = mode || 'solo';
|
||||
el('#cd-mode').value = (sug && sug.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;
|
||||
const knobs = (sug && sug.knobs) || {};
|
||||
el('#cd-mml').value = knobs.max_model_len || 32768;
|
||||
el('#cd-gmu').value = knobs.gpu_memory_utilization || 0.85;
|
||||
el('#cd-gmu-out').value = parseFloat(el('#cd-gmu').value).toFixed(2);
|
||||
el('#cd-fst').checked = knobs.fastsafetensors !== false;
|
||||
el('#cd-pcache').checked = knobs.prefix_caching !== false;
|
||||
el('#cd-fp8').checked = (knobs.kv_cache_dtype || 'fp8') === 'fp8';
|
||||
|
||||
const det = el('#cd-detected');
|
||||
if (det) {
|
||||
if (sug) {
|
||||
const caps = (sug.capabilities || []).join(', ');
|
||||
const flags = setupCtx.vllm_args.length ? `: <code>${escapeHtml(setupCtx.vllm_args.join(' '))}</code>` : '';
|
||||
det.innerHTML = `Detected <strong>${escapeHtml(sug.family || 'Generic')}</strong>${caps ? ` · ${escapeHtml(caps)}` : ''}. Launch flags set automatically${flags}.`;
|
||||
} else {
|
||||
det.textContent = "Couldn't auto-detect this model's settings — pick mode and knobs manually.";
|
||||
}
|
||||
det.classList.remove('hidden');
|
||||
}
|
||||
const submit = el('#cd-submit');
|
||||
if (submit) submit.textContent = setupCtx.thenSwap ? 'Save & switch' : 'Save settings';
|
||||
dlg.showModal();
|
||||
}
|
||||
|
||||
@@ -1745,13 +1778,15 @@ function setupCatalogDialog() {
|
||||
el('#catalog-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
key: el('#cd-key').value.trim(),
|
||||
key: el('#cd-key').value.trim() || setupCtx.key,
|
||||
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: [],
|
||||
// The inferred family flags (parsers / MoE backend); knob-controlled flags
|
||||
// are layered on by the server from `knobs`, so no duplication.
|
||||
vllm_args: setupCtx.vllm_args || [],
|
||||
knobs: {
|
||||
max_model_len: parseInt(el('#cd-mml').value, 10) || 32768,
|
||||
gpu_memory_utilization: parseFloat(el('#cd-gmu').value),
|
||||
@@ -1769,8 +1804,9 @@ function setupCatalogDialog() {
|
||||
el('#catalog-dialog').close();
|
||||
closeDownloadPanel();
|
||||
await loadModels();
|
||||
if (setupCtx.thenSwap) triggerSwap(body.key);
|
||||
pollStatus();
|
||||
} catch (e) { alert('Add to catalog failed: ' + e.message); }
|
||||
} catch (e) { alert('Saving the model setup failed: ' + e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2212,21 +2248,22 @@ async function init() {
|
||||
} catch {}
|
||||
setupDashboardTabs();
|
||||
setupEndpointCollapse();
|
||||
await loadModels();
|
||||
// Fire the (SSH-backed) menu scan without awaiting — it self-renders a
|
||||
// "Scanning…" state and fills in when it returns, so a slow/unreachable
|
||||
// cluster never blocks first paint. pollStatus() below paints the rest.
|
||||
loadModels();
|
||||
await pollStatus();
|
||||
await renderServices();
|
||||
pollCoordination();
|
||||
pollHardware();
|
||||
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(pollCoordination, 5000); // swap lock + schedule registry
|
||||
setInterval(pollHardware, 8000); // every 8s
|
||||
setInterval(pollUpdates, 300000); // every 5 min
|
||||
setInterval(loadDiskStatus, 60000); // every 60s — disk state changes rarely
|
||||
setInterval(loadModels, 60000); // every 60s — re-scan the Sparks for added/removed models
|
||||
setInterval(renderSpeechModels, 120000); // every 2 min — patches change rarely
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user