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);