From 0b53616160531486a45b5dca7bfc897ff3eaadd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:16:09 +0000 Subject: [PATCH] Admin page: schema-driven config, enable/disable, live re-init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 5. GET /api/admin/state, PUT /api/admin/modules//config and /enabled persist to config/modules.json and re-init the module in place — no restart. /api/events (SSE) tells open dashboards about admin changes; the shell then calls onConfigChange(cfg) on instances that implement it and cleanly remounts the rest, so tiles reconnect on their own. Admin page renders forms from each configSchema (text/number/boolean/select/ password), shows module health from handle.health(), and never echoes password values back to a browser. Optional shared passcode (config/proddash.json adminPasscode or PRODDASH_PASSCODE) gates the admin API behind a cookie; the dashboard API stays open. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- public/admin/admin.css | 154 +++++++++++++++++ public/admin/admin.js | 372 ++++++++++++++++++++++++++++++++++++++++ public/admin/index.html | 49 ++++++ public/shell.js | 57 +++++- server.js | 189 ++++++++++++++++++++ 5 files changed, 819 insertions(+), 2 deletions(-) create mode 100644 public/admin/admin.css create mode 100644 public/admin/admin.js create mode 100644 public/admin/index.html diff --git a/public/admin/admin.css b/public/admin/admin.css new file mode 100644 index 0000000..5f07ca1 --- /dev/null +++ b/public/admin/admin.css @@ -0,0 +1,154 @@ +/* ProdDash admin page — on top of the shell's style.css */ + +body { overflow: auto; } + +.admin-tag { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 5px; + padding: 2px 6px; + margin-left: 6px; + vertical-align: 2px; +} + +#admin-main { + flex: 1; + overflow-y: auto; + width: min(720px, 100%); + margin: 0 auto; + padding: 18px 16px 60px; +} + +.admin-h1 { + font-size: 16px; + margin: 22px 0 6px; +} + +.admin-copy { + color: var(--muted); + font-size: 13px; + line-height: 1.5; + margin-bottom: 14px; +} + +.admin-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; + margin-bottom: 14px; +} + +.admin-card h2 { font-size: 14px; margin-bottom: 8px; } + +.login-row { display: flex; gap: 8px; } +.login-row input { + flex: 1; + padding: 8px 10px; + font-size: 14px; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 7px; +} +.login-row input:focus { outline: none; border-color: var(--accent); } + +.module-head { + display: flex; + align-items: center; + gap: 10px; +} + +.module-head .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; } +.module-head .dot.ok { background: var(--accent); box-shadow: 0 0 5px var(--accent); } +.module-head .dot.connecting { background: var(--warn); } +.module-head .dot.error { background: var(--danger); } + +.module-name { font-size: 14px; font-weight: 700; } +.module-version { color: var(--muted); font-size: 11px; } +.module-head .spacer { flex: 1; } + +.module-desc { color: var(--muted); font-size: 12px; margin: 6px 0 2px; } + +.module-health { + font-size: 12px; + color: var(--muted); + margin-top: 4px; + min-height: 15px; +} +.module-health.error { color: var(--danger); } + +.module-mount-error { + margin-top: 8px; + padding: 8px 10px; + border: 1px solid var(--danger); + border-radius: 8px; + color: var(--danger); + font-size: 12px; +} + +.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; } +.module-form .field { max-width: 420px; } +.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; } + +.module-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 4px; +} + +.admin-status { min-height: 16px; font-size: 12px; color: var(--muted); margin-top: 8px; } +.admin-status.error { color: var(--danger); } +.admin-status.ok { color: var(--accent); } + +/* enable/disable switch */ +.switch { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 12px; + color: var(--muted); + user-select: none; +} +.switch input { position: absolute; opacity: 0; pointer-events: none; } +.switch .track { + width: 34px; + height: 19px; + border-radius: 999px; + background: var(--border); + position: relative; + transition: background 0.2s; + flex: none; +} +.switch .track::after { + content: ""; + position: absolute; + top: 2px; left: 2px; + width: 15px; height: 15px; + border-radius: 50%; + background: var(--muted); + transition: transform 0.2s, background 0.2s; +} +.switch input:checked + .track { background: var(--accent); } +.switch input:checked + .track::after { transform: translateX(15px); background: var(--bg); } + +.layout-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 8px; +} +.layout-row .name { flex: 1; font-size: 13px; font-weight: 600; } +.layout-row .meta { color: var(--muted); font-size: 11px; } +.layouts-empty { color: var(--muted); font-size: 13px; } diff --git a/public/admin/admin.js b/public/admin/admin.js new file mode 100644 index 0000000..b137d68 --- /dev/null +++ b/public/admin/admin.js @@ -0,0 +1,372 @@ +/* ProdDash admin client. + + Everything here is server-wide: module enable/disable and admin config + (written to config/modules.json, module re-inited on save), plus named + layout management. Guarded by the shared passcode when one is set. */ + +const loginCard = document.getElementById('login-card'); +const loginStatus = document.getElementById('login-status'); +const passcodeInput = document.getElementById('passcode'); +const modulesSection = document.getElementById('modules-section'); +const modulesList = document.getElementById('modules-list'); +const layoutsSection = document.getElementById('layouts-section'); +const layoutsList = document.getElementById('layouts-list'); +const toastEl = document.getElementById('toast'); + +let toastTimer = null; +function toast(msg, isError = false) { + toastEl.textContent = msg; + toastEl.classList.toggle('error', isError); + toastEl.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200); +} + +async function api(path, opts = {}) { + const res = await fetch(path, { + ...opts, + headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(body.error || `Request failed (${res.status})`); + err.status = res.status; + err.authRequired = Boolean(body.authRequired); + throw err; + } + return body; +} + +/* ── login ──────────────────────────────────────────────────────────── */ + +function showLogin() { + loginCard.hidden = false; + modulesSection.hidden = true; + layoutsSection.hidden = true; + passcodeInput.focus(); +} + +async function login() { + loginStatus.textContent = ''; + loginStatus.className = 'admin-status'; + try { + await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: passcodeInput.value }) }); + passcodeInput.value = ''; + loginCard.hidden = true; + await loadState(); + } catch (e) { + loginStatus.textContent = e.message; + loginStatus.className = 'admin-status error'; + } +} + +document.getElementById('login-btn').addEventListener('click', login); +passcodeInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') login(); +}); + +/* ── module cards ───────────────────────────────────────────────────── */ + +/** moduleId -> { dot, healthEl, toggle } for cheap in-place status refreshes */ +const liveEls = new Map(); + +function healthClass(mod) { + if (!mod.enabled) return ''; + if (mod.mountError) return 'error'; + return mod.health ? mod.health.status : 'ok'; +} + +function healthText(mod) { + if (!mod.enabled) return 'Disabled — hidden from the dashboard picker, server routes unmounted.'; + if (mod.mountError) return ''; + if (mod.health) return mod.health.message || mod.health.status; + return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).'; +} + +function fieldFor(key, spec, value, passwordSet) { + const type = spec?.type || 'string'; + const label = spec?.label || key; + const field = document.createElement('label'); + field.className = 'field' + (type === 'boolean' ? ' check' : ''); + let read; + + if (type === 'boolean') { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = Boolean(value); + field.append(input, document.createTextNode(label)); + read = () => input.checked; + } else if (type === 'select' && Array.isArray(spec.options)) { + const span = document.createElement('span'); + span.textContent = label; + const select = document.createElement('select'); + for (const opt of spec.options) { + const o = document.createElement('option'); + o.value = String(typeof opt === 'object' ? opt.value : opt); + o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); + select.appendChild(o); + } + select.value = String(value ?? ''); + field.append(span, select); + read = () => select.value; + } else { + const span = document.createElement('span'); + span.textContent = label; + const input = document.createElement('input'); + input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text'); + if (type === 'password') { + input.placeholder = passwordSet ? 'Saved — leave blank to keep' : 'Not set'; + input.autocomplete = 'new-password'; + } else { + input.value = value === undefined || value === null ? '' : String(value); + } + field.append(span, input); + read = () => (type === 'number' ? Number(input.value) : input.value); + } + return { field, read }; +} + +function buildModuleCard(mod) { + const card = document.createElement('section'); + card.className = 'admin-card'; + + const head = document.createElement('div'); + head.className = 'module-head'; + + const dot = document.createElement('span'); + dot.className = 'dot ' + healthClass(mod); + + const name = document.createElement('span'); + name.className = 'module-name'; + name.textContent = mod.name; + + const version = document.createElement('span'); + version.className = 'module-version'; + version.textContent = mod.version ? 'v' + mod.version : ''; + + const spacer = document.createElement('span'); + spacer.className = 'spacer'; + + const toggleLabel = document.createElement('label'); + toggleLabel.className = 'switch'; + const toggle = document.createElement('input'); + toggle.type = 'checkbox'; + toggle.checked = mod.enabled; + const track = document.createElement('span'); + track.className = 'track'; + const toggleText = document.createElement('span'); + toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled'; + toggleLabel.append(toggle, track, toggleText); + + head.append(dot, name, version, spacer, toggleLabel); + card.appendChild(head); + + const desc = document.createElement('div'); + desc.className = 'module-desc'; + desc.textContent = mod.description; + card.appendChild(desc); + + const healthEl = document.createElement('div'); + healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); + healthEl.textContent = healthText(mod); + card.appendChild(healthEl); + + const mountError = document.createElement('div'); + mountError.className = 'module-mount-error'; + mountError.hidden = !mod.mountError; + mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; + card.appendChild(mountError); + + liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError }); + + toggle.addEventListener('change', async () => { + try { + await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/enabled`, { + method: 'PUT', + body: JSON.stringify({ enabled: toggle.checked }), + }); + toggleText.textContent = toggle.checked ? 'Enabled' : 'Disabled'; + toast(`${mod.name} ${toggle.checked ? 'enabled' : 'disabled'}`); + refreshStatus(); + } catch (e) { + toggle.checked = !toggle.checked; + if (e.authRequired) showLogin(); + else toast(e.message, true); + } + }); + + const schemaKeys = Object.entries(mod.configSchema || {}); + if (schemaKeys.length) { + const form = document.createElement('div'); + form.className = 'module-form'; + const readers = new Map(); + for (const [key, spec] of schemaKeys) { + const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key]); + readers.set(key, read); + form.appendChild(field); + } + const actions = document.createElement('div'); + actions.className = 'module-actions'; + const save = document.createElement('button'); + save.className = 'btn primary'; + save.textContent = 'Save & re-init'; + const status = document.createElement('span'); + status.className = 'admin-status'; + actions.append(save, status); + form.appendChild(actions); + card.appendChild(form); + + save.addEventListener('click', async () => { + const config = {}; + for (const [key, read] of readers) config[key] = read(); + save.disabled = true; + status.textContent = 'Saving…'; + status.className = 'admin-status'; + try { + await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, { + method: 'PUT', + body: JSON.stringify({ config }), + }); + status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.'; + status.className = 'admin-status ok'; + refreshStatus(); + } catch (e) { + status.textContent = e.message; + status.className = 'admin-status error'; + if (e.authRequired) showLogin(); + } finally { + save.disabled = false; + } + }); + } else { + const none = document.createElement('div'); + none.className = 'module-form-empty'; + none.textContent = 'No server-wide settings — this module is configured per tile.'; + card.appendChild(none); + } + + return card; +} + +/* ── status refresh (leaves form inputs alone) ──────────────────────── */ + +async function refreshStatus() { + let state; + try { + state = await api('/api/admin/state'); + } catch { + return; // transient — the next tick will get it + } + for (const mod of state.modules || []) { + const els = liveEls.get(mod.id); + if (!els) continue; + els.dot.className = 'dot ' + healthClass(mod); + els.healthEl.textContent = healthText(mod); + els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); + els.mountError.hidden = !mod.mountError; + els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; + if (document.activeElement !== els.toggle) { + els.toggle.checked = mod.enabled; + els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled'; + } + } +} + +/* ── named layouts ──────────────────────────────────────────────────── */ + +async function loadLayouts() { + let body; + try { + body = await api('/api/layouts'); + } catch { + layoutsSection.hidden = true; + return; + } + layoutsSection.hidden = false; + layoutsList.innerHTML = ''; + const layouts = body.layouts || []; + if (!layouts.length) { + const none = document.createElement('div'); + none.className = 'layouts-empty'; + none.textContent = 'No named layouts saved yet. Save one from the dashboard’s Layout menu.'; + layoutsList.appendChild(none); + return; + } + for (const layout of layouts) { + const row = document.createElement('div'); + row.className = 'layout-row'; + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = layout.name; + const meta = document.createElement('span'); + meta.className = 'meta'; + meta.textContent = `${layout.tiles} tile${layout.tiles === 1 ? '' : 's'}` + + (layout.updated ? ` · ${new Date(layout.updated).toLocaleString()}` : ''); + const rename = document.createElement('button'); + rename.className = 'btn'; + rename.textContent = 'Rename'; + rename.addEventListener('click', async () => { + const next = prompt('New name for this layout:', layout.name); + if (!next || next.trim() === '' || next === layout.name) return; + try { + await api(`/api/layouts/${encodeURIComponent(layout.name)}/rename`, { + method: 'POST', + body: JSON.stringify({ name: next.trim() }), + }); + toast('Layout renamed'); + loadLayouts(); + } catch (e) { + if (e.authRequired) showLogin(); + else toast(e.message, true); + } + }); + const del = document.createElement('button'); + del.className = 'btn'; + del.textContent = 'Delete'; + del.addEventListener('click', async () => { + if (!confirm(`Delete the layout “${layout.name}”? Browsers using it keep their current tiles.`)) return; + try { + await api(`/api/layouts/${encodeURIComponent(layout.name)}`, { method: 'DELETE' }); + toast('Layout deleted'); + loadLayouts(); + } catch (e) { + if (e.authRequired) showLogin(); + else toast(e.message, true); + } + }); + row.append(name, meta, rename, del); + layoutsList.appendChild(row); + } +} + +/* ── boot ───────────────────────────────────────────────────────────── */ + +async function loadState() { + let state; + try { + state = await api('/api/admin/state'); + } catch (e) { + if (e.status === 401 || e.authRequired) { + showLogin(); + return; + } + toast('ProdDash server unreachable — retrying…', true); + setTimeout(loadState, 4000); + return; + } + loginCard.hidden = true; + modulesSection.hidden = false; + modulesList.innerHTML = ''; + liveEls.clear(); + for (const mod of state.modules || []) { + modulesList.appendChild(buildModuleCard(mod)); + } + loadLayouts(); +} + +setInterval(() => { + if (modulesSection.hidden) return; + refreshStatus(); +}, 5000); + +loadState(); diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..a273555 --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,49 @@ + + + + + + ProdDash Admin + + + + + +
+ ProdDash Admin + + ← Dashboard +
+ +
+ + + + + +
+ + + + + + diff --git a/public/shell.js b/public/shell.js index 4797c28..2c40093 100644 --- a/public/shell.js +++ b/public/shell.js @@ -461,8 +461,11 @@ function buildModuleApi(tile, man, entry) { id: man.id, instanceId: tile.id, - /** Admin (server-wide) config, read-only. Password fields never reach the client. */ - config: Object.freeze({ ...(man.config || {}) }), + /** Admin (server-wide) config, read-only. Password fields never reach + the client. Live: reflects the latest registry after admin saves. */ + get config() { + return { ...((registry.get(man.id) || man).config || {}) }; + }, /** Per-tile settings: schema defaults overlaid with what this tile saved. */ get instanceSettings() { @@ -716,6 +719,55 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => { }, { danger: true, sub: 'Remove every tile from this browser' })); }); +/* ── live updates from the admin page ───────────────────────────────── */ + +/* The server broadcasts `modules-changed` on /api/events whenever admin + config or enabled flags change. Tiles of a changed module get + onConfigChange(cfg) if they implement it, otherwise a clean remount. */ + +let eventsSource = null; +let eventsRetry = null; + +function subscribeEvents() { + try { eventsSource?.close(); } catch { /* not open */ } + eventsSource = new EventSource('/api/events'); + eventsSource.addEventListener('modules-changed', () => handleModulesChanged()); + eventsSource.onerror = () => { + if (eventsSource.readyState === EventSource.CLOSED) { + clearTimeout(eventsRetry); + eventsRetry = setTimeout(subscribeEvents, 4000); + } + }; +} + +async function handleModulesChanged() { + const before = registry; + if (!(await loadRegistry())) return; + for (const tile of [...tiles]) { + const oldMan = before.get(tile.module); + const newMan = registry.get(tile.module); + if (!newMan !== !oldMan) { + // enabled or disabled: rebuild the tile body (shows the module, or + // an "unavailable" note) + remountTile(tile); + continue; + } + if (!newMan) continue; + if (JSON.stringify(oldMan.config || {}) !== JSON.stringify(newMan.config || {})) { + const entry = tileEls.get(tile.id); + if (typeof entry?.instance?.onConfigChange === 'function') { + try { + entry.instance.onConfigChange({ ...newMan.config }); + } catch { + remountTile(tile); + } + } else { + remountTile(tile); + } + } + } +} + /* ── boot ───────────────────────────────────────────────────────────── */ async function boot() { @@ -726,6 +778,7 @@ async function boot() { return; } renderLayout(loadLayoutFromStorage()); + subscribeEvents(); } boot(); diff --git a/server.js b/server.js index 48eb34e..8670ec7 100644 --- a/server.js +++ b/server.js @@ -18,6 +18,7 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); const os = require('os'); +const crypto = require('crypto'); const ROOT = __dirname; const PUBLIC_DIR = path.join(ROOT, 'public'); @@ -260,6 +261,169 @@ function clientManifest(id) { }; } +/* ── shell events (SSE to every open dashboard/admin page) ──────────── */ + +const shellStreams = new Set(); + +function broadcastShellEvent(type, payload = {}) { + if (!shellStreams.size) return; + const frame = `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`; + for (const res of shellStreams) { + try { + res.write(frame); + } catch { + shellStreams.delete(res); + } + } +} + +setInterval(() => { + for (const res of shellStreams) { + try { + res.write(': ping\n\n'); + } catch { + shellStreams.delete(res); + } + } +}, 25000).unref(); + +/* ── admin API ──────────────────────────────────────────────────────── */ + +const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || ''); +/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */ +const adminToken = crypto.randomBytes(24).toString('hex'); + +function isAuthed(req) { + if (!ADMIN_PASSCODE) return true; + const cookies = String(req.headers.cookie || ''); + return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken); +} + +/** Guard a state-changing admin route. Returns true when the request was refused. */ +function refuseAdminWrite(req, res) { + if (!isAuthed(req)) { + sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true }); + return true; + } + // Requiring application/json forces a CORS preflight that a hostile page + // on some other origin cannot pass (same reasoning as the reference apps). + const contentType = String(req.headers['content-type'] || '').toLowerCase(); + if (!contentType.startsWith('application/json')) { + sendJson(res, 415, { error: 'Send application/json.' }); + return true; + } + return false; +} + +/** Everything the admin page needs, per module. */ +function adminModuleView(id) { + const man = manifests.get(id); + const cfg = effectiveConfig(id); + const values = {}; + const passwordSet = {}; + for (const [key, spec] of Object.entries(man.configSchema || {})) { + if (spec?.type === 'password') { + passwordSet[key] = Boolean(cfg[key]); + values[key] = ''; // never echo secrets back to a browser + } else { + values[key] = cfg[key]; + } + } + const entry = mounted.get(id); + return { + id, + name: String(man.name || id), + version: String(man.version || ''), + description: String(man.description || ''), + hasServer: Boolean(man.server), + enabled: isEnabled(id), + configSchema: man.configSchema || {}, + config: values, + passwordSet, + mountError: entry?.error || '', + health: isEnabled(id) ? moduleHealth(id) : null, + }; +} + +/** Coerce and store a config patch according to the module's schema. + Empty password fields mean "keep what is stored". */ +function applyConfigPatch(id, patch) { + const man = manifests.get(id); + const current = effectiveConfig(id); + const next = {}; + for (const [key, spec] of Object.entries(man.configSchema || {})) { + const type = spec?.type || 'string'; + let value = patch && typeof patch === 'object' && key in patch ? patch[key] : current[key]; + if (type === 'password' && (value === '' || value === undefined || value === null)) { + value = current[key] || ''; + } + if (type === 'number') value = Number(value) || 0; + else if (type === 'boolean') value = Boolean(value); + else value = value === undefined || value === null ? '' : String(value); + next[key] = value; + } + modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next }; + writeJson(MODULES_CONFIG_PATH, modulesConfig); +} + +async function handleAdminApi(req, res, urlPath) { + if (urlPath === '/api/admin/login' && req.method === 'POST') { + let body = {}; + try { + body = JSON.parse((await readBody(req)) || '{}'); + } catch { + return sendJson(res, 400, { error: 'Malformed request.' }); + } + if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false }); + if (String(body.passcode || '') !== ADMIN_PASSCODE) { + return sendJson(res, 403, { error: 'Wrong passcode.' }); + } + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`, + 'Cache-Control': 'no-store', + }); + return res.end(JSON.stringify({ ok: true })); + } + + if (urlPath === '/api/admin/state' && req.method === 'GET') { + if (!isAuthed(req)) { + return sendJson(res, 401, { authRequired: true, authed: false }); + } + return sendJson(res, 200, { + authRequired: Boolean(ADMIN_PASSCODE), + authed: true, + modules: [...manifests.keys()].sort().map(adminModuleView), + }); + } + + const m = urlPath.match(/^\/api\/admin\/modules\/([^/]+)\/(config|enabled)$/); + if (m && req.method === 'PUT') { + const id = decodeURIComponent(m[1]); + if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` }); + if (refuseAdminWrite(req, res)) return; + let body = {}; + try { + body = JSON.parse((await readBody(req)) || '{}'); + } catch { + return sendJson(res, 400, { error: 'Malformed request.' }); + } + if (m[2] === 'config') { + applyConfigPatch(id, body.config); + remountModule(id); // config changes take effect without a manual restart + } else { + const enabled = Boolean(body.enabled); + modulesConfig[id] = { ...moduleState(id), enabled }; + writeJson(MODULES_CONFIG_PATH, modulesConfig); + remountModule(id); // mounts when enabled, unmounts (only) otherwise + } + broadcastShellEvent('modules-changed', { id }); + return sendJson(res, 200, { ok: true, module: adminModuleView(id) }); + } + + sendJson(res, 404, { error: 'Unknown admin endpoint.' }); +} + /* ── module API dispatch ────────────────────────────────────────────── */ function dispatchModuleApi(req, res, id, subPath, search) { @@ -371,6 +535,31 @@ function handleRequest(req, res) { return sendJson(res, 200, { modules: list }); } + /* — shell events: open dashboards learn about admin changes live — */ + if (urlPath === '/api/events' && req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write('retry: 3000\n\n'); + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + shellStreams.add(res); + req.on('close', () => shellStreams.delete(res)); + return; + } + + /* — admin API — */ + if (urlPath.startsWith('/api/admin/')) { + handleAdminApi(req, res, urlPath).catch((err) => { + console.error('[proddash] admin API error:', err); + if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' }); + else try { res.end(); } catch { /* gone */ } + }); + return; + } + /* — module APIs: /api/modules//… (any method; modules decide) — */ const apiMatch = urlPath.match(/^\/api\/modules\/([^/]+)(\/.*)?$/); if (apiMatch) {