v0.8.1:2 - card button flips to blue "Download" when weights are absent

When a model's weights aren't on disk, the green "Switch to this"
button on the card is replaced by a blue "Download" button that
calls /api/download directly with the model's repo and the right
mode (solo -> spark1, cluster -> both). One-click re-install of a
previously-deleted model, no more pasting the repo into the manual
download form.

Also adds a confirmation dialog showing the model name, size, and
target Spark(s) before kicking off the download — and disables the
button when another download is already in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-05-14 09:30:51 -05:00
parent 513c78bfa5
commit befedf0852
3 changed files with 55 additions and 6 deletions
+49 -3
View File
@@ -82,6 +82,19 @@ function renderCards() {
: 'Delete weights from disk'; : '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>`; trashBtn = `<button class="icon-btn danger" data-disk-del-key="${key}" title="${escapeHtml(tip)}" aria-label="Delete from disk" ${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);
let primaryBtn = '';
if (isActive) {
primaryBtn = `<button class="btn" disabled>Current</button>`;
} else if (isOnDisk) {
primaryBtn = `<button class="btn primary" data-swap-key="${key}" ${isSwapping ? 'disabled' : ''}>Switch to this</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>`;
}
card.innerHTML = ` card.innerHTML = `
<div class="name">${escapeHtml(m.display_name)}</div> <div class="name">${escapeHtml(m.display_name)}</div>
<div class="meta"> <div class="meta">
@@ -97,9 +110,7 @@ function renderCards() {
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<div class="card-actions"> <div class="card-actions">
<button class="btn ${isActive ? '' : 'primary'}" data-swap-key="${key}" ${isActive || isSwapping ? 'disabled' : ''}> ${primaryBtn}
${isActive ? 'Current' : 'Switch to this'}
</button>
<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 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> <button class="btn adv-btn" data-adv-key="${key}" title="Advanced settings">Advanced</button>
${trashBtn} ${trashBtn}
@@ -111,6 +122,9 @@ function renderCards() {
for (const btn of root.querySelectorAll('[data-swap-key]')) { for (const btn of root.querySelectorAll('[data-swap-key]')) {
btn.addEventListener('click', () => triggerSwap(btn.dataset.swapKey)); 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-adv-key]')) { for (const btn of root.querySelectorAll('[data-adv-key]')) {
btn.addEventListener('click', () => openAdvanced(btn.dataset.advKey)); btn.addEventListener('click', () => openAdvanced(btn.dataset.advKey));
} }
@@ -857,6 +871,38 @@ async function triggerSwap(modelKey) {
} }
} }
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) { async function attachToSwap(jobId, needsBackfill) {
if (state.swap_eventsource) { if (state.swap_eventsource) {
state.swap_eventsource.close(); state.swap_eventsource.close();
+4 -1
View File
@@ -711,9 +711,12 @@ main {
.btn:disabled { opacity: 0.45; cursor: not-allowed; } .btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn.danger { color: var(--error); border-color: rgba(239, 68, 68, 0.3); } .btn.danger { color: var(--error); border-color: rgba(239, 68, 68, 0.3); }
.btn.danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.08); border-color: var(--error); } .btn.danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.08); border-color: var(--error); }
.btn.info { background: var(--info); color: #0a1e3d; border-color: var(--info); }
.btn.info:hover:not(:disabled) { background: #82baff; border-color: #82baff; }
.card.active .btn { background: rgba(74, 222, 128, 0.12); color: var(--accent); border-color: rgba(74, 222, 128, 0.4); } .card.active .btn { background: rgba(74, 222, 128, 0.12); color: var(--accent); border-color: rgba(74, 222, 128, 0.4); }
.card-actions { display: flex; gap: 6px; } .card-actions { display: flex; gap: 6px; }
.card-actions .btn.primary { flex: 1; } .card-actions .btn.primary,
.card-actions .btn.info { flex: 1; }
.card .adv-btn, .card .adv-btn,
.card .test-btn { padding: 8px 12px; font-size: 12px; } .card .test-btn { padding: 8px 12px; font-size: 12px; }
.card .custom-pill { color: var(--info); border-color: rgba(96, 165, 250, 0.4); } .card .custom-pill { color: var(--info); border-color: rgba(96, 165, 250, 0.4); }
+2 -2
View File
@@ -1,10 +1,10 @@
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
export const v0_1_0 = VersionInfo.of({ export const v0_1_0 = VersionInfo.of({
version: '0.8.1:1', version: '0.8.1:2',
releaseNotes: { releaseNotes: {
en_US: en_US:
'v0.8.1:1fix: the disk-status probe shipped in 0.8.1:0 was wrapping $HOME in single quotes via shlex.quote, which prevented shell variable expansion. Result: every model reported as "not downloaded" even when weights were on disk, so no trash icons appeared. Rewritten to embed $HOME in double-quoted shell context and validate the cache dirname against a whitelist. The trash icons now show up correctly. v0.8.1:0 features: per-card disk-presence pills (on disk · GB / not downloaded), trash icon to rm -rf the HF cache directory via SSH with a confirmation dialog. Safety rails unchanged: refuses to delete the currently-loaded model or during an in-flight swap/download; catalog entry persists for re-download.', 'v0.8.1:2the primary card button now adapts to whether the model is on disk. If weights are present: green "Switch to this" (unchanged). If weights are NOT on disk: blue "Download" instead, which calls /api/download directly with the model\'s repo and the right mode (solo→Spark 1, cluster→both Sparks) — no more pasting the repo into the manual download form to re-fetch a deleted model. Re-installing a previously-deleted model is now one click + a confirmation. Builds on the disk-status pills + trash icons from 0.8.1.',
}, },
migrations: { migrations: {
up: async ({ effects }) => {}, up: async ({ effects }) => {},