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:
co-authored by
Claude Fable 5.1
parent
ec178e9ccd
commit
96eff1a5c5
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user