dashboard: approval history tab on P3NDING APPR0VALS

new tabs above the approvals list: 'pending · N' and
'history · M'. active tab persists in localStorage so the
operator can park on history if they prefer. on a fresh
dashboard the default is pending (matches the prior shape).

history view shows the last 30 resolved approvals — newest
first by resolved_at — with one row per approval: status
glyph (✓ approved / ✗ denied / ⚠ failed), id, agent, kind,
short sha, status label, and a relative time chip. when the
row has a note (deny reason or build error), it renders
below in a muted block with line wraps preserved.

backend: Approvals::recent_resolved(limit) queries by
status IN ('approved', 'denied', 'failed') ORDER BY
resolved_at DESC. StateSnapshot gets approval_history (a
lean ApprovalHistoryView without diff_html — rendering 30
git diffs per state poll would be expensive and the operator
already saw the diff at decision time). dashboard's
history_view fn projects the sqlite row.

retires the matching TODO entry.
This commit is contained in:
müde 2026-05-16 03:07:50 +02:00
parent 7276e6d5d9
commit 96cb9f84c9
5 changed files with 195 additions and 13 deletions

View file

@ -601,6 +601,7 @@
root.append(ul);
}
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
function renderApprovals(s) {
const root = $('approvals-section');
root.innerHTML = '';
@ -622,6 +623,41 @@
);
root.append(spawn);
const history = s.approval_history || [];
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
const tabs = el('div', { class: 'approval-tabs' });
const pendingTab = el(
'button',
{
type: 'button',
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
},
`pending · ${s.approvals.length}`,
);
const historyTab = el(
'button',
{
type: 'button',
class: 'approval-tab' + (active === 'history' ? ' active' : ''),
},
`history · ${history.length}`,
);
pendingTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
renderApprovals(s);
});
historyTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
renderApprovals(s);
});
tabs.append(pendingTab, historyTab);
root.append(tabs);
if (active === 'history') {
renderApprovalHistory(root, history);
return;
}
if (!s.approvals.length) {
root.append(el('p', { class: 'empty' }, 'queue empty'));
return;
@ -681,6 +717,49 @@
root.append(ul);
}
function renderApprovalHistory(root, history) {
if (!history.length) {
root.append(el('p', { class: 'empty' }, 'no resolved approvals yet'));
return;
}
const ul = el('ul', { class: 'approvals approvals-history' });
for (const a of history) {
const li = el('li');
const row = el('div', { class: 'row' });
const glyph = a.status === 'approved' ? '✓'
: a.status === 'denied' ? '✗'
: '⚠';
row.append(
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : 'spawn'), ' ',
);
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
row.append(
el('span', { class: 'status status-' + a.status }, a.status), ' ',
el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
);
li.append(row);
if (a.note) {
li.append(el('div', { class: 'history-note' }, a.note));
}
ul.append(li);
}
root.append(ul);
}
// Relative time, anchored to now. resolved_at is unix seconds (server-
// authored), so we don't have to worry about client/server clock skew
// for sub-minute precision.
function fmtAgo(unixSecs) {
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
if (ageSec < 60) return ageSec + 's ago';
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
return Math.floor(ageSec / 86400) + 'd ago';
}
// ─── state polling ──────────────────────────────────────────────────────
let pollTimer = null;
// Sections whose innerHTML gets blown away on each refresh. If the