Unified endpoint config type: one Host/IP : Port control in admin
New configSchema type 'endpoint' declared in the module spec: anything
that points at a device or service on the network must be one endpoint
field, rendered identically for every module in /admin as a joined
Host/IP : Port control and stored/handed to the module server as
{ host, port }. Migrates all three connection modules — ProdCom (was a
full URL field), ProPresenter and Timers (were separate host + port
fields). Legacy stored configs (prodcomUrl / flat host+port) keep
working until the admin form is re-saved, at which point the new shape
replaces them. MODULE-GUIDE documents the type and the presentation
rule: no split host/port fields, and full URLs only for genuine web
addresses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
@@ -83,16 +83,16 @@ are written by the app itself.
|
|||||||
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
||||||
speech, channel colors, per-tile channel visibility toggles and group
|
speech, channel colors, per-tile channel visibility toggles and group
|
||||||
presets, timestamps, text size and jump-to-latest in the title bar.
|
presets, timestamps, text size and jump-to-latest in the title bar.
|
||||||
Admin config: ProdCom URL, API key.
|
Admin config: ProdCom server (host : port), API key.
|
||||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||||
Admin config: host, port, password.
|
Admin config: ProPresenter endpoint (host : port), password.
|
||||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||||
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
||||||
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
||||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||||
unmistakably red. Read-only — no start/stop controls. Admin config: host,
|
unmistakably red. Read-only — no start/stop controls. Admin config:
|
||||||
port, password.
|
ProPresenter endpoint (host : port), password.
|
||||||
- **Clock** — a big booth clock; also the smallest possible module and the
|
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||||
reference for module authors.
|
reference for module authors.
|
||||||
|
|
||||||
|
|||||||
+29
-1
@@ -64,7 +64,7 @@ shell code changes, ever.
|
|||||||
Both schemas map field names to specs:
|
Both schemas map field names to specs:
|
||||||
|
|
||||||
```
|
```
|
||||||
{ "type": "string" | "number" | "boolean" | "select" | "password",
|
{ "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint",
|
||||||
"label": "Shown next to the field",
|
"label": "Shown next to the field",
|
||||||
"default": <value>,
|
"default": <value>,
|
||||||
"options": [ { "value": "a", "label": "A" }, … ] // "select" only
|
"options": [ { "value": "a", "label": "A" }, … ] // "select" only
|
||||||
@@ -77,6 +77,34 @@ browsers** — not in `/api/modules`, and never echoed back into the admin
|
|||||||
form. Anything upstream that needs the secret must go through your server
|
form. Anything upstream that needs the secret must go through your server
|
||||||
routes.
|
routes.
|
||||||
|
|
||||||
|
### Connection settings: use `endpoint`
|
||||||
|
|
||||||
|
Anything that points at a device or service on the network — ProPresenter,
|
||||||
|
ProdCom, a camera, a mixer — must be **one `endpoint` field**, so every
|
||||||
|
module's connection is entered the same way. The admin page renders an
|
||||||
|
endpoint as a single "Host / IP : Port" control; the value is stored and
|
||||||
|
handed to your server as an object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"propresenter": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"label": "ProPresenter",
|
||||||
|
"default": { "host": "", "port": 1025 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// in your server entry
|
||||||
|
const { host, port } = config.propresenter;
|
||||||
|
const base = `http://${host}:${port}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** split host and port into separate `string`/`number` fields, and
|
||||||
|
do **not** ask for a full `http://…` URL unless the setting genuinely is a
|
||||||
|
web address where protocol or path matter (a page to embed, a webhook).
|
||||||
|
Endpoints belong in admin config, not per-tile settings — connections are
|
||||||
|
server-wide by design.
|
||||||
|
|
||||||
Admin config lives in `config/modules.json` (written by the admin page —
|
Admin config lives in `config/modules.json` (written by the admin page —
|
||||||
your module never touches that file). Per-tile settings live inside each
|
your module never touches that file). Per-tile settings live inside each
|
||||||
browser's layout.
|
browser's layout.
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
"minSize": { "w": 3, "h": 2 },
|
"minSize": { "w": 3, "h": 2 },
|
||||||
"defaultSize": { "w": 4, "h": 4 },
|
"defaultSize": { "w": 4, "h": 4 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"prodcomUrl": {
|
"prodcom": {
|
||||||
"type": "string",
|
"type": "endpoint",
|
||||||
"label": "ProdCom URL",
|
"label": "ProdCom server",
|
||||||
"default": "http://10.3.11.152:24480"
|
"default": { "host": "10.3.11.152", "port": 24480 }
|
||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -71,11 +71,22 @@ function proxy(req, res, target, apiKey) {
|
|||||||
req.pipe(preq);
|
req.pipe(preq);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
|
||||||
|
before the endpoint field existed) can only still be present until the
|
||||||
|
admin form is re-saved, so when it exists it wins over the endpoint's
|
||||||
|
schema default. */
|
||||||
|
function resolveTarget(config) {
|
||||||
|
if (config.prodcomUrl) return new URL(config.prodcomUrl);
|
||||||
|
const ep = config.prodcom;
|
||||||
|
if (ep && ep.host && Number(ep.port)) return new URL(`http://${ep.host}:${Number(ep.port)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config }) {
|
init({ config }) {
|
||||||
health = config.prodcomUrl
|
health = resolveTarget(config)
|
||||||
? { status: 'connecting', message: 'No traffic yet' }
|
? { status: 'connecting', message: 'No traffic yet' }
|
||||||
: { status: 'error', message: 'No ProdCom URL configured' };
|
: { status: 'error', message: 'No ProdCom server configured' };
|
||||||
return {
|
return {
|
||||||
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
||||||
health: () => health,
|
health: () => health,
|
||||||
@@ -83,8 +94,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
routes({ config, log }) {
|
routes({ config, log }) {
|
||||||
// An invalid URL throws here, which surfaces as a mount error in /admin.
|
// An invalid host/URL throws here, which surfaces as a mount error in /admin.
|
||||||
const target = new URL(config.prodcomUrl || 'http://10.3.11.152:24480');
|
const target = resolveTarget(config) || new URL('http://10.3.11.152:24480');
|
||||||
log(`proxying /prodcom/* → ${target.origin}`);
|
log(`proxying /prodcom/* → ${target.origin}`);
|
||||||
return {
|
return {
|
||||||
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
||||||
|
|||||||
@@ -9,15 +9,10 @@
|
|||||||
"minSize": { "w": 3, "h": 3 },
|
"minSize": { "w": 3, "h": 3 },
|
||||||
"defaultSize": { "w": 4, "h": 5 },
|
"defaultSize": { "w": 4, "h": 5 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"host": {
|
"propresenter": {
|
||||||
"type": "string",
|
"type": "endpoint",
|
||||||
"label": "ProPresenter host / IP",
|
"label": "ProPresenter",
|
||||||
"default": ""
|
"default": { "host": "", "port": 1025 }
|
||||||
},
|
|
||||||
"port": {
|
|
||||||
"type": "number",
|
|
||||||
"label": "ProPresenter network port",
|
|
||||||
"default": 1025
|
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ let current = null;
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log }) {
|
||||||
const streams = new Set();
|
const streams = new Set();
|
||||||
const enabled = Boolean(config.host && Number(config.port));
|
// Endpoint config. Legacy flat host/port keys (saved before the endpoint
|
||||||
|
// field existed) can only still be present until the admin form is
|
||||||
|
// re-saved, so when they exist they win over the endpoint's default.
|
||||||
|
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||||
|
const host = String(config.host ?? ep.host ?? '');
|
||||||
|
const port = Number(config.port ?? ep.port) || 1025;
|
||||||
|
const enabled = Boolean(host && port);
|
||||||
let latest = clearedViewModel();
|
let latest = clearedViewModel();
|
||||||
let lastFrame = '';
|
let lastFrame = '';
|
||||||
|
|
||||||
@@ -54,14 +60,14 @@ module.exports = {
|
|||||||
|
|
||||||
client.configure({
|
client.configure({
|
||||||
enabled,
|
enabled,
|
||||||
host: String(config.host || ''),
|
host,
|
||||||
port: Number(config.port) || 1025,
|
port,
|
||||||
password: String(config.password || ''),
|
password: String(config.password || ''),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
client.start();
|
client.start();
|
||||||
log(`following ProPresenter at ${config.host}:${config.port}`);
|
log(`following ProPresenter at ${host}:${port}`);
|
||||||
} else {
|
} else {
|
||||||
log('no ProPresenter host configured — set one in /admin');
|
log('no ProPresenter host configured — set one in /admin');
|
||||||
}
|
}
|
||||||
@@ -91,7 +97,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
||||||
if (latest.reachable) return { status: 'ok', message: `Following ${config.host}:${config.port}` };
|
if (latest.reachable) return { status: 'ok', message: `Following ${host}:${port}` };
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: latest.lastError
|
message: latest.lastError
|
||||||
|
|||||||
@@ -9,15 +9,10 @@
|
|||||||
"minSize": { "w": 2, "h": 1 },
|
"minSize": { "w": 2, "h": 1 },
|
||||||
"defaultSize": { "w": 4, "h": 2 },
|
"defaultSize": { "w": 4, "h": 2 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"host": {
|
"propresenter": {
|
||||||
"type": "string",
|
"type": "endpoint",
|
||||||
"label": "ProPresenter host / IP",
|
"label": "ProPresenter",
|
||||||
"default": ""
|
"default": { "host": "", "port": 1025 }
|
||||||
},
|
|
||||||
"port": {
|
|
||||||
"type": "number",
|
|
||||||
"label": "ProPresenter network port",
|
|
||||||
"default": 1025
|
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ function clearedState(enabled) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log }) {
|
||||||
const streams = new Set();
|
const streams = new Set();
|
||||||
const host = String(config.host || '');
|
// Endpoint config. Legacy flat host/port keys (saved before the endpoint
|
||||||
const port = Number(config.port) || 1025;
|
// field existed) can only still be present until the admin form is
|
||||||
|
// re-saved, so when they exist they win over the endpoint's default.
|
||||||
|
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
|
||||||
|
const host = String(config.host ?? ep.host ?? '');
|
||||||
|
const port = Number(config.port ?? ep.port) || 1025;
|
||||||
const password = String(config.password || '');
|
const password = String(config.password || '');
|
||||||
const enabled = Boolean(host && port);
|
const enabled = Boolean(host && port);
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ body { overflow: auto; }
|
|||||||
|
|
||||||
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
.module-form .field { max-width: 420px; }
|
.module-form .field { max-width: 420px; }
|
||||||
|
|
||||||
|
/* endpoint fields: every module's upstream is entered the same way */
|
||||||
|
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.endpoint-row .ep-host { flex: 1; min-width: 0; }
|
||||||
|
.endpoint-row .ep-port { width: 92px; flex: none; }
|
||||||
|
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
|
||||||
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
||||||
|
|
||||||
.module-actions {
|
.module-actions {
|
||||||
|
|||||||
@@ -96,6 +96,32 @@ function fieldFor(key, spec, value, passwordSet) {
|
|||||||
input.checked = Boolean(value);
|
input.checked = Boolean(value);
|
||||||
field.append(input, document.createTextNode(label));
|
field.append(input, document.createTextNode(label));
|
||||||
read = () => input.checked;
|
read = () => input.checked;
|
||||||
|
} 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)) {
|
} else if (type === 'select' && Array.isArray(spec.options)) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
|
|||||||
@@ -413,7 +413,13 @@ function applyConfigPatch(id, patch) {
|
|||||||
}
|
}
|
||||||
if (type === 'number') value = Number(value) || 0;
|
if (type === 'number') value = Number(value) || 0;
|
||||||
else if (type === 'boolean') value = Boolean(value);
|
else if (type === 'boolean') value = Boolean(value);
|
||||||
else value = value === undefined || value === null ? '' : String(value);
|
else if (type === 'endpoint') {
|
||||||
|
const src = value && typeof value === 'object' ? value : {};
|
||||||
|
value = {
|
||||||
|
host: String(src.host ?? '').trim(),
|
||||||
|
port: Math.max(0, Math.min(65535, Math.trunc(Number(src.port)) || 0)),
|
||||||
|
};
|
||||||
|
} else value = value === undefined || value === null ? '' : String(value);
|
||||||
next[key] = value;
|
next[key] = value;
|
||||||
}
|
}
|
||||||
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };
|
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };
|
||||||
|
|||||||
Reference in New Issue
Block a user