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