provisioning-platform/crates/provisioning-desktop/dist/settings.html

526 lines
23 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Provisioning — Settings</title>
<style>
/* Light + dark palette; the OS picks via prefers-color-scheme. color-scheme
also makes native form controls (checkbox, select, textarea) adapt. */
:root {
color-scheme: light dark;
--bg: #f5f5f7; --surface: #ffffff; --surface-2: #f5f5f7;
--text: #1d1d1f; --text-2: #6e6e73; --text-3: #8e8e93;
--border: #d2d2d7; --border-2: #e5e5ea; --row-border: #f2f2f7;
--badge-bg: #f2f2f7; --badge-fg: #636366;
--accent: #007aff; --accent-hover: #0071e3; --danger: #ff3b30; --ok: #34c759; --warn: #ff9f0a;
--header-bg: rgba(255,255,255,0.8);
--toast-bg: #1d1d1f; --toast-fg: #ffffff;
--shadow: rgba(0,0,0,0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1c1c1e; --surface: #2c2c2e; --surface-2: #242426;
--text: #f5f5f7; --text-2: #aeaeb2; --text-3: #8e8e93;
--border: #3a3a3c; --border-2: #303032; --row-border: #38383a;
--badge-bg: #3a3a3c; --badge-fg: #c7c7cc;
--accent: #0a84ff; --accent-hover: #409cff; --danger: #ff453a; --ok: #30d158; --warn: #ff9f0a;
--header-bg: rgba(28,28,30,0.8);
--toast-bg: #f5f5f7; --toast-fg: #1c1c1e;
--shadow: rgba(0,0,0,0.4);
}
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 13px;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
background: var(--header-bg);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
padding: 0 20px;
height: 44px;
display: flex;
align-items: center;
position: sticky;
top: 0;
z-index: 10;
}
header h1 { font-size: 13px; font-weight: 600; color: var(--text); }
main { padding: 20px; max-width: 720px; margin: 0 auto; }
section { background: var(--surface); border-radius: 10px; overflow: hidden; margin-bottom: 20px; box-shadow: 0 1px 3px var(--shadow); }
section h2 { font-size: 11px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.04em; padding: 12px 16px 8px; background: var(--surface-2); border-bottom: 1px solid var(--border-2); }
.row { display: flex; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--row-border); gap: 12px; }
.row:last-child { border-bottom: none; }
.row label { flex: 1; color: var(--text); }
.row .sub { font-size: 11px; color: var(--text-3); margin-top: 2px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.status-dot.healthy { background: var(--ok); }
.status-dot.starting { background: var(--warn); }
.status-dot.unhealthy, .status-dot.unreachable { background: var(--danger); }
.status-dot.stopped { background: var(--text-3); }
.status-dot.connected { background: var(--ok); }
.status-dot.connecting { background: var(--warn); }
.status-dot.disconnected { background: var(--danger); }
.status-dot.disabled { background: var(--text-3); }
.badge { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: var(--badge-bg); color: var(--badge-fg); }
.badge.active { background: var(--accent); color: #fff; }
button {
font-size: 12px;
font-family: inherit;
padding: 5px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
color: var(--text);
white-space: nowrap;
}
button:hover { background: var(--surface-2); }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.primary:hover { background: var(--accent-hover); }
button.danger { background: var(--danger); color: #fff; border-color: var(--danger); }
button.danger:hover { filter: brightness(0.92); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
/* Native checkbox — a desktop-appropriate affordance (not a mobile slider). */
input.check { width: 17px; height: 17px; flex-shrink: 0; accent-color: var(--accent); cursor: pointer; margin: 0; }
.secret-input { font-family: monospace; font-size: 12px; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; width: 180px; background: var(--surface); color: var(--text); }
.field { font-size: 12px; font-family: inherit; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); }
textarea.field { font-family: ui-monospace, monospace; line-height: 1.4; }
#toast {
position: fixed;
bottom: 20px; left: 50%;
transform: translateX(-50%) translateY(80px);
background: var(--toast-bg);
color: var(--toast-fg);
padding: 8px 16px;
border-radius: 8px;
font-size: 12px;
opacity: 0;
transition: transform 0.25s, opacity 0.25s;
pointer-events: none;
z-index: 100;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
#toast.err { background: var(--danger); color: #fff; }
</style>
</head>
<body>
<header><h1>Provisioning — Settings</h1></header>
<main>
<section>
<h2>Status</h2>
<div class="row">
<div class="status-dot" id="daemon-dot"></div>
<label>Daemon<div class="sub" id="daemon-label">checking…</div></label>
<button id="btn-start" data-act="daemon" data-arg="start_daemon">Start</button>
<button id="btn-stop" data-act="daemon" data-arg="stop_daemon">Stop</button>
<button id="btn-restart" data-act="daemon" data-arg="restart_daemon">Restart</button>
</div>
<div class="row">
<div class="status-dot" id="nats-dot"></div>
<label>NATS<div class="sub" id="nats-label">checking…</div></label>
</div>
</section>
<section>
<h2>Connections</h2>
<div id="conn-list"></div>
</section>
<section>
<h2>Launchers</h2>
<div class="row">
<label>Scope<div class="sub">Where the entry appears in the tray menu</div></label>
<select id="launcher-scope" class="field" data-change="scope"></select>
<button class="primary" data-act="launcher-add">+ Add</button>
</div>
<div id="launcher-list"></div>
<div id="launcher-form"></div>
</section>
<section>
<h2>Authentication</h2>
<div id="auth-section">
<div class="row"><label>Loading…</label></div>
</div>
</section>
<section>
<h2>Notifications</h2>
<div class="row">
<label>Enable notifications<div class="sub">Master switch for all OS notifications below</div></label>
<input type="checkbox" class="check" id="notif-enabled" data-change="notif:enabled">
</div>
<div class="row">
<label>Tool completed<div class="sub">Notify when a daemon tool finishes successfully</div></label>
<input type="checkbox" class="check" id="notif-invoked" data-change="notif:on_tool_invoked">
</div>
<div class="row">
<label>Tool failed<div class="sub">Notify when a daemon tool returns an error</div></label>
<input type="checkbox" class="check" id="notif-failed" data-change="notif:on_tool_failed">
</div>
<div class="row">
<label>Config reloaded<div class="sub">Notify when the daemon reloads its config. Needs a live NATS connection — has no effect while on the REST polling fallback</div></label>
<input type="checkbox" class="check" id="notif-config" data-change="notif:on_config_changed">
</div>
<div class="row">
<label>Only on failure<div class="sub">Mute the “Tool completed” notifications above and notify only when a tool fails</div></label>
<input type="checkbox" class="check" id="notif-only-fail" data-change="notif:only_failures">
</div>
</section>
</main>
<div id="toast"></div>
<script>
const invoke = window.__TAURI_INTERNALS__?.invoke
?? (() => Promise.reject('Tauri IPC not available'));
let settings = null;
// ── Bootstrap ────────────────────────────────────────────────────────────
async function init() {
try {
settings = await invoke('get_settings');
renderConnections(settings);
renderAuth(settings);
renderNotifPrefs(settings.notifications);
scopeOptions();
renderLaunchers();
} catch (e) {
toast('Failed to load settings: ' + e, true);
}
await refreshStatus();
}
async function refreshStatus() {
try {
const s = await invoke('get_status');
setDot('daemon-dot', s.daemon);
setDot('nats-dot', s.nats);
document.getElementById('daemon-label').textContent = s.daemon;
document.getElementById('nats-label').textContent = s.nats;
// Daemon controls are only meaningful for local connections.
const isLocal = settings?.connections
?.find(c => c.id === settings?.active)
?.kind === 'local';
['btn-start','btn-stop','btn-restart'].forEach(id => {
document.getElementById(id).disabled = !isLocal;
});
} catch (_) {}
setTimeout(refreshStatus, 4000);
}
// ── Connections ──────────────────────────────────────────────────────────
function renderConnections(s) {
const list = document.getElementById('conn-list');
list.innerHTML = '';
for (const c of s.connections) {
const isActive = c.id === s.active;
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = `
<label>${esc(c.label)}<div class="sub">${esc(c.url)}</div></label>
<span class="badge${isActive ? ' active' : ''}">${isActive ? 'active' : c.kind}</span>
${isActive ? '' : `<button data-act="switch" data-arg="${esc(c.id)}">Switch</button>`}
`;
list.appendChild(row);
}
}
async function switchConn(id) {
try {
await invoke('set_active_connection', { id });
settings = await invoke('get_settings');
renderConnections(settings);
renderAuth(settings);
scopeOptions();
renderLaunchers();
toast('Switched to ' + id);
} catch (e) {
toast('Switch failed: ' + e, true);
}
}
// ── Auth ─────────────────────────────────────────────────────────────────
function renderAuth(s) {
const section = document.getElementById('auth-section');
section.innerHTML = '';
for (const c of s.connections) {
if (c.auth.mode === 'solo') continue;
const row = document.createElement('div');
row.className = 'row';
const modeLabel = c.auth.mode === 'jwt' ? 'JWT token' : 'Password';
row.innerHTML = `
<label>${esc(c.label)}<div class="sub">${modeLabel}</div></label>
<input class="secret-input" type="password" placeholder="Enter ${modeLabel.toLowerCase()}…"
id="secret-${esc(c.id)}" />
<button class="primary" data-act="login" data-arg="${esc(c.id)}">Save</button>
<button class="danger" data-act="logout" data-arg="${esc(c.id)}">Clear</button>
`;
section.appendChild(row);
}
if (section.innerHTML === '') {
section.innerHTML = '<div class="row"><label style="color:#8e8e93">All connections use solo mode — no credentials required.</label></div>';
}
}
async function doLogin(id) {
const secret = document.getElementById('secret-' + id)?.value ?? '';
if (!secret) { toast('Secret cannot be empty', true); return; }
try {
await invoke('login', { payload: { id, secret } });
document.getElementById('secret-' + id).value = '';
toast('Credential saved for ' + id);
} catch (e) {
toast('Login failed: ' + e, true);
}
}
async function doLogout(id) {
try {
await invoke('logout', { id });
toast('Credential cleared for ' + id);
} catch (e) {
toast('Logout failed: ' + e, true);
}
}
// ── Launchers ────────────────────────────────────────────────────────────
let launcherEditIndex = -1;
function scopeOptions() {
const sel = document.getElementById('launcher-scope');
const prev = sel.value;
sel.innerHTML = '<option value="global">Global (all connections)</option>' +
settings.connections.map(c => `<option value="conn:${esc(c.id)}">${esc(c.label)}</option>`).join('');
sel.value = prev || ('conn:' + settings.active);
if (!sel.value) sel.value = 'global';
}
// Returns the live launchers array (a reference into `settings`) for a scope,
// creating the field if absent. Falls back to global for an unknown scope so
// a push is never silently dropped into a throwaway array.
function launchersFor(scopeVal) {
if (!scopeVal || scopeVal === 'global') return settings.launchers || (settings.launchers = []);
const id = scopeVal.slice('conn:'.length);
const c = settings.connections.find(x => x.id === id);
if (!c) return settings.launchers || (settings.launchers = []);
return c.launchers || (c.launchers = []);
}
function renderLaunchers() {
const scopeVal = document.getElementById('launcher-scope').value || 'global';
const list = document.getElementById('launcher-list');
const arr = launchersFor(scopeVal);
list.innerHTML = arr.length ? '' :
'<div class="row"><label style="color:#8e8e93">No launchers in this scope.</label></div>';
arr.forEach((l, i) => {
const kind = l.kind || 'tool';
const detail = (l.target || '') +
(kind === 'command' && l.args && l.args.length ? ' ' + l.args.join(' ') : '');
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = `
<label>${esc(l.label)}<div class="sub">${esc(detail)}</div></label>
<span class="badge">${esc(kind)}</span>
<button data-act="launcher-edit" data-arg="${i}">Edit</button>
<button class="danger" data-act="launcher-delete" data-arg="${i}">Delete</button>
`;
list.appendChild(row);
});
document.getElementById('launcher-form').innerHTML = '';
}
function launcherAdd() {
if (!settings) { toast('Settings not loaded yet', true); return; }
// Ensure the scope <select> is populated (defensive against any earlier
// init failure) so the new launcher lands in a real settings array.
const sel = document.getElementById('launcher-scope');
if (!sel.options.length) scopeOptions();
launcherShowForm(-1, { kind: 'url', label: '', target: '', args: [], params: null, show_output: false, confirm: false });
}
function launcherEditAt(i) {
const arr = launchersFor(document.getElementById('launcher-scope').value);
launcherShowForm(i, Object.assign({}, arr[i]));
}
function launcherShowForm(index, l) {
launcherEditIndex = index;
const kind = l.kind || 'tool';
const paramsText = l.params && Object.keys(l.params).length ? JSON.stringify(l.params, null, 2) : '';
document.getElementById('launcher-form').innerHTML = `
<div class="row"><label>Kind</label>
<select id="lf-kind" class="field" data-change="kind">
<option value="url">URL (open in browser)</option>
<option value="command">Command (run + show output)</option>
<option value="tool">Tool (daemon /api/v1)</option>
</select>
</div>
<div class="row"><label>Label</label><input id="lf-label" class="field" style="width:260px" value="${esc(l.label || '')}"></div>
<div class="row"><label id="lf-target-label">Target</label><input id="lf-target" class="field" style="width:260px" value="${esc(l.target || '')}"></div>
<div class="row" id="lf-args-row"><label>Arguments<div class="sub">one per line</div></label><textarea id="lf-args" class="field" style="width:260px;height:60px;resize:vertical">${esc((l.args || []).join('\n'))}</textarea></div>
<div class="row" id="lf-show-row"><label>Show output<div class="sub">Capture stdout/stderr and open the output window</div></label><input type="checkbox" class="check" id="lf-show" ${l.show_output ? 'checked' : ''}></div>
<div class="row" id="lf-params-row"><label>Params (JSON)</label><textarea id="lf-params" class="field" style="width:260px;height:60px;resize:vertical">${esc(paramsText)}</textarea></div>
<div class="row"><label>Confirm before running<div class="sub">Ask before this launcher runs</div></label><input type="checkbox" class="check" id="lf-confirm" ${l.confirm ? 'checked' : ''}></div>
<div class="row" style="justify-content:flex-end">
<button data-act="launcher-cancel">Cancel</button>
<button class="primary" data-act="launcher-save">${index < 0 ? 'Add' : 'Save'}</button>
</div>
`;
document.getElementById('lf-kind').value = kind;
launcherKindToggle();
}
function launcherKindToggle() {
const k = document.getElementById('lf-kind').value;
document.getElementById('lf-args-row').style.display = k === 'command' ? '' : 'none';
document.getElementById('lf-show-row').style.display = k === 'command' ? '' : 'none';
document.getElementById('lf-params-row').style.display = k === 'tool' ? '' : 'none';
document.getElementById('lf-target-label').textContent =
k === 'url' ? 'URL' : k === 'command' ? 'Program' : 'Tool name';
}
async function launcherSave() {
const arr = launchersFor(document.getElementById('launcher-scope').value);
const kind = document.getElementById('lf-kind').value;
const label = document.getElementById('lf-label').value.trim();
const target = document.getElementById('lf-target').value.trim();
if (!label || !target) { toast('Label and target are required', true); return; }
const l = { kind, label, target, confirm: document.getElementById('lf-confirm').checked };
if (kind === 'command') {
l.args = document.getElementById('lf-args').value.split('\n').map(s => s.trim()).filter(Boolean);
l.show_output = document.getElementById('lf-show').checked;
}
if (kind === 'tool') {
const raw = document.getElementById('lf-params').value.trim();
if (raw) {
try { l.params = JSON.parse(raw); }
catch (_) { toast('Params must be valid JSON', true); return; }
}
}
if (launcherEditIndex >= 0) {
l.id = arr[launcherEditIndex].id;
arr[launcherEditIndex] = l;
} else {
l.id = slug(label) + '-' + Math.random().toString(36).slice(2, 6);
arr.push(l);
}
try {
await invoke('save_settings', { settings });
toast('Launcher saved');
renderLaunchers();
} catch (e) { toast('Save failed: ' + e, true); }
}
async function launcherDelete(i) {
launchersFor(document.getElementById('launcher-scope').value).splice(i, 1);
try {
await invoke('save_settings', { settings });
toast('Launcher deleted');
renderLaunchers();
} catch (e) { toast('Delete failed: ' + e, true); }
}
function slug(s) {
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'launcher';
}
// ── Notifications ────────────────────────────────────────────────────────
function renderNotifPrefs(prefs) {
document.getElementById('notif-enabled').checked = prefs.enabled;
document.getElementById('notif-invoked').checked = prefs.on_tool_invoked;
document.getElementById('notif-failed').checked = prefs.on_tool_failed;
document.getElementById('notif-config').checked = prefs.on_config_changed;
document.getElementById('notif-only-fail').checked = prefs.only_failures;
}
async function saveNotifPref(key, value) {
if (!settings) return;
settings.notifications[key] = value;
try {
await invoke('save_settings', { settings });
toast('Saved');
} catch (e) {
toast('Save failed: ' + e, true);
}
}
// ── Generic command wrapper ───────────────────────────────────────────────
async function cmd(name) {
try {
await invoke(name);
toast(name.replace(/_/g, ' ') + ' sent');
} catch (e) {
toast(e, true);
}
}
// ── Utilities ────────────────────────────────────────────────────────────
function setDot(id, status) {
const el = document.getElementById(id);
el.className = 'status-dot ' + (status ?? 'stopped');
}
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
let toastTimer = null;
function toast(msg, isErr = false) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.toggle('err', isErr); // colors come from CSS (theme-aware)
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 2500);
}
// CSP-safe event delegation. Tauri 2 hashes inline <script> blocks, which
// makes the browser ignore 'unsafe-inline' and BLOCK inline on* handlers —
// so every action is wired via data-act / data-change, not onclick/onchange.
document.addEventListener('click', (e) => {
const el = e.target.closest('[data-act]');
if (!el) return;
const arg = el.dataset.arg;
switch (el.dataset.act) {
case 'daemon': cmd(arg); break;
case 'switch': switchConn(arg); break;
case 'login': doLogin(arg); break;
case 'logout': doLogout(arg); break;
case 'launcher-add': launcherAdd(); break;
case 'launcher-edit': launcherEditAt(Number(arg)); break;
case 'launcher-delete': launcherDelete(Number(arg)); break;
case 'launcher-save': launcherSave(); break;
case 'launcher-cancel': document.getElementById('launcher-form').innerHTML = ''; break;
}
});
document.addEventListener('change', (e) => {
const el = e.target.closest('[data-change]');
if (!el) return;
const c = el.dataset.change;
if (c === 'scope') renderLaunchers();
else if (c === 'kind') launcherKindToggle();
else if (c.startsWith('notif:')) saveNotifPref(c.slice(6), el.checked);
});
init();
</script>
</body>
</html>