Merge pull request #759 from Molecule-AI/feat/issue-753-audit-trail-panel

feat(canvas): audit trail visualization panel
This commit is contained in:
molecule-ai[bot] 2026-04-17 16:39:20 +00:00 committed by GitHub
commit d06666d87a
9 changed files with 707 additions and 11 deletions

View File

@ -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<AuditEntry["event_type"], { text: string; bg: string; border: string }> = {
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<AuditEntry[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<EventFilter>("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<AuditResponse>(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<AuditResponse>(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 (
<div className="flex items-center justify-center h-32">
<span className="text-xs text-zinc-500">Loading audit trail</span>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Filter bar */}
<div className="px-4 py-2.5 border-b border-zinc-800/40 flex items-center gap-1 overflow-x-auto shrink-0">
{FILTERS.map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
filter === f.id
? "bg-zinc-700 text-zinc-100 ring-1 ring-zinc-600"
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/60"
}`}
>
{f.label}
</button>
))}
<div className="flex-1" />
<button
onClick={loadEntries}
className="px-2 py-1 text-[10px] bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded transition-colors shrink-0"
aria-label="Refresh audit trail"
>
</button>
</div>
{/* Error banner */}
{error && (
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-red-400 shrink-0">
{error}
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{entries.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-zinc-700" aria-hidden="true"></span>
<p className="text-sm font-medium text-zinc-400">No audit events yet</p>
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
Delegation, decision, gate, and human-in-the-loop events will appear here.
</p>
</div>
) : (
<>
<div className="space-y-1.5" role="list" aria-label="Audit events">
{entries.map((entry) => (
<AuditEntryRow key={entry.id} entry={entry} now={now} />
))}
</div>
{/* Load more */}
{cursor && (
<div className="mt-4 flex justify-center">
<button
onClick={loadMore}
disabled={loadingMore}
className="px-4 py-2 text-[11px] bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-300 rounded-lg transition-colors"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
</div>
)}
{/* Entry count footer */}
<p className="mt-3 text-center text-[9px] text-zinc-600">
{entries.length} event{entries.length !== 1 ? "s" : ""} loaded
{cursor ? " · more available" : " · all loaded"}
</p>
</>
)}
</div>
</div>
);
}
// ── 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 (
<div
role="listitem"
className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 px-3 py-2.5 space-y-1.5"
>
{/* Header row: badge · actor · tamper flag · timestamp */}
<div className="flex items-center gap-2">
{/* Event-type badge */}
<span
className={`shrink-0 text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border ${badge.text} ${badge.bg} ${badge.border}`}
aria-label={`Event type: ${entry.event_type}`}
>
{entry.event_type}
</span>
{/* Actor name */}
<span className="text-[10px] text-zinc-400 truncate flex-1 min-w-0 font-mono">
{entry.actor}
</span>
{/* Tamper warning — only rendered when chain is invalid */}
{!entry.chain_valid && (
<span
className="shrink-0 text-[11px] text-red-400 font-bold leading-none"
title="Chain integrity check failed — this entry may have been tampered with"
aria-label="Chain integrity warning: tampered entry"
role="img"
>
</span>
)}
{/* Relative timestamp */}
<span className="shrink-0 text-[9px] text-zinc-600">
{formatAuditRelativeTime(entry.created_at, now)}
</span>
</div>
{/* Summary text */}
<p className="text-[11px] text-zinc-300 leading-relaxed break-words">
{entry.summary}
</p>
</div>
);
}

View File

@ -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" && <MemoryInspectorPanel key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "traces" && <TracesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "events" && <EventsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "audit" && <AuditTrailPanel key={selectedNodeId} workspaceId={selectedNodeId} />}
</div>
{/* Footer — workspace ID */}

View File

@ -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() {
<span className="text-[10px] font-medium">A2A</span>
</button>
{/* Audit trail shortcut — switches selected workspace's panel to the Audit tab */}
<button
onClick={() => {
if (selectedNodeId) {
setPanelTab("audit");
} else {
showToast("Select a workspace to view its audit trail", "info");
}
}}
aria-label="Open audit trail for selected workspace"
title="View audit ledger for the selected workspace"
className="flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
>
{/* Scroll / ledger icon */}
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
aria-hidden="true"
>
<rect x="3" y="2" width="10" height="12" rx="1.5" stroke="currentColor" strokeWidth="1.4" />
<path d="M6 5.5h4M6 8h4M6 10.5h2.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
<span className="text-[10px] font-medium">Audit</span>
</button>
{/* Search shortcut */}
<button
onClick={() => useCanvasStore.getState().setSearchOpen(true)}

View File

@ -0,0 +1,367 @@
// @vitest-environment jsdom
/**
* AuditTrailPanel tests issue #753
*
* Split into three suites:
* 1. formatAuditRelativeTime pure helper (no mocks needed)
* 2. AuditEntryRow entry renderer: badges, tamper flag, timestamp, summary
* 3. AuditTrailPanel component integration: loading, empty state, entries,
* filter bar, pagination, error handling
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
// ── Mocks (hoisted before imports) ────────────────────────────────────────────
vi.mock("@/lib/api", () => ({
api: { get: vi.fn() },
}));
// ── Imports (after mocks) ─────────────────────────────────────────────────────
import { api } from "@/lib/api";
import {
formatAuditRelativeTime,
AuditEntryRow,
AuditTrailPanel,
} from "../AuditTrailPanel";
import type { AuditEntry } from "@/types/audit";
const mockGet = vi.mocked(api.get);
// ── Helpers ───────────────────────────────────────────────────────────────────
const NOW = 1_745_000_000_000; // fixed "now" for deterministic tests
function makeEntry(overrides: Partial<AuditEntry> = {}): AuditEntry {
return {
id: "entry-1",
workspace_id: "ws-a",
event_type: "delegation",
actor: "research-agent",
summary: "Delegated SEO analysis to marketing-agent",
chain_valid: true,
created_at: new Date(NOW - 120_000).toISOString(), // 2 min ago
...overrides,
};
}
function makeResponse(
entries: AuditEntry[],
cursor: string | null = null
) {
return { entries, cursor };
}
// ── Suite 1: formatAuditRelativeTime ─────────────────────────────────────────
describe("formatAuditRelativeTime", () => {
it("returns 'just now' when diff < 60 s", () => {
expect(formatAuditRelativeTime(new Date(NOW - 30_000).toISOString(), NOW)).toBe("just now");
});
it("returns 'Xm ago' for minute-scale diffs", () => {
expect(formatAuditRelativeTime(new Date(NOW - 3 * 60_000).toISOString(), NOW)).toBe("3m ago");
});
it("returns 'Xh ago' for hour-scale diffs", () => {
expect(formatAuditRelativeTime(new Date(NOW - 2 * 3_600_000).toISOString(), NOW)).toBe("2h ago");
});
it("returns a locale date string for diffs >= 24 h", () => {
const ts = new Date(NOW - 25 * 3_600_000).toISOString();
const result = formatAuditRelativeTime(ts, NOW);
// Should be a locale-formatted date, not "Xh ago"
expect(result).not.toMatch(/ago/);
expect(result.length).toBeGreaterThan(0);
});
});
// ── Suite 2: AuditEntryRow ────────────────────────────────────────────────────
describe("AuditEntryRow — badge colors", () => {
afterEach(() => cleanup());
it("renders the delegation badge", () => {
render(<AuditEntryRow entry={makeEntry({ event_type: "delegation" })} now={NOW} />);
expect(screen.getByText("delegation")).toBeTruthy();
});
it("renders the decision badge", () => {
render(<AuditEntryRow entry={makeEntry({ event_type: "decision" })} now={NOW} />);
expect(screen.getByText("decision")).toBeTruthy();
});
it("renders the gate badge", () => {
render(<AuditEntryRow entry={makeEntry({ event_type: "gate" })} now={NOW} />);
expect(screen.getByText("gate")).toBeTruthy();
});
it("renders the hitl badge", () => {
render(<AuditEntryRow entry={makeEntry({ event_type: "hitl" })} now={NOW} />);
expect(screen.getByText("hitl")).toBeTruthy();
});
});
describe("AuditEntryRow — content", () => {
afterEach(() => cleanup());
it("displays actor name", () => {
render(<AuditEntryRow entry={makeEntry({ actor: "my-research-agent" })} now={NOW} />);
expect(screen.getByText("my-research-agent")).toBeTruthy();
});
it("displays summary text", () => {
render(<AuditEntryRow entry={makeEntry({ summary: "Approved budget allocation" })} now={NOW} />);
expect(screen.getByText("Approved budget allocation")).toBeTruthy();
});
it("shows relative timestamp", () => {
render(<AuditEntryRow entry={makeEntry({ created_at: new Date(NOW - 2 * 60_000).toISOString() })} now={NOW} />);
expect(screen.getByText("2m ago")).toBeTruthy();
});
it("does NOT render tamper warning when chain_valid is true", () => {
render(<AuditEntryRow entry={makeEntry({ chain_valid: true })} now={NOW} />);
expect(screen.queryByRole("img", { name: /tamper/i })).toBeNull();
});
it("renders ⚠ tamper warning when chain_valid is false", () => {
render(<AuditEntryRow entry={makeEntry({ chain_valid: false })} now={NOW} />);
const warning = screen.getByRole("img", { name: /tamper/i });
expect(warning).toBeTruthy();
expect(warning.textContent).toContain("⚠");
});
});
// ── Suite 3: AuditTrailPanel component ───────────────────────────────────────
describe("AuditTrailPanel — loading and empty state", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it("shows loading state while fetch is in-flight", async () => {
// Never resolve to keep loading state
mockGet.mockReturnValue(new Promise(() => {}));
render(<AuditTrailPanel workspaceId="ws-a" />);
expect(screen.getByText("Loading audit trail…")).toBeTruthy();
});
it("shows empty state when entries array is empty", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse([]) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.getByText("No audit events yet")).toBeTruthy();
});
it("shows descriptive empty state copy", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse([]) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.getByText(/Delegation, decision, gate/i)).toBeTruthy();
});
});
describe("AuditTrailPanel — entries", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it("renders all returned entries", async () => {
const entries = [
makeEntry({ id: "e1", actor: "agent-alpha" }),
makeEntry({ id: "e2", actor: "agent-beta" }),
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse(entries) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.getByText("agent-alpha")).toBeTruthy();
expect(screen.getByText("agent-beta")).toBeTruthy();
});
it("renders tamper warning for chain_valid=false entry", async () => {
const entries = [makeEntry({ id: "e1", chain_valid: false })];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse(entries) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.getByRole("img", { name: /tamper/i })).toBeTruthy();
});
it("shows entry count footer", async () => {
const entries = [makeEntry({ id: "e1" }), makeEntry({ id: "e2" })];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse(entries) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.getByText(/2 events loaded/)).toBeTruthy();
});
it("shows 'all loaded' when cursor is null", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse([makeEntry()], null) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.getByText(/all loaded/)).toBeTruthy();
});
});
describe("AuditTrailPanel — pagination", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it("shows 'Load more' button when cursor is non-null", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse([makeEntry()], "cursor-abc") as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.getByRole("button", { name: /load more/i })).toBeTruthy();
});
it("does NOT show 'Load more' when cursor is null", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse([makeEntry()], null) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.queryByRole("button", { name: /load more/i })).toBeNull();
});
it("appends entries and updates cursor when 'Load more' is clicked", async () => {
const page1 = [makeEntry({ id: "e1", actor: "alpha" })];
const page2 = [makeEntry({ id: "e2", actor: "beta" })];
mockGet
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce(makeResponse(page1, "cursor-next") as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce(makeResponse(page2, null) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.getByText("alpha")).toBeTruthy();
expect(screen.queryByText("beta")).toBeNull();
const loadMoreBtn = screen.getByRole("button", { name: /load more/i });
fireEvent.click(loadMoreBtn);
await act(async () => { await Promise.resolve(); });
expect(screen.getByText("alpha")).toBeTruthy();
expect(screen.getByText("beta")).toBeTruthy();
// Cursor is now null — Load more should disappear
expect(screen.queryByRole("button", { name: /load more/i })).toBeNull();
});
it("second page request includes cursor param", async () => {
const page1 = [makeEntry({ id: "e1" })];
mockGet
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce(makeResponse(page1, "cursor-xyz") as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce(makeResponse([], null) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
fireEvent.click(screen.getByRole("button", { name: /load more/i }));
await act(async () => { await Promise.resolve(); });
// Second call should include cursor=cursor-xyz
const secondCallPath = mockGet.mock.calls[1][0] as string;
expect(secondCallPath).toContain("cursor=cursor-xyz");
});
});
describe("AuditTrailPanel — filter bar", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it("renders all five filter buttons", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse([]) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.getByRole("button", { name: /^All$/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /^Delegation$/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /^Decision$/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /^Gate$/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /^HITL$/i })).toBeTruthy();
});
it("includes event_type param when a type filter is active", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse([]) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
const delegationBtn = screen.getByRole("button", { name: /^Delegation$/i });
fireEvent.click(delegationBtn);
await act(async () => { await Promise.resolve(); });
// Second API call should include event_type=delegation
const lastCallPath = mockGet.mock.calls[mockGet.mock.calls.length - 1][0] as string;
expect(lastCallPath).toContain("event_type=delegation");
});
it("omits event_type param when 'All' filter is active", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse([]) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
const firstCallPath = mockGet.mock.calls[0][0] as string;
expect(firstCallPath).not.toContain("event_type");
});
});
describe("AuditTrailPanel — error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it("shows error banner when fetch fails", async () => {
mockGet.mockRejectedValue(new Error("Network timeout"));
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.getByText("Network timeout")).toBeTruthy();
});
it("still renders empty state (not error) on successful empty response", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(makeResponse([]) as any);
render(<AuditTrailPanel workspaceId="ws-a" />);
await act(async () => { await Promise.resolve(); });
expect(screen.queryByText(/Network/)).toBeNull();
expect(screen.getByText("No audit events yet")).toBeTruthy();
});
});

View File

@ -68,6 +68,7 @@ const mockStoreState = {
setA2AEdges: vi.fn(),
showA2AEdges: false,
setShowA2AEdges: vi.fn(),
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({

View File

@ -78,6 +78,7 @@ const mockStoreState = {
setA2AEdges: vi.fn(),
showA2AEdges: false,
setShowA2AEdges: vi.fn(),
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({

View File

@ -19,6 +19,7 @@ vi.mock("../tabs/EventsTab", () => ({ EventsTab: () => null }));
vi.mock("../tabs/ActivityTab", () => ({ ActivityTab: () => null }));
vi.mock("../tabs/ScheduleTab", () => ({ ScheduleTab: () => null }));
vi.mock("../tabs/ChannelsTab", () => ({ ChannelsTab: () => null }));
vi.mock("../AuditTrailPanel", () => ({ AuditTrailPanel: () => null }));
// ── Mock StatusDot and Tooltip ───────────────────────────────────────────────
vi.mock("../StatusDot", () => ({ StatusDot: () => null }));
@ -67,7 +68,7 @@ import { SidePanel } from "../SidePanel";
const TABS = [
"chat", "activity", "details", "skills", "terminal",
"config", "schedule", "channels", "files", "memory", "traces", "events",
"config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
];
describe("SidePanel — ARIA tablist pattern", () => {
@ -78,10 +79,10 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
});
it("renders exactly 12 tab buttons", () => {
it("renders exactly 13 tab buttons", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
expect(tabs.length).toBe(12);
expect(tabs.length).toBe(13);
});
it("active tab (chat) has aria-selected='true'", () => {
@ -92,11 +93,11 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(chatTab?.getAttribute("aria-selected")).toBe("true");
});
it("all other 11 tabs have aria-selected='false'", () => {
it("all other 12 tabs have aria-selected='false'", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
const inactive = tabs.filter((t) => t.id !== "tab-chat");
expect(inactive.length).toBe(11);
expect(inactive.length).toBe(12);
for (const tab of inactive) {
expect(tab.getAttribute("aria-selected")).toBe("false");
}
@ -109,7 +110,7 @@ describe("SidePanel — ARIA tablist pattern", () => {
const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1");
expect(zeros.length).toBe(1);
expect(zeros[0].id).toBe("tab-chat");
expect(minusOnes.length).toBe(11);
expect(minusOnes.length).toBe(12);
});
it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => {
@ -139,11 +140,11 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(mockSetPanelTab).toHaveBeenCalledWith("activity");
});
it("ArrowLeft from 'chat' (first) wraps to 'events' (last)", () => {
it("ArrowLeft from 'chat' (first) wraps to 'audit' (last)", () => {
render(<SidePanel />);
const tablist = screen.getByRole("tablist");
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
expect(mockSetPanelTab).toHaveBeenCalledWith("events");
expect(mockSetPanelTab).toHaveBeenCalledWith("audit");
});
it("Home key calls setPanelTab with 'chat' (first tab)", () => {
@ -153,11 +154,11 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(mockSetPanelTab).toHaveBeenCalledWith("chat");
});
it("End key calls setPanelTab with 'events' (last tab)", () => {
it("End key calls setPanelTab with 'audit' (last tab)", () => {
render(<SidePanel />);
const tablist = screen.getByRole("tablist");
fireEvent.keyDown(tablist, { key: "End" });
expect(mockSetPanelTab).toHaveBeenCalledWith("events");
expect(mockSetPanelTab).toHaveBeenCalledWith("audit");
});
});

View File

@ -35,7 +35,7 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
budgetUsed?: number | null;
}
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity";
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
export interface ContextMenuState {
x: number;

17
canvas/src/types/audit.ts Normal file
View File

@ -0,0 +1,17 @@
/** Audit ledger entry — issued by GET /workspaces/:id/audit */
export interface AuditEntry {
id: string;
workspace_id: string;
event_type: "delegation" | "decision" | "gate" | "hitl";
actor: string;
summary: string;
chain_valid: boolean;
created_at: string;
}
/** Paginated response envelope from GET /workspaces/:id/audit */
export interface AuditResponse {
entries: AuditEntry[];
/** Opaque cursor for the next page; null when no more pages exist. */
cursor: string | null;
}