Merge pull request #4 from michaelmcgary2008/claude/proddash-app-build-gnwq7y
Module contract: modules can present multiple tiles
This commit is contained in:
@@ -59,6 +59,7 @@ shell code changes, ever.
|
|||||||
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
|
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
|
||||||
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
||||||
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). |
|
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). |
|
||||||
|
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
||||||
|
|
||||||
Both schemas map field names to specs:
|
Both schemas map field names to specs:
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `id` | Your module id. |
|
| `id` | Your module id. |
|
||||||
| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. |
|
| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. |
|
||||||
|
| `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles**). |
|
||||||
| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. |
|
| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. |
|
||||||
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. |
|
| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. |
|
||||||
| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). |
|
| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). |
|
||||||
@@ -260,6 +262,50 @@ module.exports = {
|
|||||||
shows in `/admin`, and API calls answer 502 until a config fix re-inits
|
shows in `/admin`, and API calls answer 502 until a config fix re-inits
|
||||||
it. Validate config early so bad input fails loudly there.
|
it. Validate config early so bad input fails loudly there.
|
||||||
|
|
||||||
|
### Presenting multiple tiles
|
||||||
|
|
||||||
|
By default a module is one entry in the + Add-tile picker. A module can
|
||||||
|
instead present a **list of tiles** — one picker entry each, grouped under
|
||||||
|
the module's name. This is how a module whose content is naturally plural
|
||||||
|
works: one tile per ProPresenter timer, one tile per admin-configured page.
|
||||||
|
|
||||||
|
Export `tiles` from your server entry (sync or async):
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log }) { … },
|
||||||
|
routes({ config, log }) { … },
|
||||||
|
|
||||||
|
tiles({ config, log }) {
|
||||||
|
// e.g. derived from admin config, or discovered from your upstream
|
||||||
|
return config.pages.map((page) => ({
|
||||||
|
id: page.name, // stable id, stored with the tile
|
||||||
|
name: page.name, // picker label AND the tile's title
|
||||||
|
description: page.url, // optional line under the label
|
||||||
|
settings: { page: page.name }, // preset instance settings
|
||||||
|
defaultSize: { w: 4, h: 3 }, // optional per-entry size overrides
|
||||||
|
minSize: { w: 2, h: 2 },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tiles()` runs on **every picker load**, so the list may be live data —
|
||||||
|
keep it fast (answer from state you already hold; don't fetch upstream on
|
||||||
|
demand). A throw or a hang (>2 s) falls back to the classic single entry,
|
||||||
|
so a dead upstream can't break the picker.
|
||||||
|
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
||||||
|
defaults in that tile's instance settings, the entry's `name` becomes the
|
||||||
|
tile's title, and `moduleApi.variant` carries its `id`.
|
||||||
|
- The tile keeps its variant id and title even if the entry later vanishes
|
||||||
|
from your list (a timer deleted in ProPresenter, a page removed in admin).
|
||||||
|
**Your client decides what that means** — show a clear "no longer
|
||||||
|
configured" state rather than erroring.
|
||||||
|
- A client-only module can declare a fixed list as a `tiles` array in
|
||||||
|
`module.json` instead (same entry shape).
|
||||||
|
- An empty list (or no `tiles` at all) keeps today's behavior: one picker
|
||||||
|
entry, no preset.
|
||||||
|
|
||||||
### Server-Sent Events from a module
|
### Server-Sent Events from a module
|
||||||
|
|
||||||
For live data, expose a `/stream` route and let clients use
|
For live data, expose a `/stream` route and let clients use
|
||||||
|
|||||||
+47
-8
@@ -142,7 +142,7 @@ function loadLayoutFromStorage() {
|
|||||||
|
|
||||||
function sanitizeTile(t) {
|
function sanitizeTile(t) {
|
||||||
const w = Math.max(1, Math.min(COLS, Number(t.w) || 3));
|
const w = Math.max(1, Math.min(COLS, Number(t.w) || 3));
|
||||||
return {
|
const tile = {
|
||||||
id: String(t.id || newId()),
|
id: String(t.id || newId()),
|
||||||
module: String(t.module || ''),
|
module: String(t.module || ''),
|
||||||
x: Math.max(0, Math.min(COLS - w, Number(t.x) || 0)),
|
x: Math.max(0, Math.min(COLS - w, Number(t.x) || 0)),
|
||||||
@@ -151,7 +151,14 @@ function sanitizeTile(t) {
|
|||||||
h: Math.max(1, Number(t.h) || 2),
|
h: Math.max(1, Number(t.h) || 2),
|
||||||
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
|
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
|
||||||
headHidden: Boolean(t.headHidden),
|
headHidden: Boolean(t.headHidden),
|
||||||
|
// set when the tile was added from a module's tile list (multi-tile modules)
|
||||||
|
variant: t.variant ? String(t.variant) : '',
|
||||||
|
title: t.title ? String(t.title) : '',
|
||||||
};
|
};
|
||||||
|
if (t.minSize && Number(t.minSize.w) > 0 && Number(t.minSize.h) > 0) {
|
||||||
|
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
|
||||||
|
}
|
||||||
|
return tile;
|
||||||
}
|
}
|
||||||
|
|
||||||
function newId() {
|
function newId() {
|
||||||
@@ -200,10 +207,10 @@ function applyRect(tile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function minSizeOf(tile) {
|
function minSizeOf(tile) {
|
||||||
const man = registry.get(tile.module);
|
const min = tile.minSize || registry.get(tile.module)?.minSize;
|
||||||
return {
|
return {
|
||||||
w: Math.max(1, man?.minSize?.w || 1),
|
w: Math.max(1, min?.w || 1),
|
||||||
h: Math.max(1, man?.minSize?.h || 1),
|
h: Math.max(1, min?.h || 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +235,7 @@ function buildTile(tile) {
|
|||||||
|
|
||||||
const title = document.createElement('span');
|
const title = document.createElement('span');
|
||||||
title.className = 'tile-title';
|
title.className = 'tile-title';
|
||||||
title.textContent = man ? man.name : tile.module + ' (not installed)';
|
title.textContent = man ? (tile.title || man.name) : tile.module + ' (not installed)';
|
||||||
|
|
||||||
// where moduleApi.header puts a module's own buttons and menus
|
// where moduleApi.header puts a module's own buttons and menus
|
||||||
const controls = document.createElement('span');
|
const controls = document.createElement('span');
|
||||||
@@ -492,18 +499,30 @@ function buildDivider(L, R, top, bottom) {
|
|||||||
|
|
||||||
/* ── add / remove tiles ─────────────────────────────────────────────── */
|
/* ── add / remove tiles ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
function addTile(moduleId) {
|
/**
|
||||||
|
* Add a tile. `entry` is one item of a multi-tile module's tile list
|
||||||
|
* (picker variants): its preset settings overlay the schema defaults, and
|
||||||
|
* its name/sizes stick to the tile.
|
||||||
|
*/
|
||||||
|
function addTile(moduleId, entry = null) {
|
||||||
const man = registry.get(moduleId);
|
const man = registry.get(moduleId);
|
||||||
|
const defaultSize = entry?.defaultSize || man?.defaultSize;
|
||||||
const size = {
|
const size = {
|
||||||
w: Math.max(1, Math.min(COLS, man?.defaultSize?.w || 4)),
|
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
|
||||||
h: Math.max(1, man?.defaultSize?.h || 3),
|
h: Math.max(1, defaultSize?.h || 3),
|
||||||
};
|
};
|
||||||
const spot = findSpot(size.w, size.h);
|
const spot = findSpot(size.w, size.h);
|
||||||
const settings = {};
|
const settings = {};
|
||||||
for (const [key, spec] of Object.entries(man?.instanceSchema || {})) {
|
for (const [key, spec] of Object.entries(man?.instanceSchema || {})) {
|
||||||
if (spec && 'default' in spec) settings[key] = spec.default;
|
if (spec && 'default' in spec) settings[key] = spec.default;
|
||||||
}
|
}
|
||||||
|
Object.assign(settings, entry?.settings || {});
|
||||||
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
|
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
|
||||||
|
if (entry) {
|
||||||
|
tile.variant = String(entry.id);
|
||||||
|
tile.title = String(entry.name || '');
|
||||||
|
if (entry.minSize) tile.minSize = { w: entry.minSize.w, h: entry.minSize.h };
|
||||||
|
}
|
||||||
tiles.push(tile);
|
tiles.push(tile);
|
||||||
buildTile(tile);
|
buildTile(tile);
|
||||||
refreshEmptyState();
|
refreshEmptyState();
|
||||||
@@ -579,6 +598,10 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
id: man.id,
|
id: man.id,
|
||||||
instanceId: tile.id,
|
instanceId: tile.id,
|
||||||
|
|
||||||
|
/** For multi-tile modules: which entry of the module's tile list this
|
||||||
|
tile was added as ('' when added as the plain module). */
|
||||||
|
variant: tile.variant || '',
|
||||||
|
|
||||||
/** Admin (server-wide) config, read-only. Password fields never reach
|
/** Admin (server-wide) config, read-only. Password fields never reach
|
||||||
the client. Live: reflects the latest registry after admin saves. */
|
the client. Live: reflects the latest registry after admin saves. */
|
||||||
get config() {
|
get config() {
|
||||||
@@ -908,11 +931,27 @@ wireDropdown('add-btn', 'add-menu', async (menu) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const man of manifests) {
|
for (const man of manifests) {
|
||||||
|
const entries = Array.isArray(man.tiles) ? man.tiles : [];
|
||||||
|
if (entries.length) {
|
||||||
|
// A multi-tile module: its name becomes a group heading and each of
|
||||||
|
// its tiles is added individually.
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'menu-title';
|
||||||
|
title.textContent = man.name;
|
||||||
|
menu.appendChild(title);
|
||||||
|
for (const entry of entries) {
|
||||||
|
menu.appendChild(menuItem(entry.name, () => {
|
||||||
|
menu.hidden = true;
|
||||||
|
addTile(man.id, entry);
|
||||||
|
}, { sub: entry.description || '' }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
menu.appendChild(menuItem(man.name, () => {
|
menu.appendChild(menuItem(man.name, () => {
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
addTile(man.id);
|
addTile(man.id);
|
||||||
}, { sub: man.description || '' }));
|
}, { sub: man.description || '' }));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
wireDropdown('layout-btn', 'layout-menu', async (menu) => {
|
wireDropdown('layout-btn', 'layout-menu', async (menu) => {
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ function clientConfig(id) {
|
|||||||
* init({ config, log }) -> handle with stop() (and optionally health())
|
* init({ config, log }) -> handle with stop() (and optionally health())
|
||||||
* routes({ config, log }) -> { 'GET /state': handler, 'GET /stream': handler,
|
* routes({ config, log }) -> { 'GET /state': handler, 'GET /stream': handler,
|
||||||
* 'GET /proxy/*': handler, '* /any/*': handler }
|
* 'GET /proxy/*': handler, '* /any/*': handler }
|
||||||
|
* tiles({ config, log }) -> optional, sync or async: the tiles this module
|
||||||
|
* presents in the Add-tile picker, e.g.
|
||||||
|
* [{ id, name, description, settings, minSize,
|
||||||
|
* defaultSize }]. Called on every picker load,
|
||||||
|
* so the list may be dynamic (discovered timers,
|
||||||
|
* admin-configured pages). Omitted or empty →
|
||||||
|
* the module appears once, as before.
|
||||||
* Routes are mounted under /api/modules/<id>/. A trailing "/*" makes a prefix
|
* Routes are mounted under /api/modules/<id>/. A trailing "/*" makes a prefix
|
||||||
* route; the matched remainder is passed as req.wildcard (req.search carries
|
* route; the matched remainder is passed as req.wildcard (req.search carries
|
||||||
* the query string). init() runs before routes(), so routes can close over
|
* the query string). init() runs before routes(), so routes can close over
|
||||||
@@ -173,6 +180,9 @@ function mountModule(id) {
|
|||||||
if (typeof mod.routes === 'function') {
|
if (typeof mod.routes === 'function') {
|
||||||
entry.routes = parseRouteTable(mod.routes({ config, log }));
|
entry.routes = parseRouteTable(mod.routes({ config, log }));
|
||||||
}
|
}
|
||||||
|
if (typeof mod.tiles === 'function') {
|
||||||
|
entry.tiles = () => mod.tiles({ config, log });
|
||||||
|
}
|
||||||
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
|
log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : ''));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
entry.error = err instanceof Error ? err.message : String(err);
|
entry.error = err instanceof Error ? err.message : String(err);
|
||||||
@@ -243,8 +253,51 @@ function readBody(req, limitBytes = 512 * 1024) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One entry of a module's tile list, with everything untrusted dropped. */
|
||||||
|
function sanitizeTileEntry(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object' || !raw.id || !raw.name) return null;
|
||||||
|
const out = {
|
||||||
|
id: String(raw.id),
|
||||||
|
name: String(raw.name),
|
||||||
|
description: raw.description ? String(raw.description) : '',
|
||||||
|
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
|
||||||
|
};
|
||||||
|
for (const key of ['minSize', 'defaultSize']) {
|
||||||
|
const size = raw[key];
|
||||||
|
if (size && Number(size.w) > 0 && Number(size.h) > 0) {
|
||||||
|
out[key] = { w: Number(size.w), h: Number(size.h) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tiles a module presents in the picker. A module's tiles() runs on every
|
||||||
|
* picker load so the list can be live data — but a hung or throwing module
|
||||||
|
* must never stall the picker, so it gets a short deadline and any failure
|
||||||
|
* falls back to the classic single entry (empty list).
|
||||||
|
*/
|
||||||
|
async function moduleTiles(id) {
|
||||||
|
const man = manifests.get(id);
|
||||||
|
const entry = mounted.get(id);
|
||||||
|
let list = Array.isArray(man.tiles) ? man.tiles : []; // static, for client-only modules
|
||||||
|
if (entry?.tiles) {
|
||||||
|
try {
|
||||||
|
const dynamic = await Promise.race([
|
||||||
|
Promise.resolve(entry.tiles()),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('tiles() timed out')), 2000).unref()),
|
||||||
|
]);
|
||||||
|
if (Array.isArray(dynamic)) list = dynamic;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[proddash] module "${id}" tiles() failed:`, err?.message || err);
|
||||||
|
list = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list.map(sanitizeTileEntry).filter(Boolean).slice(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
/** Manifest as sent to the dashboard client (enabled modules only). */
|
/** Manifest as sent to the dashboard client (enabled modules only). */
|
||||||
function clientManifest(id) {
|
async function clientManifest(id) {
|
||||||
const man = manifests.get(id);
|
const man = manifests.get(id);
|
||||||
return {
|
return {
|
||||||
id: man.id,
|
id: man.id,
|
||||||
@@ -258,6 +311,7 @@ function clientManifest(id) {
|
|||||||
instanceSchema: man.instanceSchema || {},
|
instanceSchema: man.instanceSchema || {},
|
||||||
hasServer: Boolean(man.server),
|
hasServer: Boolean(man.server),
|
||||||
config: clientConfig(id),
|
config: clientConfig(id),
|
||||||
|
tiles: await moduleTiles(id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,8 +706,13 @@ function handleRequest(req, res) {
|
|||||||
|
|
||||||
/* — shell API — */
|
/* — shell API — */
|
||||||
if (urlPath === '/api/modules' && req.method === 'GET') {
|
if (urlPath === '/api/modules' && req.method === 'GET') {
|
||||||
const list = [...manifests.keys()].filter(isEnabled).map(clientManifest);
|
Promise.all([...manifests.keys()].filter(isEnabled).map(clientManifest))
|
||||||
return sendJson(res, 200, { modules: list });
|
.then((list) => sendJson(res, 200, { modules: list }))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[proddash] /api/modules failed:', err);
|
||||||
|
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* — shell events: open dashboards learn about admin changes live — */
|
/* — shell events: open dashboards learn about admin changes live — */
|
||||||
|
|||||||
Reference in New Issue
Block a user