diff --git a/TODO.md b/TODO.md index a2d7b02..3fc599a 100644 --- a/TODO.md +++ b/TODO.md @@ -88,19 +88,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## UI / UX -- **Approval history tab on P3NDING APPR0VALS.** Today the - section renders pending rows only; once approved / denied / - failed they drop off the dashboard. Sqlite still has every - row (approvals table never deletes), and the meta git log + - applied repo's annotated `denied/` / `failed/` tags - already carry the human-readable reasons. A second tab — - `pending | history` — that scrolls the last N resolved - approvals with their terminal status, `resolved_at` - timestamp, operator note (deny), build error (failed), and - a quick link/diff to the `proposal/` tag would close - the loop so the operator can see "what went out, what got - rejected, why" without ssh-ing to the host. - - **Web UI for config repos + meta deploy log.** Browse per-agent proposed / applied tags (`proposal/* / approved/* / building/* / deployed/* / diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 07e5823..521b829 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -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 diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 6658891..85ff223 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -258,6 +258,44 @@ code { } .approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; } .approvals form.inline { display: inline; margin-left: 0.4em; } +.approval-tabs { + display: flex; + gap: 0.4em; + margin: 0.6em 0 0.4em; +} +.approval-tab { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + font: inherit; + font-size: 0.85em; + letter-spacing: 0.08em; + padding: 0.25em 0.9em; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} +.approval-tab:hover { color: var(--fg); } +.approval-tab.active { + color: var(--purple); + border-color: var(--purple); + background: rgba(203, 166, 247, 0.08); + text-shadow: 0 0 4px currentColor; +} +.approvals-history .status { font-size: 0.85em; padding: 0 0.5em; } +.status-approved { color: var(--green); } +.status-denied { color: var(--red); } +.status-failed { color: var(--amber); } +.glyph-approved { color: var(--green); } +.glyph-denied { color: var(--red); } +.glyph-failed { color: var(--amber); } +.history-note { + margin-left: 1.8em; + margin-top: 0.2em; + color: var(--muted); + font-size: 0.85em; + white-space: pre-wrap; + word-break: break-word; +} ul form.inline { display: inline-block; } .btn { font-family: inherit; diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index abcd324..995c355 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -102,6 +102,22 @@ impl Approvals { Ok(()) } + /// Last `limit` resolved approvals (approved / denied / failed), + /// newest-first. Drives the history tab on the dashboard. + pub fn recent_resolved(&self, limit: u64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha + FROM approvals + WHERE status IN ('approved', 'denied', 'failed') + ORDER BY resolved_at DESC, id DESC + LIMIT ?1", + )?; + let rows = stmt.query_map([limit], row_to_approval)?; + rows.collect::>>() + .map_err(Into::into) + } + pub fn pending(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 756ea5c..cd0c9c7 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -134,6 +134,9 @@ struct StateSnapshot { containers: Vec, transients: Vec, approvals: Vec, + /// Last 30 resolved approvals (approved / denied / failed), newest- + /// first. Drives the "history" tab on the approvals section. + approval_history: Vec, /// Latest messages addressed to `operator` — surfaces agent replies /// asynchronously so the operator can see them without watching the /// live panel during a turn. @@ -203,6 +206,24 @@ struct TransientView { secs: u64, } +#[derive(Serialize)] +struct ApprovalHistoryView { + id: i64, + agent: String, + kind: &'static str, + /// First 12 chars of the canonical sha (preferred) or + /// manager-supplied ref. None for resolved spawn approvals. + sha_short: Option, + /// `approved` / `denied` / `failed`. + status: &'static str, + /// Unix seconds. Renders as a relative time on the dashboard. + resolved_at: i64, + /// Operator-supplied deny reason (for `denied`) or build error + /// (for `failed`). None on `approved`. + #[serde(skip_serializing_if = "Option::is_none")] + note: Option, +} + #[derive(Serialize)] struct ApprovalView { id: i64, @@ -233,6 +254,14 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J build_container_views(&raw_containers, current_rev.as_deref(), &transient_snapshot).await; let transients = build_transient_views(&raw_containers, &transient_snapshot); let approvals = build_approval_views(pending_approvals).await; + let approval_history = state + .coord + .approvals + .recent_resolved(30) + .unwrap_or_default() + .into_iter() + .map(history_view) + .collect(); let tombstones = build_tombstone_views(&state.coord, &containers, &transient_snapshot); let port_conflicts = build_port_conflicts(&containers); @@ -250,6 +279,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J containers, transients, approvals, + approval_history, operator_inbox, questions, tombstones, @@ -376,6 +406,38 @@ fn build_transient_views( /// Render each pending approval into its dashboard view (short sha + /// unified diff for `ApplyCommit`, just the name for `Spawn`). +/// Project a resolved sqlite row into the lean shape the dashboard +/// history tab consumes — no `diff_html` (rendering 30 of them +/// per /api/state poll would mean 30 git diffs per refresh). +fn history_view(a: Approval) -> ApprovalHistoryView { + let displayed = a.fetched_sha.as_deref().unwrap_or(&a.commit_ref); + let sha_short = if displayed.is_empty() { + None + } else { + Some(displayed[..displayed.len().min(12)].to_owned()) + }; + let status = match a.status { + hive_sh4re::ApprovalStatus::Approved => "approved", + hive_sh4re::ApprovalStatus::Denied => "denied", + hive_sh4re::ApprovalStatus::Failed => "failed", + // Pending shouldn't appear in recent_resolved, but be defensive. + hive_sh4re::ApprovalStatus::Pending => "pending", + }; + let kind = match a.kind { + hive_sh4re::ApprovalKind::ApplyCommit => "apply_commit", + hive_sh4re::ApprovalKind::Spawn => "spawn", + }; + ApprovalHistoryView { + id: a.id, + agent: a.agent, + kind, + sha_short, + status, + resolved_at: a.resolved_at.unwrap_or(0), + note: a.note, + } +} + async fn build_approval_views(approvals: Vec) -> Vec { let mut out = Vec::with_capacity(approvals.len()); for a in approvals {