diff --git a/.gitignore b/.gitignore index db4682f..bf6e2da 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ dist/ .DS_Store *.log .env + +# runtime state written by the admin page / layout saves +config/modules.json +config/layouts/ diff --git a/modules/clock/client.js b/modules/clock/client.js new file mode 100644 index 0000000..0a8b37d --- /dev/null +++ b/modules/clock/client.js @@ -0,0 +1,74 @@ +/* Clock — the reference ProdDash module. Client-only: no server entry. + Everything a module may do is shown here: render inside root, read + instanceSettings, report status, clean up in stop(). */ + +export default function create({ root, moduleApi }) { + let timer = null; + let timeEl = null; + let dateEl = null; + let resizeObserver = null; + + function settings() { + return moduleApi.instanceSettings; // shell merges schema defaults for us + } + + function renderFrame() { + root.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'clock-wrap'; + timeEl = document.createElement('div'); + timeEl.className = 'clock-time'; + dateEl = document.createElement('div'); + dateEl.className = 'clock-date'; + wrap.append(timeEl, dateEl); + root.appendChild(wrap); + } + + function tick() { + const { format, showSeconds, showDate } = settings(); + const now = new Date(); + timeEl.textContent = now.toLocaleTimeString([], { + hour12: format === '12h', + hour: format === '12h' ? 'numeric' : '2-digit', + minute: '2-digit', + ...(showSeconds ? { second: '2-digit' } : {}), + }); + dateEl.hidden = !showDate; + if (showDate) { + dateEl.textContent = now.toLocaleDateString([], { + weekday: 'long', month: 'long', day: 'numeric', + }); + } + } + + /** Scale the digits to the tile (CSS alone can't see the tile's box). */ + function fit() { + const { showSeconds, showDate } = settings(); + const chars = (showSeconds ? 8 : 5) + 1; + const size = Math.min((root.clientWidth * 1.7) / chars, root.clientHeight * (showDate ? 0.42 : 0.55)); + timeEl.style.fontSize = Math.max(12, Math.floor(size)) + 'px'; + dateEl.style.fontSize = Math.max(10, Math.floor(size * 0.28)) + 'px'; + } + + return { + start() { + renderFrame(); + tick(); + fit(); + timer = setInterval(tick, 250); + resizeObserver = new ResizeObserver(fit); + resizeObserver.observe(root); + moduleApi.setStatus('ok', 'Ticking'); + }, + stop() { + clearInterval(timer); + timer = null; + resizeObserver?.disconnect(); + resizeObserver = null; + root.innerHTML = ''; + }, + onResize() { + fit(); + }, + }; +} diff --git a/modules/clock/module.json b/modules/clock/module.json new file mode 100644 index 0000000..1f1639b --- /dev/null +++ b/modules/clock/module.json @@ -0,0 +1,23 @@ +{ + "id": "clock", + "name": "Clock", + "version": "1.0.0", + "description": "Big booth clock — the smallest possible ProdDash module", + "client": "client.js", + "style": "style.css", + "minSize": { "w": 2, "h": 1 }, + "defaultSize": { "w": 3, "h": 2 }, + "instanceSchema": { + "format": { + "type": "select", + "label": "Format", + "default": "24h", + "options": [ + { "value": "24h", "label": "24-hour" }, + { "value": "12h", "label": "12-hour" } + ] + }, + "showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }, + "showDate": { "type": "boolean", "label": "Show date", "default": false } + } +} diff --git a/modules/clock/style.css b/modules/clock/style.css new file mode 100644 index 0000000..1ab57ca --- /dev/null +++ b/modules/clock/style.css @@ -0,0 +1,25 @@ +/* Clock module — scoped under its tile via [data-module="clock"], styled + with the shell's CSS variables only. */ + +[data-module="clock"] .clock-wrap { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2%; +} + +[data-module="clock"] .clock-time { + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + color: var(--text); + line-height: 1; +} + +[data-module="clock"] .clock-date { + color: var(--muted); + font-weight: 600; +} diff --git a/public/shell.js b/public/shell.js index ada4c88..4797c28 100644 --- a/public/shell.js +++ b/public/shell.js @@ -104,32 +104,20 @@ function menuItem(label, onClick, { sub = '', danger = false } = {}) { /* ── module registry ────────────────────────────────────────────────── */ -/* Filled from GET /api/modules once the module system is up (milestone 2). - Until then — or if the fetch fails — the shell offers a built-in - placeholder tile so the grid itself is usable and testable. */ - -const PLACEHOLDER_MANIFEST = { - id: '__placeholder', - name: 'Placeholder', - description: 'An empty tile for trying out the grid', - defaultSize: { w: 3, h: 2 }, - minSize: { w: 1, h: 1 }, - builtin: true, -}; - -let registry = new Map(); // module id -> manifest +let registry = new Map(); // module id -> manifest from GET /api/modules +/** Refresh the enabled-module list. On failure the last known list is kept + (the server may just be restarting mid-service). Returns true on success. */ async function loadRegistry() { - registry = new Map(); try { const res = await fetch('/api/modules'); if (!res.ok) throw new Error('modules ' + res.status); const body = await res.json(); - for (const m of body.modules || []) registry.set(m.id, m); + registry = new Map((body.modules || []).map((m) => [m.id, m])); + return true; } catch { - /* no module API yet (or server just restarted) — placeholder only */ + return false; } - if (!registry.size) registry.set(PLACEHOLDER_MANIFEST.id, PLACEHOLDER_MANIFEST); } /* ── layout state ───────────────────────────────────────────────────── */ @@ -434,34 +422,167 @@ function clearAllTiles() { /* ── module mounting ────────────────────────────────────────────────── */ -/* Milestone 2 turns this into the real module loader (dynamic import of each - module's client.js plus the moduleApi). For now only the built-in - placeholder renders. */ +const clientModuleCache = new Map(); // module id -> Promise -function mountModule(tile) { +function loadClientModule(man) { + if (!clientModuleCache.has(man.id)) { + clientModuleCache.set(man.id, import(`/modules/${man.id}/${man.client || 'client.js'}`)); + } + return clientModuleCache.get(man.id); +} + +/** Inject a module's stylesheet once (shared by every instance of it). */ +function ensureModuleStyle(man) { + if (!man.style) return; + const id = 'module-style-' + man.id; + if (document.getElementById(id)) return; + const link = document.createElement('link'); + link.id = id; + link.rel = 'stylesheet'; + link.href = `/modules/${man.id}/${man.style}`; + document.head.appendChild(link); +} + +function tileMessage(entry, text) { + const msg = document.createElement('div'); + msg.className = 'tile-msg'; + msg.textContent = text; + entry.body.appendChild(msg); +} + +/** + * The API handed to each module instance — the whole surface a module may + * touch outside its root element. See docs/MODULE-GUIDE.md. + */ +function buildModuleApi(tile, man, entry) { + const sseHandles = new Set(); + + const api = { + id: man.id, + instanceId: tile.id, + + /** Admin (server-wide) config, read-only. Password fields never reach the client. */ + config: Object.freeze({ ...(man.config || {}) }), + + /** Per-tile settings: schema defaults overlaid with what this tile saved. */ + get instanceSettings() { + const out = {}; + for (const [key, spec] of Object.entries(man.instanceSchema || {})) { + if (spec && 'default' in spec) out[key] = spec.default; + } + return Object.assign(out, tile.settings || {}); + }, + + /** Persist per-tile settings into the layout (no remount — the module + already knows, it made the change). */ + saveInstanceSettings(patch) { + Object.assign(tile.settings, patch || {}); + saveLayout(); + }, + + /** fetch scoped to this module's server routes. */ + fetch(subPath, opts) { + return fetch('/api/modules/' + man.id + subPath, opts); + }, + + /** + * EventSource scoped the same way, with auto-reconnect: EventSource + * retries transient drops itself but gives up for good when a retry gets + * a completed non-SSE response (a 502 while the upstream is down), so a + * closed stream is recreated on a timer until it works again. + * handlers: { open(e), error(e), message(e), events: { name: fn } } + */ + sse(subPath, handlers = {}) { + const url = '/api/modules/' + man.id + subPath; + let es = null; + let retryTimer = null; + let closed = false; + const connect = () => { + if (closed) return; + try { es?.close(); } catch { /* already closed */ } + es = new EventSource(url); + if (handlers.open) es.onopen = handlers.open; + if (handlers.message) es.onmessage = handlers.message; + for (const [name, fn] of Object.entries(handlers.events || {})) { + es.addEventListener(name, fn); + } + es.onerror = (e) => { + try { handlers.error?.(e); } catch { /* module's problem */ } + if (es.readyState === EventSource.CLOSED) { + clearTimeout(retryTimer); + retryTimer = setTimeout(connect, 3000); + } + }; + }; + connect(); + const handle = { + close() { + closed = true; + clearTimeout(retryTimer); + try { es?.close(); } catch { /* already closed */ } + sseHandles.delete(handle); + }, + }; + sseHandles.add(handle); + return handle; + }, + + /** Drive the tile's status dot: 'ok' | 'connecting' | 'error'. */ + setStatus(state, msg = '') { + const cls = state === 'ok' || state === 'connecting' || state === 'error' ? state : ''; + entry.dot.className = 'dot ' + cls; + entry.dot.title = msg; + }, + + /* shell-internal: safety net so a forgotten stream can't outlive the tile */ + _closeAll() { + for (const handle of [...sseHandles]) handle.close(); + }, + }; + return api; +} + +async function mountModule(tile) { const entry = tileEls.get(tile.id); if (!entry) return; const man = registry.get(tile.module); if (!man) { - entry.body.innerHTML = '
Module “' + tile.module + '” is not installed.
'; + tileMessage(entry, `Module “${tile.module}” is not installed or is disabled. Check Admin.`); entry.dot.className = 'dot error'; + entry.dot.title = 'Module unavailable'; return; } - if (man.builtin) { - const msg = document.createElement('div'); - msg.className = 'tile-msg'; - msg.textContent = 'Placeholder tile — drag me by the header, resize me by the corner.'; - entry.body.appendChild(msg); - entry.dot.className = 'dot ok'; + entry.dot.className = 'dot connecting'; + ensureModuleStyle(man); + try { + const mod = await loadClientModule(man); + if (tileEls.get(tile.id) !== entry || entry.instance) return; // tile removed/remounted while loading + const factory = mod.default; + if (typeof factory !== 'function') throw new Error('client.js has no default-export factory'); + const api = buildModuleApi(tile, man, entry); + const instance = factory({ root: entry.body, moduleApi: api }) || {}; + entry.instance = instance; + entry.api = api; + instance.start?.(); + } catch (err) { + console.error(`[shell] module "${tile.module}" failed to start:`, err); + entry.body.innerHTML = ''; + tileMessage(entry, `“${man.name}” failed to start: ${err?.message || err}`); + entry.dot.className = 'dot error'; } } function unmountModule(tile) { const entry = tileEls.get(tile.id); + if (!entry) return; try { - entry?.instance?.stop?.(); + entry.instance?.stop?.(); } catch { /* stopping is best-effort */ } - if (entry) entry.instance = null; + try { + entry.api?._closeAll(); + } catch { /* ditto */ } + entry.instance = null; + entry.api = null; } /* ── per-tile settings popover ──────────────────────────────────────── */ @@ -598,7 +719,12 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => { /* ── boot ───────────────────────────────────────────────────────────── */ async function boot() { - await loadRegistry(); + const ok = await loadRegistry(); + if (!ok) { + toast('ProdDash server unreachable — retrying…', true); + setTimeout(boot, 4000); + return; + } renderLayout(loadLayoutFromStorage()); } diff --git a/server.js b/server.js index 1401577..48eb34e 100644 --- a/server.js +++ b/server.js @@ -22,8 +22,10 @@ const os = require('os'); const ROOT = __dirname; const PUBLIC_DIR = path.join(ROOT, 'public'); const CONFIG_DIR = path.join(ROOT, 'config'); +const MODULES_DIR = path.join(ROOT, 'modules'); +const MODULES_CONFIG_PATH = path.join(CONFIG_DIR, 'modules.json'); -/* ── config ─────────────────────────────────────────────────────────── */ +/* ── config files ───────────────────────────────────────────────────── */ function readJson(file, fallback) { try { @@ -33,9 +35,270 @@ function readJson(file, fallback) { } } +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = file + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n'); + fs.renameSync(tmp, file); // atomic-ish: never leave a half-written config +} + const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {}); const PORT = Number(process.env.PORT || shellConfig.port || 24500); +/** Per-module server-wide state: { "": { enabled: bool, config: {…} } } */ +let modulesConfig = readJson(MODULES_CONFIG_PATH, {}); + +/* ── module discovery ───────────────────────────────────────────────── */ + +/** @type {Map} module id -> manifest (with .dir added) */ +const manifests = new Map(); + +function discoverModules() { + manifests.clear(); + let dirs = []; + try { + dirs = fs.readdirSync(MODULES_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch { + return; // no modules/ folder yet — the shell still runs + } + for (const dir of dirs) { + const manifestPath = path.join(MODULES_DIR, dir, 'module.json'); + const man = readJson(manifestPath, null); + if (!man || typeof man !== 'object') { + if (fs.existsSync(manifestPath)) console.warn(`[proddash] ${dir}/module.json is invalid — skipped`); + continue; + } + if (man.id && man.id !== dir) { + console.warn(`[proddash] module "${dir}": manifest id "${man.id}" must match its folder name — skipped`); + continue; + } + man.id = dir; + if (!man.client) { + console.warn(`[proddash] module "${dir}": manifest has no "client" entry — skipped`); + continue; + } + man.dir = path.join(MODULES_DIR, dir); + manifests.set(dir, man); + } +} + +function moduleState(id) { + const s = modulesConfig[id]; + return s && typeof s === 'object' ? s : {}; +} + +function isEnabled(id) { + return moduleState(id).enabled !== false; // newly dropped modules default to enabled +} + +/** Admin config for a module: schema defaults overlaid with stored values. */ +function effectiveConfig(id) { + const man = manifests.get(id); + const out = {}; + for (const [key, spec] of Object.entries(man?.configSchema || {})) { + if (spec && 'default' in spec) out[key] = spec.default; + } + const stored = moduleState(id).config; + if (stored && typeof stored === 'object') Object.assign(out, stored); + return out; +} + +/** Config as sent to browsers: password-typed fields never leave the server. */ +function clientConfig(id) { + const man = manifests.get(id); + const cfg = effectiveConfig(id); + const out = {}; + for (const [key, value] of Object.entries(cfg)) { + if (man?.configSchema?.[key]?.type === 'password') continue; + out[key] = value; + } + return out; +} + +/* ── module server mounting ─────────────────────────────────────────── */ + +/** + * A module's optional server.js exports: + * init({ config, log }) -> handle with stop() (and optionally health()) + * routes({ config, log }) -> { 'GET /state': handler, 'GET /stream': handler, + * 'GET /proxy/*': handler, '* /any/*': handler } + * Routes are mounted under /api/modules//. A trailing "/*" makes a prefix + * route; the matched remainder is passed as req.wildcard (req.search carries + * the query string). init() runs before routes(), so routes can close over + * state init created. + */ +const mounted = new Map(); // id -> { routes: [...], handle, error } + +function makeLog(id) { + return (...args) => console.log(`[${id}]`, ...args); +} + +function parseRouteTable(table) { + const routes = []; + for (const [key, handler] of Object.entries(table || {})) { + if (typeof handler !== 'function') continue; + const sp = key.indexOf(' '); + if (sp < 0) continue; + const method = key.slice(0, sp).toUpperCase(); + let route = key.slice(sp + 1).trim(); + let wildcard = false; + if (route.endsWith('/*')) { + wildcard = true; + route = route.slice(0, -2) || ''; + } + if (!route.startsWith('/')) route = '/' + route; + routes.push({ method, path: route, wildcard, handler }); + } + return routes; +} + +function mountModule(id) { + const man = manifests.get(id); + if (!man) return; + const entry = { routes: [], handle: null, error: '' }; + mounted.set(id, entry); + if (!man.server) return; + const log = makeLog(id); + try { + const serverPath = path.join(man.dir, man.server); + // Re-require fresh config on every (re)mount; the module code itself stays cached. + const mod = require(serverPath); + const config = effectiveConfig(id); + if (typeof mod.init === 'function') { + entry.handle = mod.init({ config, log }) || null; + } + if (typeof mod.routes === 'function') { + entry.routes = parseRouteTable(mod.routes({ config, log })); + } + log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : '')); + } catch (err) { + entry.error = err instanceof Error ? err.message : String(err); + console.error(`[proddash] module "${id}" failed to mount:`, err); + } +} + +function unmountModule(id) { + const entry = mounted.get(id); + if (!entry) return; + try { + entry.handle?.stop?.(); + } catch (err) { + console.error(`[proddash] module "${id}" failed to stop cleanly:`, err); + } + mounted.delete(id); +} + +/** Re-init a module after its config changed (admin page). */ +function remountModule(id) { + unmountModule(id); + if (manifests.has(id) && isEnabled(id)) mountModule(id); +} + +function mountAllModules() { + for (const id of manifests.keys()) { + if (isEnabled(id)) mountModule(id); + } +} + +/** Health of a mounted module, as reported by its own handle.health(). */ +function moduleHealth(id) { + const entry = mounted.get(id); + if (!entry) return null; + if (entry.error) return { status: 'error', message: entry.error }; + try { + const h = entry.handle?.health?.(); + if (h && typeof h === 'object') return { status: String(h.status || 'ok'), message: String(h.message || '') }; + } catch { /* a bad health() must not break the admin page */ } + return null; +} + +/* ── shell API helpers ──────────────────────────────────────────────── */ + +function sendJson(res, status, body) { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(body)); +} + +function readBody(req, limitBytes = 512 * 1024) { + return new Promise((resolve, reject) => { + let data = ''; + let size = 0; + req.on('data', (chunk) => { + size += chunk.length; + if (size > limitBytes) { + reject(new Error('Request body too large.')); + req.destroy(); + return; + } + data += chunk; + }); + req.on('end', () => resolve(data)); + req.on('error', reject); + }); +} + +/** Manifest as sent to the dashboard client (enabled modules only). */ +function clientManifest(id) { + const man = manifests.get(id); + return { + id: man.id, + name: String(man.name || man.id), + version: String(man.version || ''), + description: String(man.description || ''), + client: String(man.client), + style: man.style ? String(man.style) : '', + minSize: man.minSize || { w: 1, h: 1 }, + defaultSize: man.defaultSize || { w: 4, h: 3 }, + instanceSchema: man.instanceSchema || {}, + hasServer: Boolean(man.server), + config: clientConfig(id), + }; +} + +/* ── module API dispatch ────────────────────────────────────────────── */ + +function dispatchModuleApi(req, res, id, subPath, search) { + if (!manifests.has(id)) return sendJson(res, 404, { error: `No module "${id}" is installed.` }); + if (!isEnabled(id)) return sendJson(res, 404, { error: `Module "${id}" is disabled.` }); + const entry = mounted.get(id); + if (!entry) return sendJson(res, 503, { error: `Module "${id}" is not mounted.` }); + if (entry.error) return sendJson(res, 502, { error: `Module "${id}" failed to start: ${entry.error}` }); + + for (const route of entry.routes) { + if (route.method !== '*' && route.method !== req.method) continue; + if (route.wildcard) { + if (subPath === route.path || subPath.startsWith(route.path + '/')) { + req.wildcard = subPath.slice(route.path.length) || '/'; + req.search = search; + try { + route.handler(req, res); + } catch (err) { + console.error(`[${id}] route handler threw:`, err); + if (!res.headersSent) sendJson(res, 500, { error: 'Module error.' }); + else try { res.end(); } catch { /* gone */ } + } + return; + } + } else if (subPath === route.path) { + req.search = search; + try { + route.handler(req, res); + } catch (err) { + console.error(`[${id}] route handler threw:`, err); + if (!res.headersSent) sendJson(res, 500, { error: 'Module error.' }); + else try { res.end(); } catch { /* gone */ } + } + return; + } + } + sendJson(res, 404, { error: `Module "${id}" has no route for ${req.method} ${subPath}` }); +} + /* ── static files ───────────────────────────────────────────────────── */ const MIME = { @@ -90,22 +353,61 @@ function serveFile(res, baseDir, urlPath) { /* ── request routing ────────────────────────────────────────────────── */ +function handleRequest(req, res) { + let urlPath; + let search = ''; + try { + const u = new URL(req.url, 'http://proddash.invalid'); + urlPath = u.pathname; + search = u.search || ''; + } catch { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + return res.end('Bad request'); + } + + /* — shell API — */ + if (urlPath === '/api/modules' && req.method === 'GET') { + const list = [...manifests.keys()].filter(isEnabled).map(clientManifest); + return sendJson(res, 200, { modules: list }); + } + + /* — module APIs: /api/modules//… (any method; modules decide) — */ + const apiMatch = urlPath.match(/^\/api\/modules\/([^/]+)(\/.*)?$/); + if (apiMatch) { + const id = decodeURIComponent(apiMatch[1]); + const subPath = apiMatch[2] || '/'; + return dispatchModuleApi(req, res, id, subPath, search); + } + + if (urlPath.startsWith('/api/')) { + return sendJson(res, 404, { error: 'Unknown endpoint.' }); + } + + /* — static — */ + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { Allow: 'GET, HEAD' }); + return res.end('Method not allowed'); + } + + /* module client assets: /modules//… */ + const assetMatch = urlPath.match(/^\/modules\/([^/]+)(\/.*)?$/); + if (assetMatch) { + const id = decodeURIComponent(assetMatch[1]); + const man = manifests.get(id); + if (!man || !isEnabled(id)) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + return res.end('Not found'); + } + return serveFile(res, man.dir, assetMatch[2] || '/'); + } + + serveFile(res, PUBLIC_DIR, urlPath); +} + const server = http.createServer((req, res) => { // Everything is guarded: one throw must never take the dashboard down mid-service. try { - let urlPath; - try { - urlPath = new URL(req.url, 'http://proddash.invalid').pathname; - } catch { - res.writeHead(400, { 'Content-Type': 'text/plain' }); - return res.end('Bad request'); - } - - if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405, { Allow: 'GET, HEAD' }); - return res.end('Method not allowed'); - } - serveFile(res, PUBLIC_DIR, urlPath); + handleRequest(req, res); } catch (err) { console.error('[proddash] request error:', err); try { @@ -118,6 +420,11 @@ const server = http.createServer((req, res) => { // Never time out long-lived SSE connections. server.requestTimeout = 0; +/* ── boot ───────────────────────────────────────────────────────────── */ + +discoverModules(); +mountAllModules(); + server.listen(PORT, '0.0.0.0', () => { console.log('ProdDash running'); console.log(' Local : http://localhost:' + PORT); @@ -128,4 +435,16 @@ server.listen(PORT, '0.0.0.0', () => { } } } + const ids = [...manifests.keys()]; + console.log(ids.length + ? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)')).join(', ') + : ' Modules : none installed'); }); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + for (const id of [...mounted.keys()]) unmountModule(id); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 1500).unref(); + }); +}