prodcom-transcript module: ProdCom proxy + live transcript tile
Milestone 3. Server part proxies /prodcom/* (SSE passthrough included) to the admin-configured ProdCom URL, adding the Bearer key server-side so it never reaches browsers; reports proxy health for the admin page. Client ports the prodcom-listener UI into the module contract: history load, live SSE with in-place updates of in-progress speech, channel colors, channel visibility toggles + group presets (persisted per tile), hard channel filter / text size / timestamps as instance settings, auto-scroll with a jump-to-latest pill. tools/prodcom-mock.js simulates ProdCom (word-by-word in-progress speech) for testing; verified against it, including kill-and-restart recovery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
@@ -0,0 +1,403 @@
|
|||||||
|
/* ProdCom Transcript — client part.
|
||||||
|
|
||||||
|
Ported from prodcom-listener/public/app.js into the ProdDash module
|
||||||
|
contract: everything lives inside the tile's root, per-tile state
|
||||||
|
(hidden channels, timestamps, text size) persists in instance settings,
|
||||||
|
and all network traffic goes through moduleApi (the module's server
|
||||||
|
proxy at /prodcom/*). */
|
||||||
|
|
||||||
|
export default function create({ root, moduleApi }) {
|
||||||
|
/* ── per-instance state ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const channels = new Map(); // channelId -> {name, color}
|
||||||
|
const groups = new Map(); // groupId -> {name, channelIds}
|
||||||
|
const entryEls = new Map(); // entryId -> element
|
||||||
|
let pinnedToBottom = true;
|
||||||
|
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
||||||
|
let stream = null; // moduleApi.sse handle
|
||||||
|
let bootTimer = null;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="pt-wrap">
|
||||||
|
<div class="pt-bar">
|
||||||
|
<div class="pt-dd">
|
||||||
|
<button class="pt-btn pt-channels-btn" title="Choose visible channels">Channels ▾</button>
|
||||||
|
<div class="pt-menu" hidden></div>
|
||||||
|
</div>
|
||||||
|
<span class="pt-spacer"></span>
|
||||||
|
<button class="pt-btn pt-clear" title="Clear this tile (does not affect ProdCom)">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="pt-stream">
|
||||||
|
<div class="pt-empty">Waiting for transcript…</div>
|
||||||
|
<div class="pt-entries"></div>
|
||||||
|
</div>
|
||||||
|
<button class="pt-jump" hidden>↓ New messages</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const wrap = root.querySelector('.pt-wrap');
|
||||||
|
const streamEl = root.querySelector('.pt-stream');
|
||||||
|
const entriesEl = root.querySelector('.pt-entries');
|
||||||
|
const emptyEl = root.querySelector('.pt-empty');
|
||||||
|
const jumpBtn = root.querySelector('.pt-jump');
|
||||||
|
const channelBtn = root.querySelector('.pt-channels-btn');
|
||||||
|
const channelMenu = root.querySelector('.pt-menu');
|
||||||
|
|
||||||
|
function applyInstanceSettings() {
|
||||||
|
const s = moduleApi.instanceSettings;
|
||||||
|
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||||
|
wrap.style.setProperty('--pt-size', size + 'px');
|
||||||
|
wrap.classList.toggle('show-times', Boolean(s.showTimes));
|
||||||
|
}
|
||||||
|
applyInstanceSettings();
|
||||||
|
|
||||||
|
/* ── channel filter (instance setting) ──────────────────────────── */
|
||||||
|
|
||||||
|
function channelFilter() {
|
||||||
|
return String(moduleApi.instanceSettings.channel || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entry hidden by the hard per-tile filter (settings.channel)? */
|
||||||
|
function filteredOut(channelId, channelName) {
|
||||||
|
const want = channelFilter();
|
||||||
|
if (!want) return false;
|
||||||
|
const known = channels.get(channelId);
|
||||||
|
const name = ((known && known.name) || channelName || '').toLowerCase();
|
||||||
|
return name !== want && String(channelId).toLowerCase() !== want;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── channel visibility toggles ─────────────────────────────────── */
|
||||||
|
|
||||||
|
function persistHidden() {
|
||||||
|
moduleApi.saveInstanceSettings({ hiddenChannels: [...hiddenChannels] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter(el) {
|
||||||
|
const hidden = hiddenChannels.has(el.dataset.channelId)
|
||||||
|
|| filteredOut(el.dataset.channelId, el.dataset.channelName);
|
||||||
|
el.classList.toggle('hidden-by-filter', hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChannel(id) {
|
||||||
|
if (hiddenChannels.has(id)) hiddenChannels.delete(id);
|
||||||
|
else hiddenChannels.add(id);
|
||||||
|
persistHidden();
|
||||||
|
rebuildChannelMenu();
|
||||||
|
entryEls.forEach((el) => applyFilter(el));
|
||||||
|
if (pinnedToBottom) scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show exactly this group's channels, hide the rest */
|
||||||
|
function applyGroup(g) {
|
||||||
|
hiddenChannels = new Set([...channels.keys()].filter((cid) => !g.channelIds.includes(cid)));
|
||||||
|
persistHidden();
|
||||||
|
rebuildChannelMenu();
|
||||||
|
entryEls.forEach((el) => applyFilter(el));
|
||||||
|
if (pinnedToBottom) scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupIsActive(g) {
|
||||||
|
const members = g.channelIds.filter((cid) => channels.has(cid));
|
||||||
|
if (!members.length) return false;
|
||||||
|
return [...channels.keys()].every((cid) =>
|
||||||
|
members.includes(cid) ? !hiddenChannels.has(cid) : hiddenChannels.has(cid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildChannelMenu() {
|
||||||
|
channelMenu.innerHTML = '';
|
||||||
|
|
||||||
|
if (groups.size) {
|
||||||
|
for (const g of groups.values()) {
|
||||||
|
const on = groupIsActive(g);
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'pt-channel-item pt-group-item' + (on ? ' on' : '');
|
||||||
|
item.setAttribute('role', 'menuitemradio');
|
||||||
|
item.setAttribute('aria-checked', String(on));
|
||||||
|
const check = document.createElement('span');
|
||||||
|
check.className = 'check';
|
||||||
|
check.textContent = '✓';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = g.name;
|
||||||
|
item.append(check, name);
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
applyGroup(g);
|
||||||
|
});
|
||||||
|
channelMenu.appendChild(item);
|
||||||
|
}
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'pt-menu-divider';
|
||||||
|
channelMenu.appendChild(divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
let visibleCount = 0;
|
||||||
|
for (const [id, info] of channels) {
|
||||||
|
const on = !hiddenChannels.has(id);
|
||||||
|
if (on) visibleCount++;
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||||||
|
item.setAttribute('role', 'menuitemcheckbox');
|
||||||
|
item.setAttribute('aria-checked', String(on));
|
||||||
|
const check = document.createElement('span');
|
||||||
|
check.className = 'check';
|
||||||
|
check.textContent = '✓';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = info.name;
|
||||||
|
name.style.color = info.color;
|
||||||
|
item.append(check, name);
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
// keep the menu open: the rebuild detaches this item, which would
|
||||||
|
// make the outside-click handler think we clicked outside
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleChannel(id);
|
||||||
|
});
|
||||||
|
channelMenu.appendChild(item);
|
||||||
|
}
|
||||||
|
channelBtn.textContent = visibleCount === channels.size
|
||||||
|
? 'Channels ▾'
|
||||||
|
: `Channels (${visibleCount}/${channels.size}) ▾`;
|
||||||
|
}
|
||||||
|
|
||||||
|
channelBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
channelMenu.hidden = !channelMenu.hidden;
|
||||||
|
});
|
||||||
|
const onDocClick = (e) => {
|
||||||
|
if (!channelMenu.hidden && !e.target.closest('.pt-dd')) channelMenu.hidden = true;
|
||||||
|
};
|
||||||
|
document.addEventListener('click', onDocClick);
|
||||||
|
|
||||||
|
/* ── clear (local view only) ────────────────────────────────────── */
|
||||||
|
|
||||||
|
root.querySelector('.pt-clear').addEventListener('click', () => {
|
||||||
|
entryEls.forEach((el) => el.remove());
|
||||||
|
entryEls.clear();
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── scrolling ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
streamEl.addEventListener('scroll', () => {
|
||||||
|
const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
|
||||||
|
pinnedToBottom = nearBottom;
|
||||||
|
if (nearBottom) jumpBtn.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
jumpBtn.addEventListener('click', () => {
|
||||||
|
pinnedToBottom = true;
|
||||||
|
jumpBtn.hidden = true;
|
||||||
|
scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
streamEl.scrollTop = streamEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterInsert(el) {
|
||||||
|
if (el.classList.contains('hidden-by-filter')) return;
|
||||||
|
if (pinnedToBottom) scrollToBottom();
|
||||||
|
else jumpBtn.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── entry rendering ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function channelInfo(entry) {
|
||||||
|
const known = channels.get(entry.channelId);
|
||||||
|
return {
|
||||||
|
name: (known && known.name) || entry.channelName || 'Unknown',
|
||||||
|
color: (known && known.color) || 'var(--muted)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso) {
|
||||||
|
const d = iso ? new Date(iso) : new Date();
|
||||||
|
return isNaN(d) ? '' : d.toLocaleTimeString([], { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertEntry(entry) {
|
||||||
|
if (!entry || typeof entry.text !== 'string') return;
|
||||||
|
const id = entry.id || entry.channelId + '|' + entry.date;
|
||||||
|
|
||||||
|
let el = entryEls.get(id);
|
||||||
|
if (!el) {
|
||||||
|
emptyEl.hidden = true;
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.className = 'pt-entry';
|
||||||
|
el.dataset.channelId = entry.channelId || '';
|
||||||
|
el.dataset.channelName = entry.channelName || '';
|
||||||
|
const info = channelInfo(entry);
|
||||||
|
el.style.setProperty('--ch', info.color);
|
||||||
|
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.className = 'time';
|
||||||
|
time.textContent = fmtTime(entry.date);
|
||||||
|
|
||||||
|
const avatar = document.createElement('span');
|
||||||
|
avatar.className = 'avatar';
|
||||||
|
avatar.style.color = info.color;
|
||||||
|
avatar.innerHTML =
|
||||||
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
|
||||||
|
'<circle cx="12" cy="8.5" r="3.2"/>' +
|
||||||
|
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
|
||||||
|
|
||||||
|
const ch = document.createElement('span');
|
||||||
|
ch.className = 'channel';
|
||||||
|
ch.textContent = info.name;
|
||||||
|
ch.style.color = info.color;
|
||||||
|
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.className = 'text';
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'body';
|
||||||
|
body.append(ch, text);
|
||||||
|
|
||||||
|
el.append(time, avatar, body);
|
||||||
|
entryEls.set(id, el);
|
||||||
|
applyFilter(el);
|
||||||
|
entriesEl.appendChild(el);
|
||||||
|
updateEntryEl(el, entry);
|
||||||
|
afterInsert(el);
|
||||||
|
} else {
|
||||||
|
updateEntryEl(el, entry);
|
||||||
|
if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEntryEl(el, entry) {
|
||||||
|
el.querySelector('.text').textContent = entry.translatedText || entry.text;
|
||||||
|
el.classList.toggle('in-progress', entry.inProgress === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SSE event parsing ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* 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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
groups.clear();
|
||||||
|
for (const g of body.data || []) {
|
||||||
|
groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] });
|
||||||
|
}
|
||||||
|
rebuildChannelMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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', {
|
||||||
|
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(() => {});
|
||||||
|
},
|
||||||
|
error() {
|
||||||
|
moduleApi.setStatus('error', 'ProdCom unreachable — reconnecting…');
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
scrollToBottom();
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
boot();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(bootTimer);
|
||||||
|
stream?.close();
|
||||||
|
document.removeEventListener('click', onDocClick);
|
||||||
|
root.innerHTML = '';
|
||||||
|
},
|
||||||
|
onResize() {
|
||||||
|
if (pinnedToBottom) scrollToBottom();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"id": "prodcom-transcript",
|
||||||
|
"name": "ProdCom Transcript",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Live ProdCom transcript stream",
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": { "w": 3, "h": 2 },
|
||||||
|
"defaultSize": { "w": 4, "h": 4 },
|
||||||
|
"configSchema": {
|
||||||
|
"prodcomUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "ProdCom URL",
|
||||||
|
"default": "http://10.3.11.152:24480"
|
||||||
|
},
|
||||||
|
"apiKey": {
|
||||||
|
"type": "password",
|
||||||
|
"label": "API key (only if PSK auth is enabled in ProdCom)",
|
||||||
|
"default": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instanceSchema": {
|
||||||
|
"channel": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Channel filter (channel name; empty = all)",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"textSize": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Text size (px)",
|
||||||
|
"default": 15
|
||||||
|
},
|
||||||
|
"showTimes": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Show timestamps",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProdCom Transcript — server part.
|
||||||
|
*
|
||||||
|
* One wildcard route that proxies /prodcom/* (including the SSE transcript
|
||||||
|
* stream) to the ProdCom API configured in the admin page, so browsers never
|
||||||
|
* talk to ProdCom directly (no CORS, and the API key never reaches clients).
|
||||||
|
* Ported from prodcom-listener/server.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
// Hop-by-hop headers that must not be forwarded
|
||||||
|
const HOP_HEADERS = new Set([
|
||||||
|
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
||||||
|
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Health shown in the admin page: outcome of the most recent proxied request. */
|
||||||
|
let health = { status: 'connecting', message: 'No traffic yet' };
|
||||||
|
|
||||||
|
function proxy(req, res, target, apiKey) {
|
||||||
|
const upstreamPath = (req.wildcard || '/') + (req.search || '');
|
||||||
|
const headers = {};
|
||||||
|
for (const [k, v] of Object.entries(req.headers)) {
|
||||||
|
if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v;
|
||||||
|
}
|
||||||
|
headers.host = target.host;
|
||||||
|
delete headers['accept-encoding']; // keep SSE unbuffered and simple
|
||||||
|
if (apiKey) headers.authorization = 'Bearer ' + apiKey;
|
||||||
|
|
||||||
|
const preq = http.request(
|
||||||
|
{
|
||||||
|
hostname: target.hostname,
|
||||||
|
port: target.port || 80,
|
||||||
|
path: upstreamPath,
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
(pres) => {
|
||||||
|
health = { status: 'ok', message: `ProdCom reachable (last: HTTP ${pres.statusCode})` };
|
||||||
|
const outHeaders = {};
|
||||||
|
for (const [k, v] of Object.entries(pres.headers)) {
|
||||||
|
if (!HOP_HEADERS.has(k.toLowerCase())) outHeaders[k] = v;
|
||||||
|
}
|
||||||
|
res.writeHead(pres.statusCode, outHeaders);
|
||||||
|
// Flush immediately — SSE streams may send no bytes for a while, and
|
||||||
|
// the browser needs the headers to consider the stream open.
|
||||||
|
res.flushHeaders();
|
||||||
|
pres.pipe(res);
|
||||||
|
// If ProdCom dies mid-stream, pipe() unpipes on error without ending
|
||||||
|
// the browser's response, leaving SSE clients hanging on a dead
|
||||||
|
// stream. Destroy it so they notice immediately and reconnect.
|
||||||
|
pres.on('close', () => {
|
||||||
|
if (!res.writableEnded) res.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
preq.on('error', (e) => {
|
||||||
|
health = { status: 'error', message: 'ProdCom unreachable: ' + (e.code || String(e)) };
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
}
|
||||||
|
res.end(JSON.stringify({ error: 'ProdCom unreachable', detail: e.code || String(e) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tear down the upstream connection when the browser disconnects
|
||||||
|
res.on('close', () => preq.destroy());
|
||||||
|
req.pipe(preq);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config }) {
|
||||||
|
health = config.prodcomUrl
|
||||||
|
? { status: 'connecting', message: 'No traffic yet' }
|
||||||
|
: { status: 'error', message: 'No ProdCom URL configured' };
|
||||||
|
return {
|
||||||
|
stop() { /* nothing persistent to tear down — streams die with their sockets */ },
|
||||||
|
health: () => health,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
routes({ config, log }) {
|
||||||
|
// An invalid URL throws here, which surfaces as a mount error in /admin.
|
||||||
|
const target = new URL(config.prodcomUrl || 'http://10.3.11.152:24480');
|
||||||
|
log(`proxying /prodcom/* → ${target.origin}`);
|
||||||
|
return {
|
||||||
|
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/* ProdCom Transcript module — scoped to its tiles, shell variables only.
|
||||||
|
Entry styling carried over from prodcom-listener/public/style.css. */
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-wrap {
|
||||||
|
--pt-size: 15px;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── toolbar ── */
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-bar {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-spacer { flex: 1; }
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-btn {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="prodcom-transcript"] .pt-btn:hover { border-color: var(--muted); }
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-dd { position: relative; }
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 5px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
|
min-width: 170px;
|
||||||
|
max-height: 45vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
[data-module="prodcom-transcript"] .pt-menu[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-channel-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 9px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
[data-module="prodcom-transcript"] .pt-channel-item:hover { background: var(--border); }
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-channel-item .check {
|
||||||
|
width: 14px;
|
||||||
|
flex: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
[data-module="prodcom-transcript"] .pt-channel-item.on .check { visibility: visible; }
|
||||||
|
[data-module="prodcom-transcript"] .pt-channel-item .name { font-weight: 600; }
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 5px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── stream ── */
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-stream {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 8px 10px 12px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 14%;
|
||||||
|
}
|
||||||
|
[data-module="prodcom-transcript"] .pt-empty[hidden] { display: none; }
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: var(--pt-size);
|
||||||
|
line-height: 1.45;
|
||||||
|
animation: pt-fade-in 0.25s ease-out;
|
||||||
|
}
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry:hover { background: var(--bg); }
|
||||||
|
|
||||||
|
@keyframes pt-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry .time {
|
||||||
|
display: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex: none;
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
[data-module="prodcom-transcript"] .show-times .pt-entry .time { display: block; }
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry .avatar {
|
||||||
|
flex: none;
|
||||||
|
width: calc(var(--pt-size) * 1.9);
|
||||||
|
height: calc(var(--pt-size) * 1.9);
|
||||||
|
border-radius: 26%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* ~17% alpha tint of the channel color */
|
||||||
|
background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent);
|
||||||
|
}
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; }
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry .channel {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.82em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry .text {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry.in-progress .text { color: var(--muted); }
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry.in-progress .text::after {
|
||||||
|
content: "▍";
|
||||||
|
color: var(--ch, var(--accent));
|
||||||
|
animation: pt-blink 1s steps(2) infinite;
|
||||||
|
}
|
||||||
|
@keyframes pt-blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; }
|
||||||
|
|
||||||
|
/* ── jump-to-latest ── */
|
||||||
|
|
||||||
|
[data-module="prodcom-transcript"] .pt-jump {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 6;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
[data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; }
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Mock ProdCom API for verifying the prodcom-transcript module without a
|
||||||
|
* live ProdCom. Serves the endpoints the module uses — channels, groups,
|
||||||
|
* transcript history and the SSE stream — and simulates live speech:
|
||||||
|
* entries appear in progress, grow word by word (same id, in-place update),
|
||||||
|
* then complete.
|
||||||
|
*
|
||||||
|
* node tools/prodcom-mock.js [port] (default 24480)
|
||||||
|
*
|
||||||
|
* Point the module's admin config at http://127.0.0.1:<port>. Kill and
|
||||||
|
* restart this process while tiles are open to watch them degrade and
|
||||||
|
* recover on their own.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const PORT = Number.parseInt(process.argv[2] || '24480', 10);
|
||||||
|
const STEP_MS = Number.parseInt(process.env.PRODCOM_STEP_MS || '700', 10);
|
||||||
|
|
||||||
|
const CHANNELS = [
|
||||||
|
{ id: 'ch-stage', name: 'Stage', color: '#e0645c' },
|
||||||
|
{ id: 'ch-foh', name: 'FOH', color: '#5c9de0' },
|
||||||
|
{ id: 'ch-video', name: 'Video', color: '#5cd08c' },
|
||||||
|
{ id: 'ch-light', name: 'Lighting', color: '#e0b45c' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUPS = [
|
||||||
|
{ id: 'g-band', name: 'Band', channelIds: ['ch-stage', 'ch-foh'] },
|
||||||
|
{ id: 'g-tech', name: 'Tech', channelIds: ['ch-video', 'ch-light'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PHRASES = [
|
||||||
|
'Standby for the walk-in loop.',
|
||||||
|
'Vocals are hot on channel two, trim it down a touch please.',
|
||||||
|
'Camera three, give me a slow push on the worship leader.',
|
||||||
|
'House to half, stage wash up for the next song.',
|
||||||
|
'That was the last chorus, transition cue is next.',
|
||||||
|
'Confidence monitor is frozen, refreshing it now.',
|
||||||
|
'Great job everyone, that transition was clean.',
|
||||||
|
'Pastor is walking up in thirty seconds.',
|
||||||
|
];
|
||||||
|
|
||||||
|
let history = []; // completed + in-progress entries, oldest first
|
||||||
|
// Unique across mock restarts, so a reconnecting client's dedupe-by-id
|
||||||
|
// treats post-restart entries as new (like real ProdCom ids would be).
|
||||||
|
const RUN = Date.now().toString(36);
|
||||||
|
let nextId = 1;
|
||||||
|
const streams = new Set();
|
||||||
|
|
||||||
|
/** The one in-progress utterance, or null. */
|
||||||
|
let speaking = null; // { entry, words, at }
|
||||||
|
|
||||||
|
function broadcast(entry) {
|
||||||
|
const frame = 'data: ' + JSON.stringify({ type: entry.inProgress ? 'transcript.update' : 'transcript.completed', entry }) + '\n\n';
|
||||||
|
for (const res of streams) {
|
||||||
|
try { res.write(frame); } catch { streams.delete(res); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepSimulation() {
|
||||||
|
if (!speaking) {
|
||||||
|
const channel = CHANNELS[Math.floor(Math.random() * CHANNELS.length)];
|
||||||
|
const words = PHRASES[Math.floor(Math.random() * PHRASES.length)].split(' ');
|
||||||
|
const entry = {
|
||||||
|
id: 'e' + RUN + '-' + nextId++,
|
||||||
|
channelId: channel.id,
|
||||||
|
channelName: channel.name,
|
||||||
|
text: words[0],
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
inProgress: true,
|
||||||
|
};
|
||||||
|
speaking = { entry, words, at: 1 };
|
||||||
|
history.push(entry);
|
||||||
|
if (history.length > 200) history.shift();
|
||||||
|
broadcast(entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { entry, words } = speaking;
|
||||||
|
if (speaking.at < words.length) {
|
||||||
|
entry.text = words.slice(0, ++speaking.at).join(' ');
|
||||||
|
broadcast(entry);
|
||||||
|
} else {
|
||||||
|
entry.inProgress = false;
|
||||||
|
broadcast(entry);
|
||||||
|
console.log(`[prodcom-mock] ${entry.channelName}: ${entry.text}`);
|
||||||
|
speaking = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(stepSimulation, STEP_MS);
|
||||||
|
|
||||||
|
function sendJson(res, body) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, `http://x.invalid`);
|
||||||
|
const p = url.pathname;
|
||||||
|
|
||||||
|
if (p === '/api/v1/channels') return sendJson(res, { data: CHANNELS });
|
||||||
|
if (p === '/api/v1/groups') return sendJson(res, { data: GROUPS });
|
||||||
|
if (p === '/api/v1/transcript') {
|
||||||
|
const limit = Number.parseInt(url.searchParams.get('limit') || '100', 10) || 100;
|
||||||
|
return sendJson(res, { data: history.slice(-limit) });
|
||||||
|
}
|
||||||
|
if (p === '/api/v1/transcript/stream') {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
|
res.write('retry: 3000\n\n');
|
||||||
|
streams.add(res);
|
||||||
|
console.log(`[prodcom-mock] stream client connected (${streams.size})`);
|
||||||
|
res.on('close', () => {
|
||||||
|
streams.delete(res);
|
||||||
|
console.log(`[prodcom-mock] stream client left (${streams.size})`);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'not found' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.requestTimeout = 0;
|
||||||
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`[prodcom-mock] ProdCom mock API on http://127.0.0.1:${PORT} (step ${STEP_MS}ms)`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user