perf(dashboard): keyed container row cache — skip rebuild + async fetch for unchanged rows
Maintain a module-level containerRowCache (Map<name, {el, fingerprint}>)
that preserves <li> elements across renderContainers calls. Each row's
fingerprint encodes everything that affects its rendered output:
container running/login/update/reminder state, derived pending/opRunning
labels, tree position (depth, isLast, ancestorIsLast), selection, agent
question counts, and link-base context.
When the fingerprint is unchanged the existing DOM node is reused:
- no replaceChildren wipe for stable rows
- the async dashboard-state fetch (nav strip, ctx badge, status text)
is skipped — previously-fetched data stays in place
- DOM order is reconciled via insertBefore with zero layout work for
in-place nodes
Before this change every SSE event (container_changed, rebuild_queue_
changed, transient_set/cleared, question_added/resolved) caused a full
wipe + rebuild of the entire container list, triggering N concurrent
/api/dashboard-state fetches where N is the number of running agents.
After this change only the rows whose state actually changed are
rebuilt; the rest survive intact across re-renders.
This commit is contained in:
parent
59798f72f1
commit
f011638b5e
1 changed files with 307 additions and 207 deletions
|
|
@ -156,6 +156,11 @@ window.marked = marked;
|
||||||
// forms' POST → 200 → matching event flips the row without a
|
// forms' POST → 200 → matching event flips the row without a
|
||||||
// snapshot refetch.
|
// snapshot refetch.
|
||||||
const containersState = new Map();
|
const containersState = new Map();
|
||||||
|
// Keyed container row cache. Maps agent name → { el: <li>, fingerprint }.
|
||||||
|
// Allows renderContainers to skip rebuilding rows whose displayed state
|
||||||
|
// hasn't changed — prevents full-wipe flicker + avoids redundant async
|
||||||
|
// dashboard-state fetches on every SSE event.
|
||||||
|
const containerRowCache = new Map();
|
||||||
function syncContainersFromSnapshot(s) {
|
function syncContainersFromSnapshot(s) {
|
||||||
containersState.clear();
|
containersState.clear();
|
||||||
for (const c of s.containers || []) containersState.set(c.name, c);
|
for (const c of s.containers || []) containersState.set(c.name, c);
|
||||||
|
|
@ -563,6 +568,248 @@ window.marked = marked;
|
||||||
return prefix;
|
return prefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serialise the visible state of a container row into a stable string
|
||||||
|
// for change-detection. Includes everything that affects what the row
|
||||||
|
// renders — container fields, derived pending/selection state, tree
|
||||||
|
// position, and link-base context. The async dashboard-state (nav
|
||||||
|
// strip, ctx badge, status text) is intentionally excluded: it
|
||||||
|
// populates in-place and is preserved when a row is reused.
|
||||||
|
function containerRowFingerprint(c, node, pending, opRunning, selected,
|
||||||
|
askerCount, targetCount, gatewayLinks, hostname) {
|
||||||
|
return JSON.stringify({
|
||||||
|
running: c.running,
|
||||||
|
needs_login: c.needs_login,
|
||||||
|
needs_update: c.needs_update,
|
||||||
|
pending_reminders: c.pending_reminders,
|
||||||
|
port: c.port,
|
||||||
|
pending,
|
||||||
|
opRunning,
|
||||||
|
selected,
|
||||||
|
askerCount,
|
||||||
|
targetCount,
|
||||||
|
depth: node.depth,
|
||||||
|
isLast: node.isLast,
|
||||||
|
ancestorIsLast: node.ancestorIsLast,
|
||||||
|
gatewayLinks,
|
||||||
|
hostname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a single container-row <li> from scratch. Extracted so
|
||||||
|
// renderContainers can call this only for rows whose fingerprint
|
||||||
|
// changed (keyed cache), skipping the build + async dashboard-state
|
||||||
|
// fetch for stable rows.
|
||||||
|
function buildContainerLi(c, node, opts) {
|
||||||
|
const {
|
||||||
|
pending, opRunning, selected,
|
||||||
|
askerCount, targetCount, agentQCount,
|
||||||
|
url, containerBase, forgeBase, s,
|
||||||
|
} = opts;
|
||||||
|
const li = el('li', {
|
||||||
|
class: 'container-row'
|
||||||
|
+ (pending ? ' pending' : '')
|
||||||
|
+ (opRunning ? ' pending-running' : '')
|
||||||
|
+ (selected ? ' selected' : ''),
|
||||||
|
});
|
||||||
|
// Topology: depth contributes left-padding; the glyph string in
|
||||||
|
// the .tree-prefix span draws the ├─ / └─ joint + continuation
|
||||||
|
// lines (`│ `) for ancestors whose subtree extends below this
|
||||||
|
// row. Both are CSS-driven from the data attributes so the
|
||||||
|
// legacy flat layout (every container at depth 0) is bit-
|
||||||
|
// identical to today's render — no glyph, no indent.
|
||||||
|
if (node.depth > 0) li.dataset.depth = String(node.depth);
|
||||||
|
const prefix = treePrefixDom(node);
|
||||||
|
if (prefix) li.prepend(prefix);
|
||||||
|
|
||||||
|
// Agent icon: 5em square wrapper with an absolutely-positioned
|
||||||
|
// <img> + fire-and-forget load with /favicon.svg fallback. The
|
||||||
|
// wrapper doubles as the selection toggle (click / keyboard).
|
||||||
|
// See docs/web-ui.md::Container row for the layout + load-strategy
|
||||||
|
// rationale.
|
||||||
|
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
|
||||||
|
const icon = el('div', {
|
||||||
|
class: 'container-icon',
|
||||||
|
role: 'button',
|
||||||
|
tabindex: '0',
|
||||||
|
'aria-pressed': selected ? 'true' : 'false',
|
||||||
|
title: selected
|
||||||
|
? `deselect ${c.name} (or press Esc to clear all)`
|
||||||
|
: `select ${c.name} for bulk actions`,
|
||||||
|
}, iconImg);
|
||||||
|
icon.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSelection(c.name);
|
||||||
|
});
|
||||||
|
icon.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSelection(c.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (c.running) {
|
||||||
|
iconImg.src = `${url}icon`;
|
||||||
|
iconImg.addEventListener('error', () => {
|
||||||
|
if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
|
||||||
|
iconImg.dataset.fallback = '1';
|
||||||
|
icon.classList.add('icon-unreachable');
|
||||||
|
iconImg.src = '/favicon.svg';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Container stopped — skip the doomed `${url}icon` fetch and
|
||||||
|
// go straight to the dimmed hyperhive mark. Avoids a noisy
|
||||||
|
// failed request in the console + the brief broken-image flash.
|
||||||
|
icon.classList.add('icon-unreachable');
|
||||||
|
iconImg.src = '/favicon.svg';
|
||||||
|
}
|
||||||
|
// Card body: the three stacked content lines, right of the icon.
|
||||||
|
const body = el('div', { class: 'card-body' });
|
||||||
|
|
||||||
|
// ── identity ─────────────────────────────────────────────────
|
||||||
|
const head = el('div', { class: 'head' });
|
||||||
|
head.append(
|
||||||
|
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
||||||
|
);
|
||||||
|
// Icon-only nav strip — populated async from the agent's own
|
||||||
|
// `GET /api/dashboard-state` (via gateway when enabled, direct
|
||||||
|
// TCP otherwise). The agent is the single source of truth for its
|
||||||
|
// link list: stats / screen (GUI agents only — c0re's disk-based
|
||||||
|
// fallback cannot detect this) / forge profile / agent-configs /
|
||||||
|
// extras. DOM-built — link strings come from the agent's process
|
||||||
|
// and must never reach the HTML parser.
|
||||||
|
const navStrip = el('span', { class: 'nav-strip' });
|
||||||
|
head.append(navStrip);
|
||||||
|
if (c.running) {
|
||||||
|
// Fetch the lean dashboard-state snapshot from the agent directly.
|
||||||
|
// Populates: nav strip links (including the screen link that
|
||||||
|
// c0re's disk-based build cannot detect), rate_limited badge,
|
||||||
|
// ctx-window badge, and self-reported status text.
|
||||||
|
// Fails gracefully when the agent is starting up or the gateway
|
||||||
|
// is not yet routing to it — badges simply don't appear.
|
||||||
|
// Only runs when the row is first built (fingerprint changed) —
|
||||||
|
// reused rows keep their previously-fetched nav strip + badges.
|
||||||
|
fetch(`${containerBase}/api/dashboard-state`)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((ds) => {
|
||||||
|
if (!ds) return;
|
||||||
|
// ── nav strip ───────────────────────────────────────────
|
||||||
|
if (Array.isArray(ds.links)) {
|
||||||
|
for (const lnk of ds.links) {
|
||||||
|
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
|
||||||
|
: lnk.kind === 'external' ? (lnk.url || '')
|
||||||
|
: /* container */ containerBase + '/' + (lnk.url || '');
|
||||||
|
const a = el('a', {
|
||||||
|
class: 'nav-link',
|
||||||
|
href,
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener',
|
||||||
|
title: lnk.label || '',
|
||||||
|
});
|
||||||
|
// Plain text — agent-controlled strings stay out of innerHTML.
|
||||||
|
a.textContent = lnk.icon || lnk.label || '';
|
||||||
|
navStrip.append(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ── agent-owned status badges ────────────────────────────
|
||||||
|
// rate_limited: only show when no other critical badge is
|
||||||
|
// already shown (pending / not-running already handled sync).
|
||||||
|
if (ds.rate_limited) {
|
||||||
|
head.append(el('span',
|
||||||
|
{ class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
|
||||||
|
'⊘ rate limited'));
|
||||||
|
}
|
||||||
|
// ctx-window badge
|
||||||
|
if (ds.ctx_tokens != null) {
|
||||||
|
const k = Math.round(ds.ctx_tokens / 1000);
|
||||||
|
const win = ds.context_window_tokens;
|
||||||
|
const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
|
||||||
|
const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
|
||||||
|
const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn'
|
||||||
|
: ds.ctx_tokens >= caution ? 'badge-ctx-caution'
|
||||||
|
: 'badge-ctx-ok';
|
||||||
|
const title = win != null
|
||||||
|
? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
|
||||||
|
+ `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)`
|
||||||
|
: `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`;
|
||||||
|
head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`));
|
||||||
|
}
|
||||||
|
// ── agent status text (self-reported via set_status) ─────
|
||||||
|
if (ds.status_text) {
|
||||||
|
const nowUnix = Math.floor(Date.now() / 1000);
|
||||||
|
const ageStr = ds.status_set_at != null
|
||||||
|
? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : '';
|
||||||
|
body.append(el('div', {
|
||||||
|
class: 'agent-status',
|
||||||
|
title: `agent self-reported status${ageStr}`,
|
||||||
|
},
|
||||||
|
el('span', { class: 'status-icon' }, '◈ '),
|
||||||
|
ds.status_text,
|
||||||
|
el('span', { class: 'status-age' }, ageStr),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { /* graceful: agent starting / gateway miss → no data */ });
|
||||||
|
}
|
||||||
|
// Status / runtime badges. Pending transients always win
|
||||||
|
// (start / stop / restart / rebuild is in progress). Otherwise,
|
||||||
|
// when the container is stopped, surface a single `■ not running`
|
||||||
|
// badge. `needs_login` is still c0re-owned (reads auth sentinel
|
||||||
|
// files on the host). rate_limited / ctx / status_text are
|
||||||
|
// agent-owned and rendered by the async dashboard-state fetch above.
|
||||||
|
if (pending) {
|
||||||
|
head.append(el('span', { class: 'pending-state' },
|
||||||
|
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
|
||||||
|
} else if (!c.running) {
|
||||||
|
head.append(el('span',
|
||||||
|
{ class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
|
||||||
|
'■ not running'));
|
||||||
|
} else if (c.needs_login) {
|
||||||
|
head.append(el('a',
|
||||||
|
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
|
||||||
|
'needs login →'));
|
||||||
|
}
|
||||||
|
if (c.needs_update) {
|
||||||
|
head.append(form(
|
||||||
|
'/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
|
||||||
|
'rebuild ' + c.name + '? hot-reloads the container.',
|
||||||
|
{}, { noRefresh: true },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.pending_reminders && c.pending_reminders > 0) {
|
||||||
|
head.append(el('span',
|
||||||
|
{
|
||||||
|
class: 'badge badge-reminder',
|
||||||
|
title: 'pending reminders queued for this agent — see the reminders section to view / cancel',
|
||||||
|
},
|
||||||
|
`⏰ ${c.pending_reminders}`));
|
||||||
|
}
|
||||||
|
// Pending questions where this agent is the asker (awaiting an
|
||||||
|
// answer) or the target (owes a reply). Derived live from
|
||||||
|
// questionsState so the badge updates instantly on QuestionAdded /
|
||||||
|
// QuestionResolved without a separate backend field.
|
||||||
|
if (agentQCount > 0) {
|
||||||
|
const parts = [];
|
||||||
|
if (askerCount > 0) parts.push(`${askerCount} asked`);
|
||||||
|
if (targetCount > 0) parts.push(`${targetCount} to answer`);
|
||||||
|
head.append(el('span',
|
||||||
|
{
|
||||||
|
class: 'badge badge-loose-ends',
|
||||||
|
title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`,
|
||||||
|
},
|
||||||
|
`❓ ${agentQCount}`));
|
||||||
|
}
|
||||||
|
body.append(head);
|
||||||
|
|
||||||
|
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
|
||||||
|
// DESTR0Y / PURG3) moved to the selection bar — see
|
||||||
|
// docs/web-ui.md::Selection bar. The contextual `needs update ↻`
|
||||||
|
// chip in the head row stays — it's a state-hint, not an
|
||||||
|
// action button.
|
||||||
|
|
||||||
|
li.append(icon, body, buildAgentMenu(c, forgeBase));
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
function renderContainers(s) {
|
function renderContainers(s) {
|
||||||
const root = $('containers-section');
|
const root = $('containers-section');
|
||||||
// #containers-section only exists on /index.html. tabs.js is the
|
// #containers-section only exists on /index.html. tabs.js is the
|
||||||
|
|
@ -572,7 +819,6 @@ window.marked = marked;
|
||||||
// no-op-when-target-absent convention the other renderers
|
// no-op-when-target-absent convention the other renderers
|
||||||
// (renderTombstones, etc.) follow.
|
// (renderTombstones, etc.) follow.
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
root.replaceChildren();
|
|
||||||
|
|
||||||
// Containers come from the derived map (event-driven) rather than
|
// Containers come from the derived map (event-driven) rather than
|
||||||
// `s.containers`; `s` still supplies hostname (for the web-ui
|
// `s.containers`; `s` still supplies hostname (for the web-ui
|
||||||
|
|
@ -582,6 +828,13 @@ window.marked = marked;
|
||||||
const portConflicts = derivePortConflicts(containers);
|
const portConflicts = derivePortConflicts(containers);
|
||||||
const anyStale = containers.some((c) => c.needs_update);
|
const anyStale = containers.some((c) => c.needs_update);
|
||||||
|
|
||||||
|
// Preserve the keyed container list across the header-section
|
||||||
|
// rebuild. We wipe the banners/buttons above the list on every
|
||||||
|
// render (simple), but recycle <li> elements for unchanged rows
|
||||||
|
// (keyed) to avoid full-DOM thrash and redundant async fetches.
|
||||||
|
const existingUl = root.querySelector('ul.containers');
|
||||||
|
root.replaceChildren();
|
||||||
|
|
||||||
// Port-hash collisions: rename one of the listed agents and
|
// Port-hash collisions: rename one of the listed agents and
|
||||||
// rebuild. The banner sits above the agent list so it's the
|
// rebuild. The banner sits above the agent list so it's the
|
||||||
// first thing the operator sees when something's wedged.
|
// first thing the operator sees when something's wedged.
|
||||||
|
|
@ -637,18 +890,30 @@ window.marked = marked;
|
||||||
// `agent-sockets.json`). See
|
// `agent-sockets.json`). See
|
||||||
// `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`.
|
// `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`.
|
||||||
const gatewayLinks = !!(s && s.gateway_enabled);
|
const gatewayLinks = !!(s && s.gateway_enabled);
|
||||||
const ul = el('ul', { class: 'containers' });
|
// Forge public URL: prefer state.forge_public_url (set by the NixOS
|
||||||
|
// module when forge.behindGateway=true), fall back to
|
||||||
|
// "<hostname>:3000" for gateway-off / local-dev deploys.
|
||||||
|
const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`;
|
||||||
|
const ul = existingUl ?? el('ul', { class: 'containers' });
|
||||||
const tree = buildAgentTree(containers);
|
const tree = buildAgentTree(containers);
|
||||||
// In-flight rebuild / meta-update / destroy ops per agent name —
|
// In-flight rebuild / meta-update / destroy ops per agent name —
|
||||||
// see docs/web-ui.md::Container row for the building… badge
|
// see docs/web-ui.md::Container row for the building… badge
|
||||||
// rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped
|
// rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped
|
||||||
// gap when no operator transient is set).
|
// gap when no operator transient is set).
|
||||||
const inFlight = inFlightOpsByAgent();
|
const inFlight = inFlightOpsByAgent();
|
||||||
|
|
||||||
|
// Build the ordered list of <li> elements, reusing cached rows
|
||||||
|
// whose displayed state hasn't changed.
|
||||||
|
const orderedLis = [];
|
||||||
for (const node of tree) {
|
for (const node of tree) {
|
||||||
const c = node.container;
|
const c = node.container;
|
||||||
const url = gatewayLinks
|
const url = gatewayLinks
|
||||||
? `/agent/${encodeURIComponent(c.name)}/`
|
? `/agent/${encodeURIComponent(c.name)}/`
|
||||||
: `http://${hostname}:${c.port}/`;
|
: `http://${hostname}:${c.port}/`;
|
||||||
|
// Container nav-strip base: gateway prefix or direct TCP.
|
||||||
|
const containerBase = gatewayLinks
|
||||||
|
? `/agent/${encodeURIComponent(c.name)}`
|
||||||
|
: `http://${hostname}:${c.port}`;
|
||||||
// Pending-state derivation + queued-vs-running split — see
|
// Pending-state derivation + queued-vs-running split — see
|
||||||
// docs/web-ui.md::Container row for the transient → in-flight
|
// docs/web-ui.md::Container row for the transient → in-flight
|
||||||
// queue priority order and the opRunning rationale.
|
// queue priority order and the opRunning rationale.
|
||||||
|
|
@ -667,222 +932,57 @@ window.marked = marked;
|
||||||
const opRunning = transientKind != null
|
const opRunning = transientKind != null
|
||||||
|| (op != null && op.state === 'running');
|
|| (op != null && op.state === 'running');
|
||||||
const selected = selectionState.has(c.name);
|
const selected = selectionState.has(c.name);
|
||||||
const li = el('li', {
|
|
||||||
class: 'container-row'
|
|
||||||
+ (pending ? ' pending' : '')
|
|
||||||
+ (opRunning ? ' pending-running' : '')
|
|
||||||
+ (selected ? ' selected' : ''),
|
|
||||||
});
|
|
||||||
// Topology: depth contributes left-padding; the glyph string in
|
|
||||||
// the .tree-prefix span draws the ├─ / └─ joint + continuation
|
|
||||||
// lines (`│ `) for ancestors whose subtree extends below this
|
|
||||||
// row. Both are CSS-driven from the data attributes so the
|
|
||||||
// legacy flat layout (every container at depth 0) is bit-
|
|
||||||
// identical to today's render — no glyph, no indent.
|
|
||||||
if (node.depth > 0) li.dataset.depth = String(node.depth);
|
|
||||||
const prefix = treePrefixDom(node);
|
|
||||||
if (prefix) li.prepend(prefix);
|
|
||||||
|
|
||||||
// Agent icon: 5em square wrapper with an absolutely-positioned
|
|
||||||
// <img> + fire-and-forget load with /favicon.svg fallback. The
|
|
||||||
// wrapper doubles as the selection toggle (click / keyboard).
|
|
||||||
// See docs/web-ui.md::Container row for the layout + load-strategy
|
|
||||||
// rationale.
|
|
||||||
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
|
|
||||||
const icon = el('div', {
|
|
||||||
class: 'container-icon',
|
|
||||||
role: 'button',
|
|
||||||
tabindex: '0',
|
|
||||||
'aria-pressed': selected ? 'true' : 'false',
|
|
||||||
title: selected
|
|
||||||
? `deselect ${c.name} (or press Esc to clear all)`
|
|
||||||
: `select ${c.name} for bulk actions`,
|
|
||||||
}, iconImg);
|
|
||||||
icon.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleSelection(c.name);
|
|
||||||
});
|
|
||||||
icon.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleSelection(c.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (c.running) {
|
|
||||||
iconImg.src = `${url}icon`;
|
|
||||||
iconImg.addEventListener('error', () => {
|
|
||||||
if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
|
|
||||||
iconImg.dataset.fallback = '1';
|
|
||||||
icon.classList.add('icon-unreachable');
|
|
||||||
iconImg.src = '/favicon.svg';
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Container stopped — skip the doomed `${url}icon` fetch and
|
|
||||||
// go straight to the dimmed hyperhive mark. Avoids a noisy
|
|
||||||
// failed request in the console + the brief broken-image flash.
|
|
||||||
icon.classList.add('icon-unreachable');
|
|
||||||
iconImg.src = '/favicon.svg';
|
|
||||||
}
|
|
||||||
// Card body: the three stacked content lines, right of the icon.
|
|
||||||
const body = el('div', { class: 'card-body' });
|
|
||||||
|
|
||||||
// ── identity ─────────────────────────────────────────────────
|
|
||||||
const head = el('div', { class: 'head' });
|
|
||||||
head.append(
|
|
||||||
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
|
||||||
);
|
|
||||||
// Icon-only nav strip — populated async from the agent's own
|
|
||||||
// `GET /api/dashboard-state` (via gateway when enabled, direct
|
|
||||||
// TCP otherwise). The agent is the single source of truth for its
|
|
||||||
// link list: stats / screen (GUI agents only — c0re's disk-based
|
|
||||||
// fallback cannot detect this) / forge profile / agent-configs /
|
|
||||||
// extras. DOM-built — link strings come from the agent's process
|
|
||||||
// and must never reach the HTML parser.
|
|
||||||
const navStrip = el('span', { class: 'nav-strip' });
|
|
||||||
head.append(navStrip);
|
|
||||||
// Forge public URL: prefer state.forge_public_url (set by the NixOS
|
|
||||||
// module when forge.behindGateway=true, e.g.
|
|
||||||
// "https://forge.pr1ma.darkest.space"), fall back to
|
|
||||||
// "<hostname>:3000" for gateway-off / local-dev deploys.
|
|
||||||
const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`;
|
|
||||||
// Container nav-strip base: gateway prefix or direct TCP.
|
|
||||||
const containerBase = gatewayLinks
|
|
||||||
? `/agent/${encodeURIComponent(c.name)}`
|
|
||||||
: `http://${hostname}:${c.port}`;
|
|
||||||
if (c.running) {
|
|
||||||
// Fetch the lean dashboard-state snapshot from the agent directly.
|
|
||||||
// Populates: nav strip links (including the screen link that
|
|
||||||
// c0re's disk-based build cannot detect), rate_limited badge,
|
|
||||||
// ctx-window badge, and self-reported status text.
|
|
||||||
// Fails gracefully when the agent is starting up or the gateway
|
|
||||||
// is not yet routing to it — badges simply don't appear.
|
|
||||||
fetch(`${containerBase}/api/dashboard-state`)
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.then((ds) => {
|
|
||||||
if (!ds) return;
|
|
||||||
// ── nav strip ───────────────────────────────────────────
|
|
||||||
if (Array.isArray(ds.links)) {
|
|
||||||
for (const lnk of ds.links) {
|
|
||||||
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
|
|
||||||
: lnk.kind === 'external' ? (lnk.url || '')
|
|
||||||
: /* container */ containerBase + '/' + (lnk.url || '');
|
|
||||||
const a = el('a', {
|
|
||||||
class: 'nav-link',
|
|
||||||
href,
|
|
||||||
target: '_blank',
|
|
||||||
rel: 'noopener',
|
|
||||||
title: lnk.label || '',
|
|
||||||
});
|
|
||||||
// Plain text — agent-controlled strings stay out of innerHTML.
|
|
||||||
a.textContent = lnk.icon || lnk.label || '';
|
|
||||||
navStrip.append(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ── agent-owned status badges ────────────────────────────
|
|
||||||
// rate_limited: only show when no other critical badge is
|
|
||||||
// already shown (pending / not-running already handled sync).
|
|
||||||
if (ds.rate_limited) {
|
|
||||||
head.append(el('span',
|
|
||||||
{ class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
|
|
||||||
'⊘ rate limited'));
|
|
||||||
}
|
|
||||||
// ctx-window badge
|
|
||||||
if (ds.ctx_tokens != null) {
|
|
||||||
const k = Math.round(ds.ctx_tokens / 1000);
|
|
||||||
const win = ds.context_window_tokens;
|
|
||||||
const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
|
|
||||||
const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
|
|
||||||
const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn'
|
|
||||||
: ds.ctx_tokens >= caution ? 'badge-ctx-caution'
|
|
||||||
: 'badge-ctx-ok';
|
|
||||||
const title = win != null
|
|
||||||
? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
|
|
||||||
+ `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)`
|
|
||||||
: `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`;
|
|
||||||
head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`));
|
|
||||||
}
|
|
||||||
// ── agent status text (self-reported via set_status) ─────
|
|
||||||
if (ds.status_text) {
|
|
||||||
const nowUnix = Math.floor(Date.now() / 1000);
|
|
||||||
const ageStr = ds.status_set_at != null
|
|
||||||
? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : '';
|
|
||||||
body.append(el('div', {
|
|
||||||
class: 'agent-status',
|
|
||||||
title: `agent self-reported status${ageStr}`,
|
|
||||||
},
|
|
||||||
el('span', { class: 'status-icon' }, '◈ '),
|
|
||||||
ds.status_text,
|
|
||||||
el('span', { class: 'status-age' }, ageStr),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => { /* graceful: agent starting / gateway miss → no data */ });
|
|
||||||
}
|
|
||||||
// Status / runtime badges. Pending transients always win
|
|
||||||
// (start / stop / restart / rebuild is in progress). Otherwise,
|
|
||||||
// when the container is stopped, surface a single `■ not running`
|
|
||||||
// badge. `needs_login` is still c0re-owned (reads auth sentinel
|
|
||||||
// files on the host). rate_limited / ctx / status_text are
|
|
||||||
// agent-owned and rendered by the async dashboard-state fetch above.
|
|
||||||
if (pending) {
|
|
||||||
head.append(el('span', { class: 'pending-state' },
|
|
||||||
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
|
|
||||||
} else if (!c.running) {
|
|
||||||
head.append(el('span',
|
|
||||||
{ class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
|
|
||||||
'■ not running'));
|
|
||||||
} else if (c.needs_login) {
|
|
||||||
head.append(el('a',
|
|
||||||
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
|
|
||||||
'needs login →'));
|
|
||||||
}
|
|
||||||
if (c.needs_update) {
|
|
||||||
head.append(form(
|
|
||||||
'/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
|
|
||||||
'rebuild ' + c.name + '? hot-reloads the container.',
|
|
||||||
{}, { noRefresh: true },
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c.pending_reminders && c.pending_reminders > 0) {
|
|
||||||
head.append(el('span',
|
|
||||||
{
|
|
||||||
class: 'badge badge-reminder',
|
|
||||||
title: 'pending reminders queued for this agent — see the reminders section to view / cancel',
|
|
||||||
},
|
|
||||||
`⏰ ${c.pending_reminders}`));
|
|
||||||
}
|
|
||||||
// Pending questions where this agent is the asker (awaiting an
|
// Pending questions where this agent is the asker (awaiting an
|
||||||
// answer) or the target (owes a reply). Derived live from
|
// answer) or the target (owes a reply). Derived live from
|
||||||
// questionsState so the badge updates instantly on QuestionAdded /
|
// questionsState so the badge updates instantly on QuestionAdded /
|
||||||
// QuestionResolved without a separate backend field.
|
// QuestionResolved without a separate backend field.
|
||||||
|
const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length;
|
||||||
|
const targetCount = questionsState.pending.filter((q) => q.target === c.name).length;
|
||||||
const agentQCount = questionsState.pending.filter(
|
const agentQCount = questionsState.pending.filter(
|
||||||
(q) => q.asker === c.name || q.target === c.name,
|
(q) => q.asker === c.name || q.target === c.name,
|
||||||
).length;
|
).length;
|
||||||
if (agentQCount > 0) {
|
|
||||||
const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length;
|
const fp = containerRowFingerprint(c, node, pending, opRunning, selected,
|
||||||
const targetCount = questionsState.pending.filter((q) => q.target === c.name).length;
|
askerCount, targetCount, gatewayLinks, hostname);
|
||||||
const parts = [];
|
const cached = containerRowCache.get(c.name);
|
||||||
if (askerCount > 0) parts.push(`${askerCount} asked`);
|
|
||||||
if (targetCount > 0) parts.push(`${targetCount} to answer`);
|
let li;
|
||||||
head.append(el('span',
|
if (cached && cached.fingerprint === fp) {
|
||||||
{
|
// Row unchanged — reuse the existing DOM node. The async
|
||||||
class: 'badge badge-loose-ends',
|
// dashboard-state (nav strip, ctx badge, status text) stays
|
||||||
title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`,
|
// intact from the previous build, avoiding a redundant fetch.
|
||||||
},
|
li = cached.el;
|
||||||
`❓ ${agentQCount}`));
|
} else {
|
||||||
|
li = buildContainerLi(c, node, {
|
||||||
|
pending, opRunning, selected,
|
||||||
|
askerCount, targetCount, agentQCount,
|
||||||
|
url, containerBase, forgeBase, s,
|
||||||
|
});
|
||||||
|
containerRowCache.set(c.name, { el: li, fingerprint: fp });
|
||||||
}
|
}
|
||||||
body.append(head);
|
orderedLis.push(li);
|
||||||
|
|
||||||
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
|
|
||||||
// DESTR0Y / PURG3) moved to the selection bar — see
|
|
||||||
// docs/web-ui.md::Selection bar. The contextual `needs update ↻`
|
|
||||||
// chip in the head row stays — it's a state-hint, not an
|
|
||||||
// action button.
|
|
||||||
|
|
||||||
li.append(icon, body, buildAgentMenu(c, forgeBase));
|
|
||||||
ul.append(li);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove cache entries for agents that no longer exist.
|
||||||
|
for (const [name, entry] of containerRowCache) {
|
||||||
|
if (!liveNames.has(name)) {
|
||||||
|
entry.el.remove();
|
||||||
|
containerRowCache.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply correct DOM order without a full wipe. insertBefore is a
|
||||||
|
// no-op when the node is already at position i, so stable sections
|
||||||
|
// of the list cause zero layout work.
|
||||||
|
for (let i = 0; i < orderedLis.length; i++) {
|
||||||
|
if (ul.children[i] !== orderedLis[i]) {
|
||||||
|
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trim any excess children (defensive — shouldn't happen after the
|
||||||
|
// cache-removal pass above, but keeps the ul length exact).
|
||||||
|
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
|
||||||
|
|
||||||
root.append(ul);
|
root.append(ul);
|
||||||
renderSelectionBar(containers);
|
renderSelectionBar(containers);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue