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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user