ProdCom 1.3.0: one connection to ProdCom, shared by every dashboard

The module used to proxy each browser's transcript stream to ProdCom one
to one, so N screens meant N upstream connections and N history reads.
It now follows ProdCom once: it keeps the channel list, the groups and
the last 300 entries, and gives every browser a snapshot on connect
(`state`) plus each changed entry as it happens (`entry`), with `clear`
and `status` frames as the link changes. Reconnects back off from 2 s to
15 s, re-read the lists and history, and re-send the snapshot. The tile
no longer loads anything itself; the admin readout says how many channels
and entries the server holds. The /prodcom/* proxy is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-15 12:43:08 -04:00
co-authored by Claude Fable 5.1
parent ec178e9ccd
commit 96eff1a5c5
4 changed files with 340 additions and 172 deletions
+41 -93
View File
@@ -3,8 +3,9 @@
Ported from prodcom-listener/public/app.js into the ProdDash module
contract: everything lives inside the tile's root, per-tile state
(hidden channels, channel icons, timestamps, text size, flow direction)
persists in instance settings, and all network traffic goes through
moduleApi (the module's server proxy at /prodcom/*). */
persists in instance settings, and the data comes from the module's own
server, which keeps one connection to ProdCom for every dashboard: a
snapshot on connect (`state`), then each changed entry (`entry`). */
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
@@ -19,7 +20,6 @@ export default function create({ root, moduleApi }) {
let pinnedToLatest = true;
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
let stream = null; // moduleApi.sse handle
let bootTimer = null;
let stopped = false;
/* ── DOM skeleton ───────────────────────────────────────────────── */
@@ -353,120 +353,68 @@ export default function create({ root, moduleApi }) {
/* The stream sends JSON objects for new/updated/completed transcript
entries. Be liberal about the envelope shape. */
function extractEntries(obj) {
if (!obj || typeof obj !== 'object') return [];
if (Array.isArray(obj)) return obj.flatMap(extractEntries);
if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
if (obj[key] && typeof obj[key] === 'object') {
const found = extractEntries(obj[key]);
if (found.length) return found;
}
}
return [];
/* ── the module's feed: one snapshot, then each change ─────────── */
function applyStatus(st) {
if (!st || typeof st !== 'object') return;
if (st.state === 'ok') moduleApi.setStatus('ok', st.message || 'Live');
else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
}
function handleEventData(raw) {
let obj;
try { obj = JSON.parse(raw); } catch { return; }
const kind = String(obj.type || obj.event || '').toLowerCase();
if (kind.includes('clear')) {
entryEls.forEach((el) => el.remove());
entryEls.clear();
emptyEl.hidden = false;
return;
}
extractEntries(obj).forEach(upsertEntry);
}
/* ── data loading (all via the module's proxy) ──────────────────── */
async function loadChannels() {
const res = await moduleApi.fetch('/prodcom/api/v1/channels');
if (!res.ok) throw new Error('channels ' + res.status);
const body = await res.json();
function applySnapshot(snap) {
if (!snap || typeof snap !== 'object') return;
channels.clear();
for (const ch of body.data || []) {
channels.set(ch.id, { name: ch.name, color: ch.color || '#8b98a5' });
}
rebuildChannelMenu();
}
async function loadGroups() {
const res = await moduleApi.fetch('/prodcom/api/v1/groups');
if (!res.ok) throw new Error('groups ' + res.status);
const body = await res.json();
for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' });
groups.clear();
for (const g of body.data || []) {
groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] });
}
for (const g of snap.groups || []) groups.set(String(g.id), { name: g.name, channelIds: g.channelIds || [] });
rebuildChannelMenu();
const list = (snap.entries || []).slice().sort((a, b) => new Date(a.date) - new Date(b.date));
list.forEach(upsertEntry); // dedupes by id, so a snapshot after a reconnect is harmless
applyStatus(snap.status);
}
async function loadHistory() {
const res = await moduleApi.fetch('/prodcom/api/v1/transcript?limit=100');
if (!res.ok) throw new Error('transcript ' + res.status);
const body = await res.json();
const entries = (body.data || []).slice();
entries.sort((a, b) => new Date(a.date) - new Date(b.date));
entries.forEach(upsertEntry);
async function loadState() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) throw new Error(String(res.status));
applySnapshot(await res.json());
} catch { /* the stream's own snapshot covers it */ }
}
/* ── live stream ────────────────────────────────────────────────── */
const SSE_EVENT_NAMES = [
'transcript', 'transcript.new', 'transcript.updated', 'transcript.update',
'transcript.completed', 'transcript.complete', 'entry', 'new', 'update',
'updated', 'complete', 'completed',
];
function connect() {
const onEvent = (ev) => handleEventData(ev.data);
const events = {};
for (const name of SSE_EVENT_NAMES) events[name] = onEvent;
stream = moduleApi.sse('/prodcom/api/v1/transcript/stream', {
stream = moduleApi.sse('/stream', {
open() {
moduleApi.setStatus('ok', 'Live');
// refresh channels/groups and backfill entries missed while
// disconnected (upsertEntry dedupes by id, so this is harmless)
loadChannels().catch(() => {});
loadGroups().catch(() => {});
loadHistory().catch(() => {});
loadState(); // a late joiner, or a reconnect: catch up on what was missed
},
error() {
moduleApi.setStatus('error', 'ProdCom unreachable — reconnecting…');
moduleApi.setStatus('error', 'ProdDash ProdCom module unreachable — reconnecting…');
},
events: {
state(ev) {
try { applySnapshot(JSON.parse(ev.data)); } catch { /* a bad frame is skipped */ }
},
entry(ev) {
try { upsertEntry(JSON.parse(ev.data)); } catch { /* skipped */ }
},
clear() {
clearView();
},
status(ev) {
try { applyStatus(JSON.parse(ev.data)); } catch { /* skipped */ }
},
},
message: onEvent,
events,
});
}
/* ── lifecycle ──────────────────────────────────────────────────── */
async function boot() {
if (stopped) return;
moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
try {
await loadChannels();
await loadGroups().catch(() => {}); // groups are optional
await loadHistory();
} catch {
moduleApi.setStatus('error', 'ProdCom unreachable — retrying…');
bootTimer = setTimeout(boot, 5000);
return;
}
scrollToLatest();
connect();
}
return {
start() {
boot();
moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
connect();
},
stop() {
stopped = true;
clearTimeout(bootTimer);
stream?.close();
root.innerHTML = ''; // header controls are removed by the shell
},