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

@ -134,6 +134,9 @@ struct StateSnapshot {
containers: Vec<ContainerView>,
transients: Vec<TransientView>,
approvals: Vec<ApprovalView>,
/// Last 30 resolved approvals (approved / denied / failed), newest-
/// first. Drives the "history" tab on the approvals section.
approval_history: Vec<ApprovalHistoryView>,
/// 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<String>,
/// `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<String>,
}
#[derive(Serialize)]
struct ApprovalView {
id: i64,
@ -233,6 +254,14 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> 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<AppState>) -> 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<Approval>) -> Vec<ApprovalView> {
let mut out = Vec::with_capacity(approvals.len());
for a in approvals {