v0.27.0:0 - in-app settings gear + swap-lock route fix

Move the ~20 optional cluster knobs out of the StartOS "Configure Sparks"
action (now just the 4 required fields) and into a dashboard ⚙ Settings gear,
backed by a /data/app_settings.json overlay keyed by env-var names. One shared
mutable Settings instance + Settings.reload() applies edits live without a
restart; existing installs' values migrate automatically on first boot.

Also: support-service ports (parakeet/kokoro/embed/qdrant + vllm) are now
configurable, and GET /api/swap/lock no longer 404s (it was shadowed by the
/api/swap/{job_id} catch-all). WebhookNotifier is re-pointed on save so its
url/secret reload live too.
This commit is contained in:
Keysat
2026-06-18 13:41:28 -05:00
parent b67e001642
commit 7e0759846f
15 changed files with 797 additions and 268 deletions
+96
View File
@@ -2192,8 +2192,104 @@ function handleUpdateDone(d) {
setTimeout(pollUpdates, 2000);
}
// ===================== settings ('gear') =====================
// Renders the optional cluster knobs from /api/settings (server-driven field
// list, so adding a knob server-side needs no JS change) and POSTs edits back.
// The server reloads its config in place, so changes take effect immediately.
let settingsClearSentinel = '__clear__';
function renderSettingsForm(data) {
settingsClearSentinel = data.clear_sentinel || settingsClearSentinel;
const body = el('#settings-body');
body.innerHTML = (data.groups || []).map((g) => {
const rows = g.fields.map((f) => {
const help = f.help ? `<span class="muted small settings-help">${escapeHtml(f.help)}</span>` : '';
let input;
let clearToggle = '';
if (f.type === 'secret') {
const ph = f.set ? 'set — leave blank to keep' : (f.placeholder || '');
input = `<input type="password" autocomplete="off" data-key="${f.key}" data-secret="1" placeholder="${escapeHtml(ph)}">`;
// A stored secret is never echoed back, so blank means "keep". Offer an
// explicit way to remove it.
if (f.set) clearToggle = `<label class="settings-clear muted small"><input type="checkbox" data-clear-for="${f.key}"> clear stored value</label>`;
} else if (f.type === 'int') {
input = `<input type="number" min="1" max="65535" data-key="${f.key}" value="${escapeHtml(f.value || '')}" placeholder="${escapeHtml(f.placeholder || '')}">`;
} else {
input = `<input type="text" autocomplete="off" data-key="${f.key}" value="${escapeHtml(f.value || '')}" placeholder="${escapeHtml(f.placeholder || '')}">`;
}
return `<div class="settings-field"><label class="modal-row"><span>${escapeHtml(f.label)}</span>${input}</label>${clearToggle}${help}</div>`;
}).join('');
return `<fieldset class="modal-fieldset"><legend>${escapeHtml(g.name)}</legend>${rows}</fieldset>`;
}).join('');
}
async function openSettingsDialog() {
const dlg = el('#settings-dialog');
const err = el('#settings-error');
err.classList.add('hidden');
el('#settings-body').innerHTML = '<p class="muted small">Loading…</p>';
dlg.showModal();
try {
renderSettingsForm(await fetchJSON('/api/settings'));
} catch (e) {
el('#settings-body').innerHTML = '';
err.textContent = 'Could not load settings: ' + e.message;
err.classList.remove('hidden');
}
}
async function saveSettings(e) {
e.preventDefault();
const err = el('#settings-error');
err.classList.add('hidden');
const values = {};
$$('#settings-body [data-key]').forEach((inp) => {
const key = inp.dataset.key;
const v = inp.value.trim();
if (inp.dataset.secret) {
// "clear" checkbox wins; else a typed value sets it; else omit (keep the
// stored one — we can't see it to retype it).
const clear = el(`[data-clear-for="${key}"]`);
if (clear && clear.checked) values[key] = settingsClearSentinel;
else if (v) values[key] = v;
} else {
values[key] = v; // blank non-secret ⇒ server reverts it to the default
}
});
const btn = el('#settings-save');
btn.disabled = true;
try {
await fetchJSON('/api/settings', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ values }),
});
el('#settings-dialog').close();
// Re-pull everything a knob can move: the Open WebUI link, health probes,
// service tiles, and the model menu (host/port changes alter all of them).
try {
state.config = await fetchJSON('/api/config');
const a = el('#open-webui-link');
if (state.config.open_webui_url) { a.href = state.config.open_webui_url; a.classList.remove('hidden'); }
else { a.classList.add('hidden'); }
} catch (e3) { console.warn('post-save /api/config refresh failed:', e3); }
pollStatus();
renderServices();
loadModels();
} catch (e2) {
err.textContent = 'Save failed: ' + e2.message.replace(/^\d+ [^:]*:\s*/, '');
err.classList.remove('hidden');
} finally {
btn.disabled = false;
}
}
async function init() {
setupCopyButtons();
el('#open-settings').addEventListener('click', openSettingsDialog);
el('#settings-cancel').addEventListener('click', () => el('#settings-dialog').close());
el('#settings-form').addEventListener('submit', saveSettings);
el('#open-download').addEventListener('click', openDownloadForm);
el('#dl-cancel').addEventListener('click', closeDownloadPanel);
el('#dl-start').addEventListener('click', startDownload);
+15 -1
View File
@@ -17,14 +17,28 @@
<span class="muted">connecting…</span>
</div>
<a id="open-webui-link" class="topbar-btn hidden" href="#" target="_blank" rel="noopener" title="Open Open WebUI">Open chat ↗</a>
<button id="open-settings" class="topbar-btn" type="button" title="Settings" aria-label="Open cluster settings">⚙ Settings</button>
</header>
<main>
<section id="setup-banner" class="banner hidden">
<strong>Configuration needed.</strong>
<span>Run the <em>Configure Sparks</em> action in StartOS to set hostnames, then run <em>Test Connection</em>.</span>
<span>Run the <em>Configure Sparks</em> action in StartOS to set your two Spark IPs and SSH users. Everything else (ports, services, integrations) lives under <em>⚙ Settings</em> above.</span>
</section>
<dialog id="settings-dialog" class="modal">
<form method="dialog" class="modal-form" id="settings-form">
<h3>Settings</h3>
<p class="muted small">Optional cluster knobs — vLLM/service ports, container names, support-service hosts, and integrations. The two Spark IPs and SSH users are set once via the <em>Configure Sparks</em> action in StartOS; everything else is here. Changes apply immediately. Stored on this server and included in StartOS backups.</p>
<div id="settings-body" class="settings-body"><p class="muted small">Loading…</p></div>
<p id="settings-error" class="muted small dd-error hidden"></p>
<div class="modal-actions">
<button type="button" id="settings-cancel" class="btn">Cancel</button>
<button type="submit" id="settings-save" class="btn primary">Save</button>
</div>
</form>
</dialog>
<section id="hardware-panel" class="hardware-panel hidden">
<div class="section-header">
<h2 class="section-title">Spark hardware</h2>
+10
View File
@@ -964,3 +964,13 @@ main {
.tab-content.active { display: block; }
/* (WhisperX install banner styles removed in v0.13.0:0 — see release notes) */
/* ===== Settings ('gear') dialog ===== */
.modal#settings-dialog { max-width: 560px; }
/* Cap the (tall) form so the Save/Cancel actions stay reachable; the grouped
fields scroll within. */
#settings-body { max-height: 60vh; overflow-y: auto; padding-right: 6px; display: flex; flex-direction: column; gap: 12px; }
.settings-field { display: flex; flex-direction: column; gap: 2px; }
.settings-help { display: block; line-height: 1.35; }
.settings-clear { display: inline-flex; align-items: center; gap: 6px; margin-top: 2px; cursor: pointer; }
.settings-clear input { width: auto; }