feat(FN-1045): add clickable agent run history with inline log viewer

- Add backend API endpoint for time-range filtered agent log retrieval (GET /api/agents/:id/logs)
- Add fetchAgentRunLogs API function in dashboard client
- Make RunsTab run cards clickable with inline log viewer accordion
- Make AgentRunHistory run rows clickable to expand and view logs
- Enhance AgentDetailView with collapsible log sections and time-range filtering
- Add comprehensive tests for store, routes, and component click behavior
This commit is contained in:
gsxdsm
2026-04-07 12:55:05 -07:00
parent 5646a03d83
commit 4e220d0287
8 changed files with 635 additions and 43 deletions

View File

@@ -2,12 +2,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import {
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
Settings, FileText, ActivitySquare, X, Copy,
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch,
ChevronDown, ChevronRight
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentChildren } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
/**
* Simple className utility - joins class names conditionally
@@ -377,6 +379,8 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
runs={runs}
activeRun={activeRun}
addToast={addToast}
agentId={agent.id}
projectId={projectId}
/>
)}
@@ -673,12 +677,40 @@ function LogEntry({ entry, showTimestamp }: { entry: AgentLogEntry; showTimestam
function RunsTab({
runs,
activeRun,
addToast
addToast,
agentId,
projectId,
}: {
runs: AgentHeartbeatRun[];
activeRun?: AgentHeartbeatRun;
addToast: (msg: string, type?: "success" | "error") => void;
agentId: string;
projectId?: string;
}) {
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [runLogs, setRunLogs] = useState<AgentLogEntry[]>([]);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const handleRunClick = useCallback(async (runId: string) => {
if (selectedRunId === runId) {
setSelectedRunId(null);
setRunLogs([]);
return;
}
setSelectedRunId(runId);
setIsLoadingLogs(true);
setRunLogs([]);
try {
const logs = await fetchAgentRunLogs(agentId, runId, projectId);
setRunLogs(logs);
} catch (err: any) {
addToast(`Failed to load run logs: ${err.message}`, "error");
setRunLogs([]);
} finally {
setIsLoadingLogs(false);
}
}, [selectedRunId, agentId, projectId, addToast]);
if (runs.length === 0 && !activeRun) {
return (
<div className="runs-empty">
@@ -693,50 +725,86 @@ function RunsTab({
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
return (
<div className="runs-tab">
{activeRun && (
<div className="run-card run-card--active">
const renderRunCard = (run: AgentHeartbeatRun, index: number, isActive: boolean) => {
const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed;
const StatusIcon = statusInfo.icon;
const duration = run.endedAt
? formatDuration(new Date(run.startedAt), new Date(run.endedAt))
: "In progress";
const isSelected = selectedRunId === run.id;
return (
<div key={run.id}>
<div
className={cn("run-card", isActive && "run-card--active", isSelected && "run-card--selected")}
onClick={() => void handleRunClick(run.id)}
style={{ cursor: "pointer" }}
role="button"
tabIndex={0}
aria-expanded={isSelected}
aria-label={`${isActive ? "Active" : ""} run ${run.id.slice(0, 8)}, ${run.status}`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void handleRunClick(run.id);
}
}}
>
<div className="run-header">
<span className="run-live-indicator">
<span className="live-dot" />
Live Run
</span>
<span className="run-status active">
<Loader2 size={14} className="animate-spin" />
Active
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{isSelected ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{isActive ? (
<span className="run-live-indicator">
<span className="live-dot" />
Live Run
</span>
) : (
<span className="run-id">#{index + 1} {run.id.slice(0, 8)}</span>
)}
</div>
<span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} style={run.status === "active" ? { color: statusInfo.color } : undefined} />
{run.status}
</span>
</div>
<div className="run-details">
<span>Started {relativeTime(activeRun.startedAt)}</span>
<span>Started {relativeTime(run.startedAt)}</span>
<span>•</span>
<span>{duration}</span>
</div>
</div>
)}
{sortedRuns.map((run, i) => {
const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed;
const StatusIcon = statusInfo.icon;
const duration = run.endedAt
? formatDuration(new Date(run.startedAt), new Date(run.endedAt))
: "In progress";
return (
<div key={run.id} className="run-card">
<div className="run-header">
<span className="run-id">#{i + 1} {run.id.slice(0, 8)}</span>
<span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} />
{run.status}
</span>
</div>
<div className="run-details">
<span>Started {relativeTime(run.startedAt)}</span>
<span>•</span>
<span>{duration}</span>
</div>
{isSelected && (
<div
className="run-logs-container"
style={{
padding: "12px",
background: "var(--bg-secondary)",
borderBottom: "1px solid var(--border-color)",
borderTop: "1px solid var(--border-color)",
}}
>
{isLoadingLogs ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "12px 0" }}>
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading logs...</span>
</div>
) : runLogs.length === 0 ? (
<div className="text-muted" style={{ padding: "12px 0", fontStyle: "italic" }}>
No logs available for this run
</div>
) : (
<AgentLogViewer entries={runLogs} loading={false} />
)}
</div>
);
})}
)}
</div>
);
};
return (
<div className="runs-tab">
{activeRun && renderRunCard(activeRun, 0, true)}
{sortedRuns.map((run, i) => renderRunCard(run, activeRun ? i + 1 : i, false))}
</div>
);
}

View File

@@ -6,6 +6,8 @@ import { fetchAgentRuns } from "../api";
interface AgentRunHistoryProps {
agentId: string;
projectId?: string;
/** Optional callback when a run row is clicked */
onRunClick?: (runId: string) => void;
}
const STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
@@ -15,7 +17,7 @@ const STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }>
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" },
};
export function AgentRunHistory({ agentId, projectId }: AgentRunHistoryProps) {
export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHistoryProps) {
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -46,7 +48,20 @@ export function AgentRunHistory({ agentId, projectId }: AgentRunHistoryProps) {
const usage = run.usageJson;
return (
<div key={run.id} className="agent-run-row">
<div
key={run.id}
className={onRunClick ? "agent-run-row agent-run-row--clickable" : "agent-run-row"}
onClick={onRunClick ? () => onRunClick(run.id) : undefined}
role={onRunClick ? "button" : undefined}
tabIndex={onRunClick ? 0 : undefined}
aria-label={onRunClick ? `Run ${run.id.slice(0, 8)}, ${run.status}` : undefined}
onKeyDown={onRunClick ? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRunClick(run.id);
}
} : undefined}
>
<StatusIcon size={16} style={{ color: statusInfo.color }} className={run.status === "active" ? "animate-spin" : ""} />
<div className="agent-run-info">
<span className="agent-run-id">{run.id}</span>

View File

@@ -12,13 +12,23 @@ vi.mock("../../api", () => ({
updateAgentState: vi.fn(),
deleteAgent: vi.fn(),
fetchAgentLogs: vi.fn(),
fetchAgentRunLogs: vi.fn(),
}));
import { fetchAgent, updateAgent, updateAgentState } from "../../api";
vi.mock("../AgentLogViewer", () => ({
AgentLogViewer: ({ entries }: { entries: Array<{ text: string }> }) => (
<div data-testid="agent-log-viewer">
{entries.map((e, i) => <span key={i}>{e.text}</span>)}
</div>
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
const mockUpdateAgentState = vi.mocked(updateAgentState);
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
describe("AgentDetailView", () => {
const createMockAgent = (overrides: Partial<{
@@ -882,4 +892,221 @@ describe("AgentDetailView", () => {
});
});
});
// ── Runs Tab — Click to show logs ──────────────────────────────────
describe("Runs Tab — click to show logs", () => {
const navigateToRuns = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.getByText("Runs")).toBeInTheDocument();
});
await user.click(screen.getByText("Runs"));
};
it("shows run cards as clickable with chevron indicators", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToRuns(user);
await waitFor(() => {
// Completed run card should be clickable (has role="button")
const buttons = screen.getAllByRole("button");
const runButtons = buttons.filter(btn => btn.getAttribute("aria-label")?.includes("run"));
expect(runButtons.length).toBeGreaterThan(0);
});
});
it("fetches and displays logs when clicking a completed run", async () => {
const mockLogs = [
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "FN-001", text: "Starting task execution", type: "text" },
{ timestamp: "2024-01-01T00:02:00.000Z", taskId: "FN-001", text: "Read file: src/index.ts", type: "tool" },
];
mockFetchAgentRunLogs.mockResolvedValue(mockLogs);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToRuns(user);
// Wait for run cards to render
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
// Click the completed run
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
// Verify fetchAgentRunLogs was called
await waitFor(() => {
expect(mockFetchAgentRunLogs).toHaveBeenCalled();
});
// Verify logs appear
await waitFor(() => {
expect(screen.getByText("Starting task execution")).toBeInTheDocument();
});
});
it("shows loading state while fetching run logs", async () => {
// Create a promise that won't resolve immediately
let resolveLogs: (value: any) => void;
mockFetchAgentRunLogs.mockImplementation(() => new Promise(r => { resolveLogs = r; }));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
// Should show loading state
await waitFor(() => {
expect(screen.getByText("Loading logs...")).toBeInTheDocument();
});
// Resolve to clean up
resolveLogs!([]);
});
it("shows empty message when no logs available for a run", async () => {
mockFetchAgentRunLogs.mockResolvedValue([]);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
await waitFor(() => {
expect(screen.getByText("No logs available for this run")).toBeInTheDocument();
});
});
it("collapses log viewer when clicking the same run again", async () => {
mockFetchAgentRunLogs.mockResolvedValue([
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "FN-001", text: "Test log entry", type: "text" },
]);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
// Click to expand
await user.click(completedRunButton);
await waitFor(() => {
expect(screen.getByText("Test log entry")).toBeInTheDocument();
});
// Click to collapse
await user.click(completedRunButton);
await waitFor(() => {
expect(screen.queryByText("Test log entry")).not.toBeInTheDocument();
});
});
it("shows toast on fetch error", async () => {
const addToast = vi.fn();
mockFetchAgentRunLogs.mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to load run logs"),
"error",
);
});
});
});
});