dashboard: forge-linked config + approval card + 3-way diff base

- forge nix option moves to hyperhive.forge.enable, defaults true;
  hive-c0re imports the forge module so it's on by default.
- drop the agent.nix container-row viewer + /api/agent-config; link
  to the agent-configs forge repo instead.
- restructure pending approvals into a card (identity header /
  what-changed body / decision actions) with a link to the proposal
  commit on the forge.
- diff opens in the side panel with a 3-way base toggle: vs applied
  (running) / vs last-approved / vs previous proposal, served by the
  new /api/approval-diff/{id}?base= endpoint.
This commit is contained in:
müde 2026-05-20 11:22:28 +02:00
parent 0c62bbf1cd
commit 49f4e9cc89
5 changed files with 305 additions and 134 deletions

View file

@ -593,14 +593,23 @@
}
li.append(actions);
// ── line 3: drill-ins ────────────────────────────────────────
const drill = el('div', { class: 'drill-ins' });
// Per-container journald viewer. Opens the side panel and
// fetches the last N lines; refresh re-fetches; unit selector
// narrows to the harness service (or empty = full machine).
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
li.append(buildJournalTrigger(c.container, journalUnit));
// Per-container applied config viewer. Shows the agent.nix
// the container is actually built against.
li.append(buildConfigTrigger(c.name));
drill.append(buildJournalTrigger(c.container, journalUnit));
// Applied config now lives on the forge — link to the
// agent-configs mirror repo instead of a one-file viewer.
if (s && s.forge_present) {
drill.append(el('a', {
class: 'panel-trigger', target: '_blank', rel: 'noopener',
href: `http://${hostname}:3000/agent-configs/${c.name}`,
title: 'applied config repo on the hive forge',
}, '↳ config repo ↗'));
}
li.append(drill);
ul.append(li);
}
@ -660,46 +669,6 @@
return trigger;
}
// Per-container applied-config viewer. Returns an inline trigger;
// the click opens the side panel and fetches agent.nix. Read-only —
// the file is hive-c0re's applied repo, mutated only via approvals.
function buildConfigTrigger(agentName) {
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'↳ agent.nix · ' + agentName);
trigger.addEventListener('click', () => {
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
'↻ refresh');
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
let fetching = false;
async function fetchConfig() {
if (fetching) return;
fetching = true;
pre.textContent = 'fetching…';
try {
const resp = await fetch('/api/agent-config/' + agentName);
const text = await resp.text();
if (!resp.ok) {
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
} finally {
fetching = false;
}
}
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
controls.append(refresh);
body.append(controls, pre);
Panel.open('agent.nix · ' + agentName, body);
fetchConfig();
});
return trigger;
}
function renderTombstones(s) {
const root = $('tombstones-section');
root.innerHTML = '';
@ -1097,6 +1066,68 @@
}
renderApprovals();
}
// Classify each unified-diff line by its leading char so
// `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` /
// `.diff-ctx` colour the output. Built as text-only spans (no
// innerHTML) so there's no HTML-escape surface.
function buildDiffPre(text) {
const pre = el('pre', { class: 'diff' });
for (const raw of String(text).split('\n')) {
let cls = 'diff-ctx';
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
else if (raw.startsWith('@')) cls = 'diff-hunk';
else if (raw.startsWith('+')) cls = 'diff-add';
else if (raw.startsWith('-')) cls = 'diff-del';
const span = document.createElement('span');
span.className = cls;
span.textContent = raw + '\n';
pre.appendChild(span);
}
return pre;
}
// Open an approval's diff in the side panel with a 3-way base
// toggle: vs applied (running tree), vs last-approved, vs previous
// proposal. `applied` uses the diff already shipped on the approval
// for instant paint; the other two fetch /api/approval-diff.
function openDiffPanel(a) {
const bases = [
['applied', 'vs applied'],
['approved', 'vs last-approved'],
['previous', 'vs previous proposal'],
];
const tabs = el('div', { class: 'diff-base-tabs' });
const host = el('div', { class: 'diff-host' });
async function selectBase(base) {
for (const btn of tabs.children) {
btn.classList.toggle('active', btn.dataset.base === base);
}
if (base === 'applied' && a.diff != null) {
host.replaceChildren(buildDiffPre(a.diff));
return;
}
host.replaceChildren(el('div', { class: 'meta' }, 'loading…'));
try {
const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base);
const text = await resp.text();
host.replaceChildren(resp.ok
? buildDiffPre(text)
: el('div', { class: 'meta' }, 'error: ' + text));
} catch (e) {
host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e));
}
}
for (const [base, label] of bases) {
const btn = el('button',
{ type: 'button', class: 'diff-base-tab', 'data-base': base }, label);
btn.addEventListener('click', () => selectBase(base));
tabs.append(btn);
}
const wrap = el('div', { class: 'diff-panel' }, tabs, host);
Panel.open('diff · ' + a.agent + ' #' + a.id, wrap);
selectBase('applied');
}
function renderApprovals() {
const root = $('approvals-section');
root.innerHTML = '';
@ -1158,34 +1189,56 @@
root.append(el('p', { class: 'empty' }, 'queue empty'));
return;
}
// forge link base — only when the hive-forge container is up.
const fs = window.__hyperhive_state;
const hostname = (fs && fs.hostname) || window.location.hostname;
const forgeBase = (fs && fs.forge_present) ? `http://${hostname}:3000` : null;
const ul = el('ul', { class: 'approvals' });
for (const a of pending) {
const li = el('li');
const row = el('div', { class: 'row' });
if (a.kind === 'apply_commit') {
row.append(
el('span', { class: 'glyph' }, '→'), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind' }, 'apply'), ' ',
el('code', {}, a.sha_short || ''),
);
} else {
row.append(
el('span', { class: 'glyph' }, '⊕'), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind kind-spawn' }, 'spawn'), ' ',
el('span', { class: 'meta' },
'new sub-agent — container will be created on approve'),
);
}
const isApply = a.kind === 'apply_commit';
const li = el('li', { class: 'approval-card' });
// ── identity header ──────────────────────────────────────────
const head = el('div', { class: 'approval-head' },
el('span', { class: 'glyph' }, isApply ? '→' : '⊕'),
el('span', { class: 'id' }, '#' + a.id),
el('span', { class: 'agent' }, a.agent),
el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
isApply ? 'apply' : 'spawn'),
);
if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short));
li.append(head);
// ── what-changed body ────────────────────────────────────────
const body = el('div', { class: 'approval-body' });
if (a.description) {
li.append(el('div', { class: 'approval-description' }, a.description));
body.append(el('div', { class: 'approval-description' }, a.description));
}
// Deny prompts the operator for an optional reason; the
// submit handler stashes it into a hidden `note` input that
// rides along on the POST and is surfaced to the manager via
if (isApply) {
const drill = el('div', { class: 'drill-ins' });
const diffBtn = el('button', { type: 'button', class: 'panel-trigger' },
'↳ view diff');
diffBtn.addEventListener('click', () => openDiffPanel(a));
drill.append(diffBtn);
if (forgeBase && a.sha_short) {
drill.append(el('a', {
class: 'panel-trigger', target: '_blank', rel: 'noopener',
href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`,
title: 'this proposal commit on the hive forge',
}, '↳ commit on forge ↗'));
}
body.append(drill);
} else {
body.append(el('span', { class: 'meta' },
'new sub-agent — container will be created on approve'));
}
li.append(body);
// ── decision actions ─────────────────────────────────────────
// Deny prompts the operator for an optional reason; the submit
// handler stashes it into a hidden `note` input that rides along
// on the POST and is surfaced to the manager via
// HelperEvent::ApprovalResolved { note }.
const denyForm = el('form', {
method: 'POST', action: '/deny/' + a.id,
@ -1193,39 +1246,11 @@
'data-prompt': 'reason for denying (optional, sent to manager):',
});
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
row.append(
' ',
li.append(el('div', { class: 'approval-actions' },
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
' ',
denyForm,
);
li.append(row);
if (a.diff) {
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'diff vs applied');
trigger.addEventListener('click', () => {
// Server ships the raw unified diff; classify each line by
// its leading char so `.diff-add` / `.diff-del` /
// `.diff-hunk` / `.diff-file` / `.diff-ctx` colour the
// output. Building spans here (instead of innerHTML-ing
// pre-rendered markup) keeps the snapshot wire format
// text-only and one less HTML-escape surface server-side.
const pre = el('pre', { class: 'diff' });
for (const raw of a.diff.split('\n')) {
let cls = 'diff-ctx';
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
else if (raw.startsWith('@')) cls = 'diff-hunk';
else if (raw.startsWith('+')) cls = 'diff-add';
else if (raw.startsWith('-')) cls = 'diff-del';
const span = document.createElement('span');
span.className = cls;
span.textContent = raw + '\n';
pre.appendChild(span);
}
Panel.open('diff · ' + a.agent + ' #' + a.id, pre);
});
li.append(trigger);
}
));
ul.append(li);
}
root.append(ul);

View file

@ -226,9 +226,66 @@ code {
border-radius: 2px;
font-size: 0.9em;
}
.approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; }
.approvals form.inline { display: inline; margin-left: 0.4em; }
.approval-description { font-size: 0.85em; color: var(--fg-dim, #888); margin: 0.2em 0 0.4em 1.2em; }
/* Pending approval: a card with three stacked sections identity
header, what-changed body, decision actions. */
.approvals { list-style: none; padding: 0; margin: 0.4em 0 0; }
.approval-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-left: 3px solid var(--purple);
border-radius: 4px;
padding: 0.6em 0.8em;
margin-bottom: 0.6em;
}
.approval-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.3em;
}
.approval-body {
margin: 0.45em 0;
padding-left: 1.3em;
}
.approval-description {
font-size: 0.9em;
color: var(--fg);
white-space: pre-wrap;
margin-bottom: 0.35em;
}
.approval-actions {
display: flex;
gap: 0.5em;
padding-top: 0.45em;
border-top: 1px solid var(--border);
}
.approval-actions form.inline { display: inline; }
/* Inline drill-in triggers (logs / config repo / view diff). */
.drill-ins {
display: flex;
flex-wrap: wrap;
gap: 0.15em 1.1em;
margin-top: 0.4em;
}
.drill-ins .panel-trigger { margin-top: 0; }
/* Diff side-panel: base-toggle tabs above the diff host. */
.diff-panel { display: flex; flex-direction: column; gap: 0.6em; }
.diff-base-tabs { display: flex; flex-wrap: wrap; gap: 0.4em; }
.diff-base-tab {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font: inherit;
font-size: 0.85em;
padding: 0.2em 0.7em;
cursor: pointer;
}
.diff-base-tab:hover { color: var(--fg); }
.diff-base-tab.active {
color: var(--purple);
border-color: var(--purple);
background: rgba(203, 166, 247, 0.08);
}
.approval-tabs {
display: flex;
gap: 0.4em;
@ -738,6 +795,7 @@ footer a { color: var(--purple); }
margin-top: 0.5em;
display: inline-block;
text-align: left;
text-decoration: none;
}
.panel-trigger:hover { color: var(--cyan); }