2026-08-30 15:16:09 +00:00
/* 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' );
2026-09-09 21:12:46 +00:00
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' );
2026-09-13 12:55:55 -04:00
const themeSection = document . getElementById ( 'theme-section' );
const themeSwatches = document . getElementById ( 'theme-swatches' );
const themeStatus = document . getElementById ( 'theme-status' );
2026-09-09 21:12:46 +00:00
/** The shell version the page loaded with — the update flow watches it change. */
let shellVersion = '' ;
2026-08-30 15:16:09 +00:00
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 ;
2026-09-09 21:12:46 +00:00
availableSection . hidden = true ;
2026-08-30 15:16:09 +00:00
layoutsSection . hidden = true ;
2026-09-09 21:12:46 +00:00
updateCard . hidden = true ;
2026-09-13 12:55:55 -04:00
themeSection . hidden = true ;
2026-08-30 15:16:09 +00:00
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).' ;
}
2026-09-10 15:31:12 -04:00
function fieldFor ( key , spec , value , passwordSet , moduleId ) {
2026-08-30 15:16:09 +00:00
const type = spec ? . type || 'string' ;
const label = spec ? . label || key ;
const field = document . createElement ( 'label' );
2026-09-10 15:31:12 -04:00
field . className = 'field' + ( type === 'boolean' ? ' check' : '' ) + ( type === 'switch' ? ' switch-field' : '' );
2026-08-30 15:16:09 +00:00
let read ;
2026-09-10 15:31:12 -04:00
// 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 ;
2026-08-30 15:16:09 +00:00
2026-09-10 15:31:12 -04:00
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' ) {
2026-08-30 15:16:09 +00:00
const input = document . createElement ( 'input' );
input . type = 'checkbox' ;
input . checked = Boolean ( value );
field . append ( input , document . createTextNode ( label ));
read = () => input . checked ;
2026-09-10 15:31:12 -04:00
control = input ;
2026-08-31 19:33:08 +00:00
} 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 });
2026-09-10 15:31:12 -04:00
} else if ( type === 'select' && ( Array . isArray ( spec . options ) || spec . optionsRoute )) {
2026-08-30 15:16:09 +00:00
const span = document . createElement ( 'span' );
span . textContent = label ;
const select = document . createElement ( 'select' );
2026-09-10 15:31:12 -04:00
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 ();
2026-08-30 15:16:09 +00:00
}
field . append ( span , select );
read = () => select . value ;
2026-09-10 15:31:12 -04:00
selectEl = select ;
2026-09-10 16:31:46 -04:00
} 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 ;
2026-08-30 15:16:09 +00:00
} 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 );
}
2026-09-10 15:31:12 -04:00
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 );
});
2026-08-30 15:16:09 +00:00
}
2026-09-13 12:55:55 -04:00
/* ── 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 ();
}
2026-08-30 15:16:09 +00:00
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 : '' ;
2026-09-09 21:12:46 +00:00
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' ;
2026-08-30 15:16:09 +00:00
const spacer = document . createElement ( 'span' );
spacer . className = 'spacer' ;
2026-09-09 21:12:46 +00:00
// 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 );
}
});
2026-08-30 15:16:09 +00:00
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 );
2026-09-09 21:12:46 +00:00
head . append ( dot , name , version , source , spacer , updateSlot , toggleLabel , uninstall );
2026-08-30 15:16:09 +00:00
card . appendChild ( head );
const desc = document . createElement ( 'div' );
desc . className = 'module-desc' ;
desc . textContent = mod . description ;
card . appendChild ( desc );
2026-09-13 12:55:55 -04:00
// 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.
2026-08-30 15:16:09 +00:00
const healthEl = document . createElement ( 'div' );
2026-09-13 12:55:55 -04:00
healthEl . className = 'module-status ' + healthClass ( mod );
2026-08-30 15:16:09 +00:00
healthEl . textContent = healthText ( mod );
card . appendChild ( healthEl );
const mountError = document . createElement ( 'div' );
mountError . className = 'module-mount-error' ;
2026-09-09 21:12:46 +00:00
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 } ` : '' );
2026-08-30 15:16:09 +00:00
card . appendChild ( mountError );
2026-09-09 21:12:46 +00:00
liveEls . set ( mod . id , { dot , healthEl , toggle , toggleText , mountError , updateSlot });
2026-08-30 15:16:09 +00:00
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 ();
2026-09-10 15:31:12 -04:00
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
2026-09-13 12:55:55 -04:00
// `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.
2026-09-10 15:31:12 -04:00
const groupMeta = mod . configGroups && typeof mod . configGroups === 'object' ? mod . configGroups : {};
let groupName = null ;
let container = form ;
let currentGroup = null ;
2026-08-30 15:16:09 +00:00
for ( const [ key , spec ] of schemaKeys ) {
2026-09-10 15:31:12 -04:00
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' ;
2026-09-13 12:55:55 -04:00
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 )));
2026-09-10 15:31:12 -04:00
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 ;
2026-09-13 12:55:55 -04:00
currentGroup = {
el : group ,
title ,
meta ,
fields : [],
toggleKey : typeof meta . toggle === 'string' ? meta . toggle : '' ,
};
2026-09-10 15:31:12 -04:00
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 );
2026-08-30 15:16:09 +00:00
readers . set ( key , read );
2026-09-10 15:31:12 -04:00
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 });
2026-09-13 12:55:55 -04:00
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 );
}
2026-09-10 15:31:12 -04:00
}
// 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 )));
2026-08-30 15:16:09 +00:00
}
const actions = document . createElement ( 'div' );
actions . className = 'module-actions' ;
const save = document . createElement ( 'button' );
const status = document . createElement ( 'span' );
status . className = 'admin-status' ;
2026-09-13 13:55:36 -04:00
actions . append ( status , save );
2026-09-13 14:03:10 -04:00
// 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.
2026-08-30 15:16:09 +00:00
card . appendChild ( form );
2026-09-13 14:03:10 -04:00
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' ;
2026-09-13 14:10:39 -04:00
// 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 );
2026-09-13 14:03:10 -04:00
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 ); }
});
2026-08-30 15:16:09 +00:00
save . addEventListener ( 'click' , async () => {
2026-09-13 14:03:10 -04:00
if ( form . hidden ) return setOpen ( true );
2026-08-30 15:16:09 +00:00
const config = {};
for ( const [ key , read ] of readers ) config [ key ] = read ();
save . disabled = true ;
2026-09-13 13:55:36 -04:00
status . textContent = 'Applying…' ;
2026-08-30 15:16:09 +00:00
status . className = 'admin-status' ;
try {
await api ( `/api/admin/modules/ ${ encodeURIComponent ( mod . id ) } /config` , {
method : 'PUT' ,
body : JSON . stringify ({ config }),
});
2026-09-13 13:55:36 -04:00
status . textContent = 'Applied — module re-initialised. Open tiles reconnect on their own.' ;
2026-08-30 15:16:09 +00:00
status . className = 'admin-status ok' ;
refreshStatus ();
2026-09-10 15:31:12 -04:00
// 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 );
2026-08-30 15:16:09 +00:00
} 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 );
2026-09-13 12:55:55 -04:00
els . healthEl . className = 'module-status ' + healthClass ( mod );
2026-09-09 21:12:46 +00:00
if ( ! mod . incompatible ) {
els . mountError . hidden = ! mod . mountError ;
els . mountError . textContent = mod . mountError ? `Failed to start: ${ mod . mountError } ` : '' ;
}
2026-08-30 15:16:09 +00:00
if ( document . activeElement !== els . toggle ) {
els . toggle . checked = mod . enabled ;
els . toggleText . textContent = mod . enabled ? 'Enabled' : 'Disabled' ;
}
}
}
2026-09-13 14:06:30 -04:00
/* ── saved layouts ──────────────────────────────────────────────────── */
2026-08-30 15:16:09 +00:00
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' ;
2026-09-13 14:06:30 -04:00
none . textContent = 'No layouts saved yet. Save one from the dashboard’ s Layout menu.' ;
2026-08-30 15:16:09 +00:00
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 ;
2026-09-09 21:12:46 +00:00
availableSection . hidden = false ;
shellVersion = String ( state . version || '' );
shellVersionEl . textContent = shellVersion ? 'v' + shellVersion : '' ;
2026-09-08 14:42:15 +00:00
const storageNote = document . getElementById ( 'storage-note' );
if ( state . dataDir ) {
document . getElementById ( 'storage-path' ). textContent = state . dataDir ;
storageNote . hidden = false ;
}
2026-08-30 15:16:09 +00:00
modulesList . innerHTML = '' ;
liveEls . clear ();
for ( const mod of state . modules || []) {
modulesList . appendChild ( buildModuleCard ( mod ));
}
2026-09-13 12:55:55 -04:00
loadTheme ();
2026-08-30 15:16:09 +00:00
loadLayouts ();
2026-09-09 21:12:46 +00:00
loadCatalog ( false );
2026-08-30 15:16:09 +00:00
}
2026-09-13 12:55:55 -04:00
/* ── 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 ();
2026-09-09 21:12:46 +00:00
/* ── 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 );
});
2026-08-30 15:16:09 +00:00
setInterval (() => {
if ( modulesSection . hidden ) return ;
refreshStatus ();
}, 5000 );
loadState ();