Shell: the + picker opens at once, and tiles share their streams
The Add-tile menu would stop appearing once enough tiles were on the dashboard — a different count each time. The button's handler awaited GET /api/modules before revealing the menu, and that request could never complete: browsers allow about six HTTP/1.1 connections per host, an open EventSource holds one for as long as it lives, and every stream-fed tile (ProPresenter, Timers, PCO Plan, ProdCom) opened its own. With the shell's own /api/events that is the whole allowance at five or six such tiles, and every request after that — the picker's module list, a new tile's /state, its stream — sat in the browser's queue for a connection that was never handed back. The count varied because a stream in its 3-second retry loop frees a connection for a moment, and a clock tile holds none. Reproduced with six stream-fed tiles: the sixth stream and the picker's /api/modules both pend forever, and the menu stays hidden after the click. Three changes, all in the shell: - Dropdowns open the moment they are clicked, from what the page already knows, and slot in what their rebuild fetches when it lands. A rebuild that fails leaves the previous contents rather than an invisible menu, and a generation token stops a late fetch from repainting a menu that has since been closed or reopened (the old await also let two quick clicks cancel each other out). The + picker repaints only when the module list actually changed; the layout menu shows Save and Reset at once and inserts the server's layouts above Reset when they arrive. - Tiles asking for the same stream share one connection. moduleApi.sse() now subscribes to a shell-wide pool with one EventSource per URL, fanning events out to every subscriber and reconnecting on the same 3-second timer while anyone listens; a tile joining an open stream is told `open` straight away, so its backfill runs as before. Six tiles of three modules now hold three connections instead of six. Modules see no difference. - /api/modules and /api/layouts fetches give up after 8 s instead of pending forever, so nothing awaiting them can hang. Verified in the browser pane against a scratch server: the same six-tile layout now opens the picker instantly with all five modules listed and /api/modules completes; with the pool deliberately starved (three extra EventSources) the picker still opens from its cached list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a6dc2ddb85
commit
c46f98818e
+174
-70
@@ -71,12 +71,34 @@ function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' })
|
|||||||
|
|
||||||
/* ── dropdown menus ─────────────────────────────────────────────────── */
|
/* ── dropdown menus ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* A menu opens the moment its button is clicked, drawn from what the page
|
||||||
|
already knows; whatever its rebuild has to fetch is slotted in when it
|
||||||
|
lands. It used to open only once the rebuild had finished, which made the
|
||||||
|
+ picker hostage to a network round trip — one that never completes when
|
||||||
|
the tiles' streams hold every connection the browser allows (see the
|
||||||
|
shared-stream pool below). rebuild(menu, stillOpen) may be async;
|
||||||
|
stillOpen() says whether the open it was started for is still current,
|
||||||
|
so a late fetch can't repaint a menu that has since closed or reopened. */
|
||||||
function wireDropdown(btnId, menuId, rebuild) {
|
function wireDropdown(btnId, menuId, rebuild) {
|
||||||
const btn = document.getElementById(btnId);
|
const btn = document.getElementById(btnId);
|
||||||
const menu = document.getElementById(menuId);
|
const menu = document.getElementById(menuId);
|
||||||
btn.addEventListener('click', async () => {
|
let generation = 0;
|
||||||
if (menu.hidden) await rebuild(menu);
|
btn.addEventListener('click', () => {
|
||||||
menu.hidden = !menu.hidden;
|
if (!menu.hidden) {
|
||||||
|
menu.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gen = ++generation;
|
||||||
|
const stillOpen = () => gen === generation && !menu.hidden;
|
||||||
|
menu.hidden = false;
|
||||||
|
// Whatever the rebuild does, the menu is on screen; a failure (now or
|
||||||
|
// later) leaves its previous contents rather than an invisible menu.
|
||||||
|
const failed = (err) => console.error(`[shell] ${menuId} could not be rebuilt:`, err);
|
||||||
|
try {
|
||||||
|
Promise.resolve(rebuild(menu, stillOpen)).catch(failed);
|
||||||
|
} catch (err) {
|
||||||
|
failed(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) {
|
if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) {
|
||||||
@@ -106,11 +128,22 @@ function menuItem(label, onClick, { sub = '', danger = false } = {}) {
|
|||||||
|
|
||||||
let registry = new Map(); // module id -> manifest from GET /api/modules
|
let registry = new Map(); // module id -> manifest from GET /api/modules
|
||||||
|
|
||||||
|
/** fetch that gives up after `ms`. A request the browser has queued behind
|
||||||
|
held connections would otherwise pend forever, and so would anything
|
||||||
|
awaiting it. */
|
||||||
|
function fetchWithTimeout(url, ms, opts = {}) {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), ms);
|
||||||
|
return fetch(url, { ...opts, signal: ctl.signal }).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
/** Refresh the enabled-module list. On failure the last known list is kept
|
/** Refresh the enabled-module list. On failure the last known list is kept
|
||||||
(the server may just be restarting mid-service). Returns true on success. */
|
(the server may just be restarting mid-service). Returns true on success. */
|
||||||
async function loadRegistry() {
|
async function loadRegistry() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/modules');
|
// Slow modules' tiles() are cut off server-side at 2 s, so a healthy
|
||||||
|
// answer is well inside this.
|
||||||
|
const res = await fetchWithTimeout('/api/modules', 8000);
|
||||||
if (!res.ok) throw new Error('modules ' + res.status);
|
if (!res.ok) throw new Error('modules ' + res.status);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
registry = new Map((body.modules || []).map((m) => [m.id, m]));
|
registry = new Map((body.modules || []).map((m) => [m.id, m]));
|
||||||
@@ -753,6 +786,84 @@ function tileMessage(entry, text) {
|
|||||||
entry.body.appendChild(msg);
|
entry.body.appendChild(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── shared event streams ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Browsers allow about six HTTP/1.1 connections to one host, and an open
|
||||||
|
EventSource holds one of them for as long as it lives. One stream per
|
||||||
|
tile plus the shell's own /api/events reaches that ceiling at five or six
|
||||||
|
tiles, and from then on every further request — the + picker's module
|
||||||
|
list, a new tile's /state, its stream — sits in the browser's queue
|
||||||
|
waiting for a connection that is never handed back. So tiles that ask
|
||||||
|
for the same stream share one connection: the pool keeps one EventSource
|
||||||
|
per URL, fans its events out to every subscriber, and reconnects it (on
|
||||||
|
the same 3-second timer the per-tile version used) while anyone is still
|
||||||
|
listening. A tile joining an already-open stream is told `open` straight
|
||||||
|
away, so its backfill runs exactly as if the connection were its own. */
|
||||||
|
|
||||||
|
const ssePool = new Map(); // url -> shared source
|
||||||
|
|
||||||
|
function sseSubscribe(url, handlers = {}) {
|
||||||
|
let shared = ssePool.get(url);
|
||||||
|
if (!shared) {
|
||||||
|
shared = { subs: new Set(), es: null, retryTimer: null, names: new Set() };
|
||||||
|
const fanOut = (kind, e) => {
|
||||||
|
for (const sub of [...shared.subs]) {
|
||||||
|
const fn = kind === 'event' ? sub.handlers.events?.[e.type] : sub.handlers[kind];
|
||||||
|
try { fn?.(e); } catch (err) { console.error('[shell] stream handler failed:', err); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
shared.listen = (name) => shared.es.addEventListener(name, (e) => fanOut('event', e));
|
||||||
|
shared.connect = () => {
|
||||||
|
clearTimeout(shared.retryTimer);
|
||||||
|
shared.retryTimer = null;
|
||||||
|
try { shared.es?.close(); } catch { /* already closed */ }
|
||||||
|
const es = new EventSource(url);
|
||||||
|
shared.es = es;
|
||||||
|
es.onopen = (e) => fanOut('open', e);
|
||||||
|
es.onmessage = (e) => fanOut('message', e);
|
||||||
|
es.onerror = (e) => {
|
||||||
|
fanOut('error', e);
|
||||||
|
// EventSource retries transient drops itself but gives up for good on
|
||||||
|
// a completed non-SSE response (a 502 while the upstream is down).
|
||||||
|
if (es.readyState === EventSource.CLOSED && shared.subs.size) {
|
||||||
|
clearTimeout(shared.retryTimer);
|
||||||
|
shared.retryTimer = setTimeout(shared.connect, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const name of shared.names) shared.listen(name);
|
||||||
|
};
|
||||||
|
ssePool.set(url, shared);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = { handlers };
|
||||||
|
shared.subs.add(sub);
|
||||||
|
for (const name of Object.keys(handlers.events || {})) {
|
||||||
|
if (shared.names.has(name)) continue;
|
||||||
|
shared.names.add(name);
|
||||||
|
if (shared.es) shared.listen(name);
|
||||||
|
}
|
||||||
|
if (!shared.es || shared.es.readyState === EventSource.CLOSED) {
|
||||||
|
// first subscriber, or the stream had given up while nobody listened
|
||||||
|
shared.connect();
|
||||||
|
} else if (shared.es.readyState === EventSource.OPEN) {
|
||||||
|
// Joining mid-flight: report the open the way a fresh connection would
|
||||||
|
// have, a moment later, so the caller's own setup finishes first.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!shared.subs.has(sub)) return;
|
||||||
|
try { handlers.open?.(new Event('open')); } catch (err) { console.error('[shell] stream handler failed:', err); }
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
close() {
|
||||||
|
if (!shared.subs.delete(sub) || shared.subs.size) return;
|
||||||
|
clearTimeout(shared.retryTimer);
|
||||||
|
try { shared.es?.close(); } catch { /* already closed */ }
|
||||||
|
ssePool.delete(url);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The API handed to each module instance — the whole surface a module may
|
* The API handed to each module instance — the whole surface a module may
|
||||||
* touch outside its root element. See docs/MODULE-GUIDE.md.
|
* touch outside its root element. See docs/MODULE-GUIDE.md.
|
||||||
@@ -798,40 +909,17 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EventSource scoped the same way, with auto-reconnect: EventSource
|
* EventSource scoped the same way, with auto-reconnect: a stream that
|
||||||
* retries transient drops itself but gives up for good when a retry gets
|
* gives up (a 502 while the upstream is down) is recreated on a timer
|
||||||
* a completed non-SSE response (a 502 while the upstream is down), so a
|
* until it works again. Tiles asking for the same stream share one
|
||||||
* closed stream is recreated on a timer until it works again.
|
* connection — see the pool above; a module sees no difference.
|
||||||
* handlers: { open(e), error(e), message(e), events: { name: fn } }
|
* handlers: { open(e), error(e), message(e), events: { name: fn } }
|
||||||
*/
|
*/
|
||||||
sse(subPath, handlers = {}) {
|
sse(subPath, handlers = {}) {
|
||||||
const url = '/api/modules/' + man.id + subPath;
|
const shared = sseSubscribe('/api/modules/' + man.id + subPath, handlers);
|
||||||
let es = null;
|
|
||||||
let retryTimer = null;
|
|
||||||
let closed = false;
|
|
||||||
const connect = () => {
|
|
||||||
if (closed) return;
|
|
||||||
try { es?.close(); } catch { /* already closed */ }
|
|
||||||
es = new EventSource(url);
|
|
||||||
if (handlers.open) es.onopen = handlers.open;
|
|
||||||
if (handlers.message) es.onmessage = handlers.message;
|
|
||||||
for (const [name, fn] of Object.entries(handlers.events || {})) {
|
|
||||||
es.addEventListener(name, fn);
|
|
||||||
}
|
|
||||||
es.onerror = (e) => {
|
|
||||||
try { handlers.error?.(e); } catch { /* module's problem */ }
|
|
||||||
if (es.readyState === EventSource.CLOSED) {
|
|
||||||
clearTimeout(retryTimer);
|
|
||||||
retryTimer = setTimeout(connect, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
connect();
|
|
||||||
const handle = {
|
const handle = {
|
||||||
close() {
|
close() {
|
||||||
closed = true;
|
shared.close();
|
||||||
clearTimeout(retryTimer);
|
|
||||||
try { es?.close(); } catch { /* already closed */ }
|
|
||||||
sseHandles.delete(handle);
|
sseHandles.delete(handle);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1182,8 +1270,21 @@ function renderLayout(list) {
|
|||||||
|
|
||||||
/* ── menus ──────────────────────────────────────────────────────────── */
|
/* ── menus ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
wireDropdown('add-btn', 'add-menu', async (menu) => {
|
/* What the + picker shows, as a string: repainting a menu the user is
|
||||||
await loadRegistry();
|
already pointing at is only worth it when something in it changed. */
|
||||||
|
function addMenuSignature() {
|
||||||
|
return JSON.stringify([...registry.values()].map((m) => [m.id, m.name, m.description, m.tiles]));
|
||||||
|
}
|
||||||
|
|
||||||
|
wireDropdown('add-btn', 'add-menu', async (menu, stillOpen) => {
|
||||||
|
// Painted from the list already in hand, then refreshed: a module's tile
|
||||||
|
// list is live data, and Admin may have enabled or disabled something.
|
||||||
|
const shown = addMenuSignature();
|
||||||
|
renderAddMenu(menu);
|
||||||
|
if (await loadRegistry() && stillOpen() && addMenuSignature() !== shown) renderAddMenu(menu);
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderAddMenu(menu) {
|
||||||
menu.innerHTML = '';
|
menu.innerHTML = '';
|
||||||
const manifests = [...registry.values()];
|
const manifests = [...registry.values()];
|
||||||
if (!manifests.length) {
|
if (!manifests.length) {
|
||||||
@@ -1215,9 +1316,9 @@ wireDropdown('add-btn', 'add-menu', async (menu) => {
|
|||||||
}, { sub: man.description || '' }));
|
}, { sub: man.description || '' }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
wireDropdown('layout-btn', 'layout-menu', async (menu) => {
|
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||||
menu.innerHTML = '';
|
menu.innerHTML = '';
|
||||||
|
|
||||||
menu.appendChild(menuItem('Save as named layout…', async () => {
|
menu.appendChild(menuItem('Save as named layout…', async () => {
|
||||||
@@ -1250,43 +1351,46 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => {
|
|||||||
divider.className = 'menu-divider';
|
divider.className = 'menu-divider';
|
||||||
menu.appendChild(divider);
|
menu.appendChild(divider);
|
||||||
|
|
||||||
// named layouts stored on the server
|
const reset = menuItem('Reset layout', () => {
|
||||||
let layouts = [];
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/layouts');
|
|
||||||
if (res.ok) layouts = (await res.json()).layouts || [];
|
|
||||||
} catch { /* server briefly away — the menu just shows none */ }
|
|
||||||
if (layouts.length) {
|
|
||||||
const title = document.createElement('div');
|
|
||||||
title.className = 'menu-title';
|
|
||||||
title.textContent = 'Load from server';
|
|
||||||
menu.appendChild(title);
|
|
||||||
for (const l of layouts) {
|
|
||||||
menu.appendChild(menuItem(l.name, async () => {
|
|
||||||
menu.hidden = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
|
|
||||||
// Loading copies the layout into this browser's own state — it
|
|
||||||
// does not live-link browsers together.
|
|
||||||
renderLayout(body.layout.tiles || []);
|
|
||||||
saveLayout();
|
|
||||||
toast(`Loaded “${l.name}”`);
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message, true);
|
|
||||||
}
|
|
||||||
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
|
|
||||||
}
|
|
||||||
menu.appendChild(divider.cloneNode());
|
|
||||||
}
|
|
||||||
|
|
||||||
menu.appendChild(menuItem('Reset layout', () => {
|
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
renderLayout([]);
|
renderLayout([]);
|
||||||
saveLayout();
|
saveLayout();
|
||||||
toast('Layout cleared');
|
toast('Layout cleared');
|
||||||
}, { danger: true, sub: 'Remove every tile from this browser' }));
|
}, { danger: true, sub: 'Remove every tile from this browser' });
|
||||||
|
menu.appendChild(reset);
|
||||||
|
|
||||||
|
// Named layouts stored on the server slot in above Reset once they
|
||||||
|
// arrive; until then (or if they never do) the menu is already usable.
|
||||||
|
let layouts = [];
|
||||||
|
try {
|
||||||
|
const res = await fetchWithTimeout('/api/layouts', 8000);
|
||||||
|
if (res.ok) layouts = (await res.json()).layouts || [];
|
||||||
|
} catch { /* server briefly away — the menu just shows none */ }
|
||||||
|
if (!layouts.length || !stillOpen()) return;
|
||||||
|
const block = document.createDocumentFragment();
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'menu-title';
|
||||||
|
title.textContent = 'Load from server';
|
||||||
|
block.appendChild(title);
|
||||||
|
for (const l of layouts) {
|
||||||
|
block.appendChild(menuItem(l.name, async () => {
|
||||||
|
menu.hidden = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
|
||||||
|
// Loading copies the layout into this browser's own state — it
|
||||||
|
// does not live-link browsers together.
|
||||||
|
renderLayout(body.layout.tiles || []);
|
||||||
|
saveLayout();
|
||||||
|
toast(`Loaded “${l.name}”`);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, true);
|
||||||
|
}
|
||||||
|
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
|
||||||
|
}
|
||||||
|
block.appendChild(divider.cloneNode());
|
||||||
|
menu.insertBefore(block, reset);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
|
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
|
||||||
|
|||||||
Reference in New Issue
Block a user