diff --git a/canvas/src/components/AuditTrailPanel.tsx b/canvas/src/components/AuditTrailPanel.tsx new file mode 100644 index 00000000..f7056dbe --- /dev/null +++ b/canvas/src/components/AuditTrailPanel.tsx @@ -0,0 +1,276 @@ +'use client'; + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; +import type { AuditEntry, AuditResponse } from "@/types/audit"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +type EventFilter = "all" | AuditEntry["event_type"]; + +const BADGE_COLORS: Record = { + delegation: { text: "text-blue-400", bg: "bg-blue-950/40", border: "border-blue-800/40" }, + decision: { text: "text-violet-400", bg: "bg-violet-950/40", border: "border-violet-800/40" }, + gate: { text: "text-yellow-400", bg: "bg-yellow-950/40", border: "border-yellow-800/40" }, + hitl: { text: "text-orange-400", bg: "bg-orange-950/40", border: "border-orange-800/40" }, +}; + +const FILTERS: { id: EventFilter; label: string }[] = [ + { id: "all", label: "All" }, + { id: "delegation", label: "Delegation" }, + { id: "decision", label: "Decision" }, + { id: "gate", label: "Gate" }, + { id: "hitl", label: "HITL" }, +]; + +const AUDIT_LIMIT = 50; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Format an ISO timestamp as a human-readable relative time string. + * Exported so unit tests can call it directly without rendering. + */ +export function formatAuditRelativeTime(iso: string, now = Date.now()): string { + const diff = now - new Date(iso).getTime(); + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return new Date(iso).toLocaleDateString(); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +interface Props { + workspaceId: string; +} + +/** + * AuditTrailPanel — side-panel tab showing the workspace audit ledger. + * + * Features: + * - Color-coded event-type badges (delegation/decision/gate/hitl) + * - chain_valid=false tamper ⚠ indicator + * - Event-type filter bar + * - Cursor-based "Load more" pagination + * - Relative timestamps refreshed every 30 s + * - Empty state with icon + */ +export function AuditTrailPanel({ workspaceId }: Props) { + const [entries, setEntries] = useState([]); + const [cursor, setCursor] = useState(null); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [filter, setFilter] = useState("all"); + // Relative-time "now" — refreshed every 30 s to keep labels current + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, []); + + // ── URL builder (stable between renders when inputs unchanged) ───────────── + + const buildUrl = useCallback( + (cursorParam?: string | null): string => { + const params = new URLSearchParams(); + params.set("limit", String(AUDIT_LIMIT)); + if (filter !== "all") params.set("event_type", filter); + if (cursorParam) params.set("cursor", cursorParam); + return `/workspaces/${workspaceId}/audit?${params.toString()}`; + }, + [workspaceId, filter] + ); + + // ── Initial load (and on filter change) ─────────────────────────────────── + + const loadEntries = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await api.get(buildUrl()); + setEntries(data.entries ?? []); + setCursor(data.cursor ?? null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load audit trail"); + setEntries([]); + setCursor(null); + } finally { + setLoading(false); + } + }, [buildUrl]); + + useEffect(() => { + loadEntries(); + }, [loadEntries]); + + // ── Pagination (append next page) ───────────────────────────────────────── + + const loadMore = useCallback(async () => { + if (!cursor || loadingMore) return; + setLoadingMore(true); + try { + const data = await api.get(buildUrl(cursor)); + setEntries((prev) => [...prev, ...(data.entries ?? [])]); + setCursor(data.cursor ?? null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load more entries"); + } finally { + setLoadingMore(false); + } + }, [cursor, loadingMore, buildUrl]); + + // ── Render ───────────────────────────────────────────────────────────────── + + if (loading) { + return ( +
+ Loading audit trail… +
+ ); + } + + return ( +
+ {/* Filter bar */} +
+ {FILTERS.map((f) => ( + + ))} +
+ +
+ + {/* Error banner */} + {error && ( +
+ {error} +
+ )} + + {/* Content */} +
+ {entries.length === 0 ? ( + /* Empty state */ +
+ +

No audit events yet

+

+ Delegation, decision, gate, and human-in-the-loop events will appear here. +

+
+ ) : ( + <> +
+ {entries.map((entry) => ( + + ))} +
+ + {/* Load more */} + {cursor && ( +
+ +
+ )} + + {/* Entry count footer */} +

+ {entries.length} event{entries.length !== 1 ? "s" : ""} loaded + {cursor ? " · more available" : " · all loaded"} +

+ + )} +
+
+ ); +} + +// ── AuditEntryRow sub-component ─────────────────────────────────────────────── + +export interface AuditEntryRowProps { + entry: AuditEntry; + now: number; +} + +/** + * Single audit-trail entry row. + * Exported so tests can render it in isolation without the full panel. + */ +export function AuditEntryRow({ entry, now }: AuditEntryRowProps) { + const badge = BADGE_COLORS[entry.event_type] ?? { + text: "text-zinc-400", + bg: "bg-zinc-800/40", + border: "border-zinc-700/40", + }; + + return ( +
+ {/* Header row: badge · actor · tamper flag · timestamp */} +
+ {/* Event-type badge */} + + {entry.event_type} + + + {/* Actor name */} + + {entry.actor} + + + {/* Tamper warning — only rendered when chain is invalid */} + {!entry.chain_valid && ( + + ⚠ + + )} + + {/* Relative timestamp */} + + {formatAuditRelativeTime(entry.created_at, now)} + +
+ + {/* Summary text */} +

+ {entry.summary} +

+
+ ); +} diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx index 180088d9..64ec2601 100644 --- a/canvas/src/components/SidePanel.tsx +++ b/canvas/src/components/SidePanel.tsx @@ -12,6 +12,7 @@ import { ConfigTab } from "./tabs/ConfigTab"; import { TerminalTab } from "./tabs/TerminalTab"; import { FilesTab } from "./tabs/FilesTab"; import { MemoryInspectorPanel } from "./MemoryInspectorPanel"; +import { AuditTrailPanel } from "./AuditTrailPanel"; import { TracesTab } from "./tabs/TracesTab"; import { EventsTab } from "./tabs/EventsTab"; import { ActivityTab } from "./tabs/ActivityTab"; @@ -36,6 +37,7 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [ { id: "memory", label: "Memory", icon: "◇" }, { id: "traces", label: "Traces", icon: "◎" }, { id: "events", label: "Events", icon: "◊" }, + { id: "audit", label: "Audit", icon: "⊟" }, ]; export function SidePanel() { @@ -246,6 +248,7 @@ export function SidePanel() { {panelTab === "memory" && } {panelTab === "traces" && } {panelTab === "events" && } + {panelTab === "audit" && }
{/* Footer — workspace ID */} diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index 0c2a78d5..a4273a05 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -14,6 +14,8 @@ export function Toolbar() { const wsStatus = useCanvasStore((s) => s.wsStatus); const showA2AEdges = useCanvasStore((s) => s.showA2AEdges); const setShowA2AEdges = useCanvasStore((s) => s.setShowA2AEdges); + const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); + const setPanelTab = useCanvasStore((s) => s.setPanelTab); const [stopping, setStopping] = useState(false); const [restartingAll, setRestartingAll] = useState(false); @@ -216,6 +218,34 @@ export function Toolbar() { A2A + {/* Audit trail shortcut — switches selected workspace's panel to the Audit tab */} + + {/* Search shortcut */}