Merge pull request #759 from Molecule-AI/feat/issue-753-audit-trail-panel
feat(canvas): audit trail visualization panel
This commit is contained in:
commit
6ec9ada929
276
canvas/src/components/AuditTrailPanel.tsx
Normal file
276
canvas/src/components/AuditTrailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
@ -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)}
|
||||
|
||||
367
canvas/src/components/__tests__/AuditTrailPanel.test.tsx
Normal file
367
canvas/src/components/__tests__/AuditTrailPanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -68,6 +68,7 @@ const mockStoreState = {
|
||||
setA2AEdges: vi.fn(),
|
||||
showA2AEdges: false,
|
||||
setShowA2AEdges: vi.fn(),
|
||||
setPanelTab: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
|
||||
@ -78,6 +78,7 @@ const mockStoreState = {
|
||||
setA2AEdges: vi.fn(),
|
||||
showA2AEdges: false,
|
||||
setShowA2AEdges: vi.fn(),
|
||||
setPanelTab: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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
17
canvas/src/types/audit.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user