Files
Waters ChurchandClaude Opus 5 7bb1131378 PCO Plan 1.10.0: follow several service types in priority order, and read any of them from any dashboard
An account runs more than one service type — a Sunday morning, a Wednesday
night, a youth service, a second campus — and until now that meant one
ProdDash per type. The module now follows as many as are configured.

Admin picks them in priority order. Shell 1.12.0 gives `multiselect` an
`orderable: true` that stops being a ticklist: the picks sit in a numbered
list with a nudge either way and a remove, over a dropdown holding what is
left. Forty switches to read past is not a picker, and a ticklist cannot
carry an order anyway. The order is the setting — when two types have a plan
at the same date and time, the one higher up wins.

The server gathers candidate plans from every ticked type and tiers them:
today's plan first, then the next upcoming, then the most recent past, with
the configured order breaking a tie. A type that fails is skipped rather
than failing the read, unless it is an auth or rate-limit answer, which
belongs to the whole connection. The types not being followed are read on a
slower lane (45 s) into `state.others`, so they are there to look at without
a second connection.

Every tile's title bar carries a service-type control. Picking one moves
every PCO Plan tile on that browser's dashboard — plan, teams and song key
together — onto its plan, over a small page bus in `viewed-type.js`; the
choice is per browser and survives a reload. The control hides itself when
only one type is configured.

Live machinery stays with the type being followed: another type's plan shows
its running order with no now marker, no AbleSet and no service clock — it
shows that plan's own scheduled time instead, since borrowing the running
clock of a different service is exactly the misreading the amber top-bar
stripe exists to prevent.

Old configs carry `serviceTypeId`; it is read once into the new array and
saved, and plan pins still resolve bare, so nothing has to be re-entered.

`tools/pco-mock.js` takes `--types N` to pad the list out, for trying the
picker against an account with dozens of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 14:32:19 -04:00

1593 lines
64 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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. Behind the admin PIN when one is set — a shell setting of
its own, nothing to do with a module's PIN (Slack's send PIN, say). */
const securitySection = document.getElementById('security-section');
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');
const alertsSection = document.getElementById('alerts-section');
const alertLevelsEl = document.getElementById('alert-levels');
const alertsStatus = document.getElementById('alerts-status');
const alertTestChannel = document.getElementById('alert-test-channel');
/** 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;
alertsSection.hidden = true;
securitySection.hidden = true;
passcodeInput.value = '';
passcodeInput.focus();
}
let loggingIn = false;
async function login() {
if (loggingIn) return;
loggingIn = true;
loginStatus.textContent = '';
loginStatus.className = 'admin-status';
try {
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ pin: passcodeInput.value }) });
passcodeInput.value = '';
loginCard.hidden = true;
await loadState();
} catch (e) {
passcodeInput.value = '';
loginStatus.textContent = e.message;
loginStatus.className = 'admin-status error';
passcodeInput.focus();
} finally {
loggingIn = false;
}
}
document.getElementById('login-btn').addEventListener('click', login);
passcodeInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') login();
});
// Six digits is the whole PIN, so there is nothing left to press.
passcodeInput.addEventListener('input', () => {
passcodeInput.value = passcodeInput.value.replace(/\D/g, '').slice(0, 6);
if (passcodeInput.value.length === 6) login();
});
/* ── the admin PIN ───────────────────────────────────────────────────── */
const pinCurrentField = document.getElementById('pin-current-field');
const pinCurrent = document.getElementById('pin-current');
const pinNew = document.getElementById('pin-new');
const pinRepeat = document.getElementById('pin-repeat');
const pinSaveBtn = document.getElementById('pin-save');
const pinClearBtn = document.getElementById('pin-clear');
const pinStatus = document.getElementById('pin-status');
const pinStateEl = document.getElementById('pin-state');
for (const input of [pinCurrent, pinNew, pinRepeat]) {
input.addEventListener('input', () => {
input.value = input.value.replace(/\D/g, '').slice(0, 6);
});
}
/** Paint the section for "no PIN yet" or "one is set". */
function renderPin(state) {
const set = Boolean(state.pinSet);
const fromEnv = Boolean(state.pinFromEnv);
securitySection.hidden = false;
pinCurrentField.hidden = !set;
pinClearBtn.hidden = !set;
document.getElementById('pin-new-label').textContent = set ? 'New PIN' : 'PIN';
pinSaveBtn.textContent = set ? 'Change PIN' : 'Set PIN';
pinStateEl.textContent = fromEnv
? 'A passcode is set in this server’s environment (PRODDASH_PASSCODE) and takes precedence. Anything set here applies once that is removed.'
: set
? 'Admin is locked. This page asks for the PIN, and so does every admin request.'
: 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.';
}
function pinMessage(text, bad = false) {
pinStatus.textContent = text;
pinStatus.className = 'admin-status' + (bad ? ' error' : ' ok');
}
async function savePin(clearing = false) {
pinStatus.textContent = '';
pinStatus.className = 'admin-status';
const current = pinCurrent.value;
const next = clearing ? '' : pinNew.value;
if (!clearing) {
if (!/^\d{6}$/.test(next)) return pinMessage('The PIN must be exactly 6 digits.', true);
if (next !== pinRepeat.value) return pinMessage('The two PINs are not the same.', true);
}
if (!pinCurrentField.hidden && !/^\d{6}$/.test(current)) {
return pinMessage('Enter the current PIN to change it.', true);
}
pinSaveBtn.disabled = true;
pinClearBtn.disabled = true;
try {
const body = await api('/api/admin/pin', { method: 'POST', body: JSON.stringify({ pin: next, current }) });
for (const i of [pinCurrent, pinNew, pinRepeat]) i.value = '';
pinMessage(body.pinSet ? 'PIN saved. It is asked for from the next visit.' : 'PIN removed — admin is open again.');
await loadState();
} catch (e) {
pinMessage(e.message, true);
} finally {
pinSaveBtn.disabled = false;
pinClearBtn.disabled = false;
}
}
pinSaveBtn.addEventListener('click', () => savePin(false));
pinClearBtn.addEventListener('click', () => {
if (confirm('Turn the admin PIN off? Anyone on the network could then change this server’s settings.')) savePin(true);
});
pinRepeat.addEventListener('keydown', (e) => { if (e.key === 'Enter') savePin(false); });
/* ── 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 <optgroup>, 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 === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) {
// Several picks from one list — a switch per option, stored as an array
// of values. A saved value the list no longer offers stays checked and
// marked, so a channel the account has left isn't dropped in silence.
field.classList.add('multiselect-field');
// The wrapper is a <label>; with several controls inside, a click on the
// wrapper would activate the FIRST one. `for=""` makes it label nothing.
field.htmlFor = '';
const span = document.createElement('span');
span.textContent = label;
const list = document.createElement('div');
list.className = 'multiselect';
// Kept as an ORDER, not a set: with `orderable` the position is the
// value (a priority), and even without it the saved order is what the
// shell stores, so losing it here would quietly reshuffle a module's list.
let chosen = (Array.isArray(value) ? value : []).map(String).filter(Boolean);
const orderable = Boolean(spec.orderable);
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
const syncChosen = () => {
for (const input of list.querySelectorAll('input')) {
const v = input.value;
const at = chosen.indexOf(v);
if (input.checked && at < 0) chosen.push(v); // newly on: lowest priority
else if (!input.checked && at >= 0) chosen.splice(at, 1);
}
};
const move = (v, by) => {
const at = chosen.indexOf(v);
const to = at + by;
if (at < 0 || to < 0 || to >= chosen.length) return;
chosen.splice(to, 0, chosen.splice(at, 1)[0]);
};
const fill = (options) => {
syncChosen();
list.innerHTML = '';
const missing = chosen.filter((v) => !options.some((opt) => optValue(opt) === v));
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
for (const opt of all) {
const row = document.createElement('div');
row.className = 'multiselect-row';
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.value = optValue(opt);
input.checked = chosen.includes(input.value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = optLabel(opt);
row.append(toggle, text);
row.addEventListener('click', (e) => {
if (e.target === input) return; // the box toggles itself
input.checked = !input.checked;
input.dispatchEvent(new Event('change', { bubbles: true }));
});
list.appendChild(row);
}
if (!all.length) {
const none = document.createElement('div');
none.className = 'multiselect-empty';
none.textContent = 'Nothing to choose from yet.';
list.appendChild(none);
}
};
// A list built on purpose, in an order that means something: the picks,
// each numbered and movable, and a dropdown holding everything not picked
// yet. Switches cannot carry an order — and an account with forty service
// types is forty switches to read past before finding the two that matter.
const fillPicked = (options) => {
list.innerHTML = '';
const byValue = new Map(options.map((opt) => [optValue(opt), opt]));
chosen.forEach((v, i) => {
const opt = byValue.get(v);
const row = document.createElement('div');
row.className = 'multiselect-row is-picked';
const rank = document.createElement('span');
rank.className = 'multiselect-rank';
rank.textContent = String(i + 1);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = opt ? optLabel(opt) : `${v} (saved — not listed now)`;
const btn = (glyph, title, on, off) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'multiselect-move';
b.textContent = glyph;
b.title = title;
b.disabled = Boolean(off);
b.addEventListener('click', (e) => { e.preventDefault(); on(); });
return b;
};
row.append(
rank,
text,
btn('▲', 'Higher priority', () => { move(v, -1); changed(); }, i === 0),
btn('▼', 'Lower priority', () => { move(v, 1); changed(); }, i === chosen.length - 1),
btn('✕', 'Remove', () => { chosen.splice(chosen.indexOf(v), 1); changed(); }),
);
list.appendChild(row);
});
if (!chosen.length) {
const none = document.createElement('div');
none.className = 'multiselect-empty';
none.textContent = 'Nothing chosen yet.';
list.appendChild(none);
}
// The adder. Its own change bubbles to the field, which is what any
// dependent list listens for, so nothing extra is dispatched here.
const add = document.createElement('select');
add.className = 'multiselect-add';
const left = options.filter((opt) => !chosen.includes(optValue(opt)));
const head = document.createElement('option');
head.value = '';
head.textContent = left.length
? (spec.addLabel || 'Add one…')
: (options.length ? 'Everything is added' : 'Nothing to choose from yet');
add.appendChild(head);
add.disabled = !left.length;
const groups = new Map();
for (const opt of left) {
const o = document.createElement('option');
o.value = optValue(opt);
o.textContent = optLabel(opt);
const g = typeof opt === 'object' && opt.group ? String(opt.group) : '';
if (!g) { add.appendChild(o); continue; }
if (!groups.has(g)) {
const og = document.createElement('optgroup');
og.label = g;
groups.set(g, og);
add.appendChild(og);
}
groups.get(g).appendChild(o);
}
add.addEventListener('change', () => {
if (!add.value) return;
chosen.push(add.value); // added last: lowest priority
redraw();
});
list.appendChild(add);
};
// The options last drawn, so a reorder or a pick can redraw without asking
// the module for its list again.
let lastOptions = Array.isArray(spec.options) ? spec.options : [];
const redraw = () => (orderable ? fillPicked(lastOptions) : fill(lastOptions));
// Moving or removing changes the value without any control firing, so say so.
const changed = () => {
redraw();
list.dispatchEvent(new Event('change', { bubbles: true }));
};
redraw();
if (spec.optionsRoute && moduleId) {
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)) {
lastOptions = body.options;
redraw();
}
})
.catch(() => { /* keep the static/saved options */ });
};
if (!spec.optionsDependsOn) refetch();
}
field.append(span, list);
read = () => {
// With `orderable` the list IS the value, in the order it is shown.
if (orderable) return [...chosen];
syncChosen();
return [...list.querySelectorAll('input:checked')].map((input) => input.value);
};
} 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 if (type === 'text') {
// A multi-line string — one item per line by convention (a list of
// presets, say). Stored as-is, newlines included.
const span = document.createElement('span');
span.textContent = label;
const textarea = document.createElement('textarea');
textarea.rows = 5;
textarea.spellcheck = false;
textarea.value = value === undefined || value === null ? '' : String(value);
field.append(span, textarea);
read = () => textarea.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 <summary>
* 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 <summary> 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
// <summary> 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 <summary>, 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 sources = new Map(); // key → { el, value() } for optionsDependsOn, whatever the type
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);
// Anything can be the source of an optionsDependsOn — a multiselect
// hands over its values joined, which is what a module's route reads.
sources.set(key, {
el: selectEl || control || field,
value: () => {
const v = read();
return Array.isArray(v) ? v.join(',') : (typeof v === 'boolean' ? String(v) : String(v ?? ''));
},
});
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 = sources.get(dep.on);
const run = () => dep.refetch({ [dep.on]: src ? src.value() : '' });
// A multiselect's switches are inside the field, so listen where they live.
if (src) src.el.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';
// Folded, the readout shares the bottom row with Configure instead of
// taking a line of its own; open, it goes back above the form.
if (open) card.insertBefore(healthEl, mountError);
else actions.insertBefore(healthEl, status);
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);
}
});
// The copy the last save replaced, one back. Restoring swaps the two,
// so a restore is itself one restore away from being undone.
const restore = document.createElement('button');
restore.className = 'btn';
restore.textContent = 'Restore previous';
restore.title = 'Put back the copy the last save replaced';
restore.hidden = !layout.hasPrevious;
restore.addEventListener('click', async () => {
if (!confirm(`Go back to the previous version of “${layout.name}”? The current one becomes its previous version, so this can be undone the same way.`)) return;
try {
await api(`/api/layouts/${encodeURIComponent(layout.name)}/restore`, { method: 'POST', body: '{}' });
toast('Previous version restored');
loadLayouts();
} catch (e) {
if (e.authRequired) showLogin();
else toast(e.message, true);
}
});
row.append(name, meta, restore, 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));
}
if (!(state.modules || []).length) {
// A packaged ProdDash starts like this: the catalog below is the way in.
const none = document.createElement('div');
none.className = 'layouts-empty';
none.textContent = 'Nothing installed yet — pick from Available modules below.';
modulesList.appendChild(none);
}
renderPin(state);
loadTheme();
loadAlerts();
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();
}
}
/* ── alerts ─────────────────────────────────────────────────────────── */
/* The two colours each alert level flashes between are a server-wide
choice, like the theme: GET /api/alerts lists them (public — dashboards
need them too), POST /api/admin/alerts saves them, and the server's
`alerts-config` shell event carries a change to every open dashboard and
any other admin page. The cadence per level is the shell's (style.css);
the swatch beside each row runs it, so a pair can be judged here before
it is tested on a real dashboard with the buttons below. */
const ALERT_LEVELS = [
{ id: 'low', name: 'Low', cadence: 'a slow fade between the two' },
{ id: 'medium', name: 'Medium', cadence: 'a faster fade' },
{ id: 'critical', name: 'Critical', cadence: 'no fade — it snaps between them' },
];
let alertColors = null; // level -> [a, b], as the server last said
const alertInputs = new Map(); // level -> [inputA, inputB]
function alertsMessage(text, bad = false) {
alertsStatus.textContent = text;
alertsStatus.className = 'admin-status' + (bad ? ' error' : text ? ' ok' : '');
}
function renderAlertLevels() {
alertLevelsEl.innerHTML = '';
alertInputs.clear();
for (const level of ALERT_LEVELS) {
const pair = alertColors?.[level.id] || ['#000000', '#000000'];
const row = document.createElement('div');
row.className = 'alert-level';
const preview = document.createElement('span');
preview.className = 'alert-preview ' + level.id;
preview.setAttribute('aria-hidden', 'true');
const name = document.createElement('div');
name.className = 'alert-level-name';
const strong = document.createElement('strong');
strong.textContent = level.name;
const cadence = document.createElement('span');
cadence.textContent = level.cadence;
name.append(strong, cadence);
const inputs = pair.map((value, i) => {
const input = document.createElement('input');
input.type = 'color';
input.value = value;
input.setAttribute('aria-label', `${level.name} colour ${i === 0 ? 'A' : 'B'}`);
input.title = `${level.name} — colour ${i === 0 ? 'A' : 'B'}`;
// The swatch follows the picker as it is dragged; the save waits for
// the picker to close, so a drag is one write rather than a hundred.
input.addEventListener('input', () => paintPreview());
input.addEventListener('change', () => saveAlertColors());
return input;
});
const paintPreview = () => {
preview.style.setProperty('--alert-a', inputs[0].value);
preview.style.setProperty('--alert-b', inputs[1].value);
};
paintPreview();
alertInputs.set(level.id, inputs);
row.append(preview, name, ...inputs);
alertLevelsEl.appendChild(row);
}
if (!alertTestChannel.options.length) {
for (let n = 1; n <= 6; n += 1) {
const opt = document.createElement('option');
opt.value = String(n);
opt.textContent = String(n);
alertTestChannel.appendChild(opt);
}
}
}
/** What the pickers say right now, in the server's shape. */
function readAlertColors() {
const out = {};
for (const [level, inputs] of alertInputs) out[level] = inputs.map((i) => i.value);
return out;
}
async function loadAlerts() {
let body;
try {
body = await api('/api/alerts');
} catch {
alertsSection.hidden = true; // a server without alerts — nothing to offer
return;
}
alertColors = body.colors || {};
renderAlertLevels();
alertsSection.hidden = false;
}
async function saveAlertColors() {
const colors = readAlertColors();
alertsMessage('');
try {
const body = await api('/api/admin/alerts', { method: 'POST', body: JSON.stringify({ colors }) });
alertColors = body.colors || colors;
alertsMessage('Saved — every open dashboard uses the new colours.');
} catch (e) {
alertsMessage(e.message, true);
if (e.authRequired) showLogin();
}
}
/** Another admin page changed the colours: follow, without yanking a picker out from under a hand. */
function applyAlertColorsEvent(colors) {
if (!colors || typeof colors !== 'object') return;
alertColors = colors;
for (const [level, inputs] of alertInputs) {
const pair = Array.isArray(colors[level]) ? colors[level] : null;
if (!pair) continue;
inputs.forEach((input, i) => {
if (document.activeElement !== input && /^#[0-9a-fA-F]{6}$/.test(String(pair[i] || ''))) input.value = pair[i];
});
inputs[0].dispatchEvent(new Event('input'));
}
}
for (const btn of document.querySelectorAll('.alert-test-btn')) {
btn.addEventListener('click', async () => {
const level = btn.dataset.level;
const channel = Number(alertTestChannel.value) || 1;
alertsMessage('');
btn.disabled = true;
try {
await api('/api/admin/alerts/test', { method: 'POST', body: JSON.stringify({ level, channel }) });
alertsMessage(`Test sent — dashboards listening on channel ${channel} are flashing ${level} for 8 seconds.`);
} catch (e) {
alertsMessage(e.message, true);
if (e.authRequired) showLogin();
} finally {
btn.disabled = false;
}
});
}
// Follow a theme or alert-colour 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.addEventListener('alerts-config', (e) => {
try { applyAlertColorsEvent(JSON.parse(e.data).colors); } catch { /* a malformed frame changes nothing */ }
});
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 || [];
installSelectedBtn.hidden = available.filter((a) => a.compatible).length < 2;
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));
if (item.compatible) {
const pick = document.createElement('input');
pick.type = 'checkbox';
pick.className = 'catalog-pick';
pick.dataset.id = item.id;
pick.dataset.name = item.name;
pick.title = 'Tick several, then Install selected';
row.append(pick);
}
row.append(main, btn);
availableList.appendChild(row);
}
}
const installSelectedBtn = document.getElementById('install-selected');
/** The ticked modules, in one request; the answer says which ones landed. */
async function installSelected() {
const picks = [...availableList.querySelectorAll('.catalog-pick:checked')];
if (!picks.length) return toast('Tick the modules to install first', true);
installSelectedBtn.disabled = true;
installSelectedBtn.textContent = `Installing ${picks.length}…`;
try {
const r = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: picks.map((p) => p.dataset.id) }) });
const done = (r.results || []).filter((x) => x.ok).length;
const failed = (r.results || []).filter((x) => !x.ok);
toast(failed.length
? `${done} installed; ${failed.map((f) => `${f.id}: ${f.error}`).join(' · ')}`
: `${done} module${done === 1 ? '' : 's'} installed`, failed.length > 0);
await loadState();
} catch (e) {
if (e.authRequired) showLogin();
else toast(e.message, true);
} finally {
installSelectedBtn.disabled = false;
installSelectedBtn.textContent = 'Install selected';
}
}
installSelectedBtn.addEventListener('click', installSelected);
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();