/* 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'); const availableSection = document.getElementById('available-section'); const availableList = document.getElementById('available-list'); const catalogStatus = document.getElementById('catalog-status'); const updateCard = document.getElementById('update-card'); const updateTitle = document.getElementById('update-title'); const updateCopy = document.getElementById('update-copy'); const updateStatus = document.getElementById('update-status'); const updateBtn = document.getElementById('update-btn'); const updateChanges = document.getElementById('update-changes'); const shellVersionEl = document.getElementById('shell-version'); const themeSection = document.getElementById('theme-section'); const themeSwatches = document.getElementById('theme-swatches'); const themeStatus = document.getElementById('theme-status'); /** The shell version the page loaded with — the update flow watches it change. */ let shellVersion = ''; 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; availableSection.hidden = true; layoutsSection.hidden = true; updateCard.hidden = true; themeSection.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, moduleId) { const type = spec?.type || 'string'; const label = spec?.label || key; const field = document.createElement('label'); field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : ''); let read; // For boolean/switch fields, the control other fields may depend on // (showWhen) — returned so the caller can wire visibility. let control = null; // For selects: the element (optionsDependsOn sources) and, with an // optionsRoute, a function that (re)loads the options. let selectEl = null; let refetch = null; if (type === 'switch') { // Same toggle as the module enable switch, inline with its label. const toggle = document.createElement('span'); toggle.className = 'switch'; const input = document.createElement('input'); input.type = 'checkbox'; input.checked = Boolean(value); const track = document.createElement('span'); track.className = 'track'; toggle.append(input, track); const text = document.createElement('span'); text.className = 'switch-label'; text.textContent = label; field.append(toggle, text); read = () => input.checked; control = input; } else if (type === 'boolean') { const input = document.createElement('input'); input.type = 'checkbox'; input.checked = Boolean(value); field.append(input, document.createTextNode(label)); read = () => input.checked; control = input; } else if (type === 'endpoint') { // One consistent "Host / IP : Port" control for every module's upstream. const span = document.createElement('span'); span.textContent = label; const row = document.createElement('span'); row.className = 'endpoint-row'; const host = document.createElement('input'); host.type = 'text'; host.className = 'ep-host'; host.placeholder = 'Host / IP'; host.autocomplete = 'off'; host.spellcheck = false; host.value = value?.host ? String(value.host) : ''; const sep = document.createElement('span'); sep.className = 'ep-sep'; sep.textContent = ':'; const port = document.createElement('input'); port.type = 'number'; port.className = 'ep-port'; port.placeholder = 'Port'; port.min = '1'; port.max = '65535'; port.value = value?.port ? String(value.port) : ''; row.append(host, sep, port); field.append(span, row); read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 }); } else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) { const span = document.createElement('span'); span.textContent = label; const select = document.createElement('select'); const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt); const fill = (options) => { // Keep what the admin has picked but not yet saved across a refill // (a dependent select refetching), else the saved value. const saved = String(value ?? ''); const wanted = select.options.length ? select.value : saved; select.innerHTML = ''; // The saved value must stay selectable even when it isn't among the // live options (device unplugged, module route down). if (saved && !options.some((opt) => optValue(opt) === saved)) { options = [{ value: saved, label: `${saved} (saved)` }, ...options]; } // Nothing saved yet → a blank "choose" entry, unless the module already // offers a blank option of its own (an "Automatic" default, say). if (!saved && !options.some((opt) => optValue(opt) === '')) { options = [{ value: '', label: '— choose —' }, ...options]; } // Options may carry a `group` (a folder path, a device class): options // sharing one render under a single , in first-seen order. const groups = new Map(); for (const opt of options) { const o = document.createElement('option'); o.value = optValue(opt); o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); const g = typeof opt === 'object' && opt.group ? String(opt.group) : ''; if (!g) { select.appendChild(o); continue; } if (!groups.has(g)) { const og = document.createElement('optgroup'); og.label = g; groups.set(g, og); select.appendChild(og); } groups.get(g).appendChild(o); } select.value = options.some((opt) => optValue(opt) === wanted) ? wanted : saved; }; fill(Array.isArray(spec.options) ? spec.options : []); if (spec.optionsRoute && moduleId) { // Options discovered at runtime (audio devices, ports, sources…): // the module serves them from one of its own routes. With // optionsDependsOn the caller triggers the fetch (and re-fetches when // that other field changes), passing its value as a query parameter. refetch = (query) => { const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : ''; return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`) .then((res) => (res.ok ? res.json() : null)) .then((body) => { if (Array.isArray(body?.options)) fill(body.options); }) .catch(() => { /* keep the static/saved options */ }); }; if (!spec.optionsDependsOn) refetch(); } field.append(span, select); read = () => select.value; selectEl = select; } else if (type === 'color') { // A swatch picker; the value is always a #rrggbb string. field.classList.add('color-field'); const span = document.createElement('span'); span.textContent = label; const input = document.createElement('input'); input.type = 'color'; const hex = (v, fb) => (/^#[0-9a-fA-F]{6}$/.test(String(v ?? '').trim()) ? String(v).trim().toLowerCase() : fb); input.value = hex(value, hex(spec?.default, '#2ee59a')); field.append(span, input); read = () => input.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); } if (spec?.help) { // One line of guidance under the control (allowed placeholders, where a // value comes from…) — the label stays short. const help = document.createElement('small'); help.className = 'field-help'; help.textContent = spec.help; field.appendChild(help); } return { field, read, control, selectEl, refetch }; } /** * Does a config group have anything entered? Drives `collapsed: "whenSet"`: * a credentials block stays folded once it holds a saved secret or a value * other than the schema default, and opens by itself while still empty. */ function groupHasValues(fields, mod) { return fields.some(({ key, spec }) => { const type = spec?.type || 'string'; if (type === 'password') return Boolean(mod.passwordSet[key]); if (type === 'boolean' || type === 'switch') return false; const v = mod.config[key]; if (type === 'endpoint') return Boolean(v?.host); if (v === undefined || v === null || String(v) === '') return false; return !('default' in (spec || {})) || String(v) !== String(spec.default); }); } /* ── group title row: the ⓘ and the group toggle ────────────────────── */ let groupInfoSeq = 0; function closeGroupInfos(except) { for (const open of document.querySelectorAll('.group-info.is-open')) { if (open === except) continue; open.classList.remove('is-open'); open.querySelector('.group-info-btn')?.setAttribute('aria-expanded', 'false'); } } /** * configGroups[name].help as an ⓘ beside the group title: click toggles a * small popover with the text (hover and keyboard focus show it too), so a * group can carry a paragraph without pushing its fields down the page. A * real button, labelled, so it works from the keyboard; inside a * it must never fold the group. */ function groupInfo(groupName, text) { const wrap = document.createElement('span'); wrap.className = 'group-info'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'group-info-btn'; btn.textContent = 'i'; btn.setAttribute('aria-label', `About ${groupName}`); btn.setAttribute('aria-expanded', 'false'); const pop = document.createElement('span'); pop.className = 'group-info-pop'; pop.id = `group-info-${++groupInfoSeq}`; pop.setAttribute('role', 'note'); pop.textContent = text; btn.setAttribute('aria-describedby', pop.id); wrap.append(btn, pop); const setOpen = (open) => { wrap.classList.toggle('is-open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); }; btn.addEventListener('click', (e) => { e.preventDefault(); // a click in a would otherwise fold the group e.stopPropagation(); const open = !wrap.classList.contains('is-open'); closeGroupInfos(wrap); setOpen(open); }); pop.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }); wrap.addEventListener('keydown', (e) => { // Enter / Space activate the button (a click); they must not reach a // above as well. Escape closes and hands focus back. if (e.key === ' ' || e.key === 'Enter') e.stopPropagation(); if (e.key === 'Escape' && wrap.classList.contains('is-open')) { e.stopPropagation(); setOpen(false); btn.focus(); } }); return wrap; } // A click anywhere else, or Escape, closes an open popover. document.addEventListener('pointerdown', (e) => { if (!(e.target instanceof Element) || !e.target.closest('.group-info')) closeGroupInfos(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeGroupInfos(); }); /** * configGroups[name].toggle: the named switch/boolean field is the group's * on/off. It moves into the title row, right-aligned, and the body dims * while it is off (fields stay editable). The field itself is untouched — * it is still read on save and still drives showWhen dependents. */ function mountGroupToggle(group, field, control) { const slot = document.createElement('span'); slot.className = 'field-group-toggle'; slot.appendChild(field); // Inside a , flipping the switch must not fold the group. slot.addEventListener('click', (e) => e.stopPropagation()); slot.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') e.stopPropagation(); }); group.title.appendChild(slot); group.el.classList.add('has-toggle'); const sync = () => group.el.classList.toggle('is-off', !control.checked); control.addEventListener('change', sync); sync(); } 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 source = document.createElement('span'); source.className = 'module-source'; source.textContent = mod.source === 'installed' ? 'installed' : 'bundled'; source.title = mod.source === 'installed' ? 'Installed from the repo into the data directory' : 'Ships with this copy of ProdDash'; const spacer = document.createElement('span'); spacer.className = 'spacer'; // filled by the catalog when the repo has a newer version const updateSlot = document.createElement('span'); updateSlot.className = 'module-update-slot'; const uninstall = document.createElement('button'); uninstall.className = 'btn small danger'; uninstall.textContent = 'Uninstall'; uninstall.title = mod.source === 'installed' ? 'Remove this module\'s files from the data directory' : 'Hide this bundled module from every dashboard (its folder stays with the app)'; uninstall.addEventListener('click', async () => { const what = mod.source === 'installed' ? `Uninstall ${mod.name}? Its files are removed from the data directory. Its settings are kept in case you install it again.` : `Uninstall ${mod.name}? It disappears from every dashboard and the picker. It ships with ProdDash, so it can be brought back from "Available modules" any time.`; if (!window.confirm(what)) return; uninstall.disabled = true; try { await api(`/api/admin/modules/${encodeURIComponent(mod.id)}`, { method: 'DELETE' }); toast(`${mod.name} uninstalled`); await loadState(); } catch (e) { uninstall.disabled = false; if (e.authRequired) showLogin(); else toast(e.message, true); } }); 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, source, spacer, updateSlot, toggleLabel, uninstall); card.appendChild(head); const desc = document.createElement('div'); desc.className = 'module-desc'; desc.textContent = mod.description; card.appendChild(desc); // The module's own words about its upstream ("polling timers on // 10.3.11.20:1025") — a readout, so it sits in a bordered monospace box // and the state (ok / connecting / error) is its text colour. const healthEl = document.createElement('div'); healthEl.className = 'module-status ' + healthClass(mod); healthEl.textContent = healthText(mod); card.appendChild(healthEl); const mountError = document.createElement('div'); mountError.className = 'module-mount-error'; mountError.hidden = !mod.mountError && !mod.incompatible; mountError.textContent = mod.incompatible ? `Not loaded — ${mod.incompatible}. Update ProdDash from the banner above.` : (mod.mountError ? `Failed to start: ${mod.mountError}` : ''); card.appendChild(mountError); liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError, updateSlot }); 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(); const controls = new Map(); // key → boolean/switch input (for showWhen) const selects = new Map(); // key → select element (optionsDependsOn sources) const dependents = []; // { field, when } to show/hide by a control's state const optionDeps = []; // { refetch, on } selects whose options follow another field const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list) const collapsibles = []; // { el, meta, fields } groups that fold // Fields render in schema order; a `group` starts a labeled subsection // and consecutive same-group fields share it. The manifest's optional // `configGroups[name]` adds { collapsed, columns, help, toggle } for // that group: help is an ⓘ beside the title, toggle names the switch // that is the group's on/off and lives in the title row. const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {}; let groupName = null; let container = form; let currentGroup = null; for (const [key, spec] of schemaKeys) { const g = spec?.group || ''; if (g !== groupName) { groupName = g; currentGroup = null; if (g) { const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {}; const folds = meta.collapsed !== undefined; const group = document.createElement(folds ? 'details' : 'div'); group.className = 'field-group'; const title = document.createElement(folds ? 'summary' : 'div'); title.className = 'field-group-title'; const titleName = document.createElement('span'); titleName.className = 'field-group-name'; titleName.textContent = g; title.appendChild(titleName); if (meta.help) title.appendChild(groupInfo(g, String(meta.help))); group.appendChild(title); const body = document.createElement('div'); body.className = 'field-group-body'; const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0); if (cols > 1) { body.classList.add('is-columns'); body.style.setProperty('--cols', String(cols)); } group.appendChild(body); form.appendChild(group); container = body; currentGroup = { el: group, title, meta, fields: [], toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '', }; if (folds) collapsibles.push(currentGroup); } else { container = form; } } const { field, read, control, selectEl, refetch } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id); readers.set(key, read); if (control) controls.set(key, control); if (selectEl) selects.set(key, selectEl); if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn }); else if (refetch) reloaders.push(() => refetch()); if (spec?.showWhen) dependents.push({ field, when: spec.showWhen }); if (currentGroup) currentGroup.fields.push({ key, spec }); if (currentGroup && control && key === currentGroup.toggleKey) { // The group's on/off: title row, not the body (never both). A // toggle naming a non-boolean field is ignored and renders normally. mountGroupToggle(currentGroup, field, control); } else { container.appendChild(field); } } // A field with showWhen:"K" is visible only while K's toggle is on. for (const dep of dependents) { const ctl = controls.get(dep.when); if (!ctl) continue; const sync = () => { dep.field.hidden = !ctl.checked; }; ctl.addEventListener('change', sync); sync(); } // A select with optionsDependsOn:"K" loads its options with K's current // value as a query parameter, now and whenever K changes (a plan list // that follows the chosen service type). for (const dep of optionDeps) { const src = selects.get(dep.on) || controls.get(dep.on); const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) }); if (src) src.addEventListener('change', run); reloaders.push(run); run(); } // Collapsible groups: `collapsed: true` starts folded, `false` open, // "whenSet" folded only once the group already holds values. for (const grp of collapsibles) { const c = grp.meta.collapsed; grp.el.open = !(c === true || (c === 'whenSet' && groupHasValues(grp.fields, mod))); } const actions = document.createElement('div'); actions.className = 'module-actions'; const save = document.createElement('button'); const status = document.createElement('span'); status.className = 'admin-status'; actions.append(status, save); // A card is a status line until someone wants to change something: the // form starts folded, and the row at the bottom holds one button that // does both jobs — "Configure" opens the form and becomes "Apply", which // saves without closing it. The name is the other way in and out. card.appendChild(form); card.appendChild(actions); const openKey = 'proddash:admin:open:' + mod.id; const setOpen = (open) => { form.hidden = !open; status.hidden = !open; save.textContent = open ? 'Apply' : 'Configure'; save.className = open ? 'btn primary' : 'btn'; try { sessionStorage.setItem(openKey, open ? '1' : '0'); } catch { /* fine */ } }; let wasOpen = false; try { wasOpen = sessionStorage.getItem(openKey) === '1'; } catch { /* fine */ } setOpen(wasOpen); name.classList.add('is-toggle'); name.title = 'Show or hide this module\'s settings'; name.tabIndex = 0; name.setAttribute('role', 'button'); name.addEventListener('click', () => setOpen(form.hidden)); name.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(form.hidden); } }); save.addEventListener('click', async () => { if (form.hidden) return setOpen(true); const config = {}; for (const [key, read] of readers) config[key] = read(); save.disabled = true; status.textContent = 'Applying…'; status.className = 'admin-status'; try { await api(`/api/admin/modules/${encodeURIComponent(mod.id)}/config`, { method: 'PUT', body: JSON.stringify({ config }), }); status.textContent = 'Applied — module re-initialised. Open tiles reconnect on their own.'; status.className = 'admin-status ok'; refreshStatus(); // What the module can list may have changed (credentials just // saved → the upstream is reachable now): reload the dynamic selects. // The re-init is quick but not instant; give it a beat. setTimeout(() => { for (const reload of reloaders) reload(); }, 800); } 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-status ' + healthClass(mod); if (!mod.incompatible) { 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'; } } } /* ── saved 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 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; availableSection.hidden = false; shellVersion = String(state.version || ''); shellVersionEl.textContent = shellVersion ? 'v' + shellVersion : ''; const storageNote = document.getElementById('storage-note'); if (state.dataDir) { document.getElementById('storage-path').textContent = state.dataDir; storageNote.hidden = false; } modulesList.innerHTML = ''; liveEls.clear(); for (const mod of state.modules || []) { modulesList.appendChild(buildModuleCard(mod)); } loadTheme(); loadLayouts(); loadCatalog(false); } /* ── theme ──────────────────────────────────────────────────────────── */ /* The dashboard palette is a server-wide choice. The server lists the presets (GET /api/theme — public, dashboards need it too) and the admin page shows them as swatches; picking one POSTs it, applies it here at once, and the server's `theme` shell event switches every open dashboard — and any other admin page — live. The palettes themselves are html[data-theme="…"] variable sets in the shell's style.css, which this page shares, so the admin page follows its own choice. */ const LS_THEME = 'proddash:theme'; let themeList = []; let currentTheme = ''; function applyTheme(id) { const theme = String(id || ''); if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return; document.documentElement.dataset.theme = theme; try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ } } // The last-known theme first (shared with the dashboard, same origin), so // the page doesn't flash the default palette before the server answers. try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ } function themeName(id) { return themeList.find((t) => t.id === id)?.name || id; } function renderThemes() { themeSwatches.innerHTML = ''; for (const t of themeList) { const current = t.id === currentTheme; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'theme-swatch' + (current ? ' is-current' : ''); btn.setAttribute('role', 'radio'); btn.setAttribute('aria-checked', current ? 'true' : 'false'); btn.title = current ? `${t.name} — the current theme` : `Switch every dashboard to ${t.name}`; const preview = document.createElement('span'); preview.className = 'theme-preview'; preview.style.setProperty('--sw-bg', String(t.colors?.bg || '#000')); preview.style.setProperty('--sw-panel', String(t.colors?.panel || '#222')); preview.style.setProperty('--sw-accent', String(t.colors?.accent || '#fff')); const name = document.createElement('span'); name.className = 'theme-name'; name.textContent = t.name; btn.append(preview, name); btn.addEventListener('click', () => chooseTheme(t.id)); themeSwatches.appendChild(btn); } } /** * Fetch the server's theme and list. Applied to this page either way (the * endpoint is public, so even the login card wears the right palette); the * swatch section itself is only revealed for an unlocked page. */ async function loadTheme(reveal = true) { let body; try { body = await api('/api/theme'); } catch { if (reveal) themeSection.hidden = true; // a server without themes — nothing to offer return; } themeList = Array.isArray(body.themes) ? body.themes.filter((t) => t && t.id && t.name) : []; currentTheme = String(body.theme || ''); applyTheme(currentTheme); renderThemes(); if (reveal) themeSection.hidden = !themeList.length; } loadTheme(false); async function chooseTheme(id) { if (id === currentTheme) return; const previous = currentTheme; currentTheme = id; applyTheme(id); // at once, here — the server's event confirms it everywhere else renderThemes(); themeStatus.textContent = ''; themeStatus.className = 'admin-status'; try { await api('/api/admin/theme', { method: 'POST', body: JSON.stringify({ theme: id }) }); themeStatus.textContent = `${themeName(id)} — every open dashboard switched.`; themeStatus.className = 'admin-status ok'; } catch (e) { currentTheme = previous; applyTheme(previous); renderThemes(); themeStatus.textContent = e.message; themeStatus.className = 'admin-status error'; if (e.authRequired) showLogin(); } } // Follow a theme change made elsewhere (another admin page) live. let themeEvents = null; let themeEventsRetry = null; function subscribeThemeEvents() { try { themeEvents?.close(); } catch { /* not open */ } themeEvents = new EventSource('/api/events'); themeEvents.addEventListener('theme', (e) => { let theme = ''; try { theme = String(JSON.parse(e.data).theme || ''); } catch { return; } if (!theme || theme === currentTheme) return; currentTheme = theme; applyTheme(theme); renderThemes(); }); themeEvents.onerror = () => { if (themeEvents.readyState === EventSource.CLOSED) { clearTimeout(themeEventsRetry); themeEventsRetry = setTimeout(subscribeThemeEvents, 4000); } }; } subscribeThemeEvents(); /* ── the repo: available modules, module updates, shell update ──────── */ function fmtTime(ms) { return ms ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; } async function loadCatalog(refresh) { catalogStatus.className = 'admin-status'; catalogStatus.textContent = refresh ? 'Checking the repo…' : 'Checking…'; let cat; try { cat = await api('/api/admin/catalog' + (refresh ? '?refresh=1' : '')); } catch (e) { if (e.authRequired) return showLogin(); catalogStatus.className = 'admin-status error'; catalogStatus.textContent = e.message; return; } renderShell(cat.shell || {}); renderAvailable(cat); } function renderShell(shell) { const remote = shell.remote || {}; if (remote.error) { catalogStatus.className = 'admin-status error'; catalogStatus.textContent = `Couldn't reach ${shell.repo}: ${remote.error}`; } else { catalogStatus.className = 'admin-status'; catalogStatus.textContent = `${shell.repo}@${shell.branch}` + (remote.version ? ` · latest ProdDash ${remote.version}` : '') + (remote.checkedAt ? ` · checked ${fmtTime(remote.checkedAt)}` : ''); } if (!shell.updateAvailable) { updateCard.hidden = true; return; } updateCard.hidden = false; updateTitle.textContent = shell.versionBehind ? `ProdDash ${remote.version} is available` : `New commits on ${shell.branch}`; const where = shell.local?.isGit ? `a git checkout of ${shell.local.branch} at ${String(shell.local.sha || '').slice(0, 7)}; updating runs git pull` : 'not a git checkout; updating unpacks the repo archive over the app folder'; updateCopy.textContent = `You're running ProdDash ${shell.version} (${where}). Settings and layouts live outside the app folder and are not touched.`; updateChanges.href = shell.changesUrl || '#'; updateBtn.disabled = Boolean(shell.updateBlocker) || updateBtn.dataset.busy === '1'; if (shell.updateBlocker) { updateStatus.className = 'admin-status error'; updateStatus.textContent = `Can't update from here: ${shell.updateBlocker}.`; } else if (updateBtn.dataset.busy !== '1') { updateStatus.className = 'admin-status'; updateStatus.textContent = 'Updating restarts ProdDash; open dashboards reconnect on their own.'; } } function renderAvailable(cat) { availableList.innerHTML = ''; const updates = cat.updates || []; // newer versions of installed modules → a button on their card for (const els of liveEls.values()) els.updateSlot.innerHTML = ''; for (const up of updates) { const els = liveEls.get(up.id); if (!els) continue; const btn = document.createElement('button'); btn.className = 'btn small primary'; btn.textContent = `Update to v${up.version}`; btn.disabled = !up.compatible; btn.title = up.compatible ? `Download v${up.version} from the repo` : up.reason; btn.addEventListener('click', () => installFromCatalog(up, btn)); els.updateSlot.appendChild(btn); } const available = cat.available || []; if (!available.length) { const none = document.createElement('div'); none.className = 'layouts-empty'; none.textContent = cat.shell?.remote?.error ? 'The repo could not be reached, and nothing bundled is waiting to be installed.' : 'Everything the repo offers is installed.'; availableList.appendChild(none); return; } for (const item of available) { const row = document.createElement('div'); row.className = 'catalog-row'; const main = document.createElement('div'); main.className = 'catalog-main'; const title = document.createElement('div'); title.className = 'catalog-title'; const name = document.createElement('span'); name.className = 'module-name'; name.textContent = item.name; const version = document.createElement('span'); version.className = 'module-version'; version.textContent = item.version ? 'v' + item.version : ''; const source = document.createElement('span'); source.className = 'module-source'; source.textContent = item.source === 'bundled' ? 'bundled' : 'repo'; source.title = item.source === 'bundled' ? 'Ships with this copy of ProdDash — restoring it downloads nothing' : 'Downloaded from the repo'; title.append(name, version, source); const desc = document.createElement('div'); desc.className = 'module-desc'; desc.textContent = item.description || ''; const req = document.createElement('div'); req.className = 'module-health' + (item.compatible ? '' : ' error'); req.textContent = item.compatible ? (item.requires ? `Requires ProdDash ${item.requires}` : '') : item.reason; main.append(title, desc, req); const btn = document.createElement('button'); btn.className = 'btn small primary'; btn.textContent = 'Install'; btn.disabled = !item.compatible; btn.title = item.compatible ? '' : item.reason; btn.addEventListener('click', () => installFromCatalog(item, btn)); row.append(main, btn); availableList.appendChild(row); } } async function installFromCatalog(item, btn) { btn.disabled = true; const label = btn.textContent; btn.textContent = 'Installing…'; try { const r = await api(`/api/admin/modules/${encodeURIComponent(item.id)}/install`, { method: 'POST', body: '{}' }); toast(`${item.name} ${r.downloaded ? 'installed' : 'restored'}${r.version ? ' (v' + r.version + ')' : ''}`); await loadState(); } catch (e) { btn.disabled = false; btn.textContent = label; if (e.authRequired) showLogin(); else toast(e.message, true); } } document.getElementById('catalog-refresh').addEventListener('click', () => loadCatalog(true)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** After the update the server exits and comes back; wait for it. */ async function waitForRestart(before) { const started = Date.now(); let sawDown = false; while (Date.now() - started < 90000) { await sleep(1500); try { const s = await api('/api/admin/state'); if (sawDown || s.version !== before) { toast(`ProdDash ${s.version} is running`); await sleep(600); location.reload(); return; } } catch (e) { if (e.status === 401) { location.reload(); return; } // back up, passcode wanted again sawDown = true; } } updateStatus.className = 'admin-status error'; updateStatus.textContent = 'ProdDash did not come back on its own — start it again on the server, then reload this page.'; updateBtn.dataset.busy = ''; updateBtn.disabled = false; } updateBtn.addEventListener('click', async () => { if (!window.confirm('Update ProdDash now? The server restarts and every open dashboard reconnects a few seconds later.')) return; updateBtn.dataset.busy = '1'; updateBtn.disabled = true; updateStatus.className = 'admin-status'; updateStatus.textContent = 'Updating…'; const before = shellVersion; try { const r = await api('/api/admin/update', { method: 'POST', body: '{}' }); updateStatus.textContent = `Updated ${r.from} → ${r.to || 'latest commit'} via ${r.method}. Restarting…`; } catch (e) { updateStatus.className = 'admin-status error'; updateStatus.textContent = e.message; updateBtn.dataset.busy = ''; updateBtn.disabled = false; if (e.authRequired) showLogin(); return; } await waitForRestart(before); }); setInterval(() => { if (modulesSection.hidden) return; refreshStatus(); }, 5000); loadState();