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:
parent
7276e6d5d9
commit
96cb9f84c9
5 changed files with 195 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue