Add dashboard live runs page

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-24 13:39:10 -05:00
1 parent 23c00829c0
commit b980e2b9d5
7 files changed
+309 -16

No files matched your search

+14 -5
View File
@@ -92,6 +92,12 @@ function readRunLogLimitBytes(value: unknown) {
return Math.max(1, Math.min(RUN_LOG_MAX_LIMIT_BYTES, Math.trunc(parsed)));
}
function readLiveRunsQueryInt(value: unknown, max: number, fallback = 0) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(0, Math.min(max, Math.trunc(parsed)));
}
export function agentRoutes(db: Db) {
// Legacy hardcoded maps — used as fallback when adapter module does not
// declare capability flags explicitly.
@@ -2486,8 +2492,8 @@ export function agentRoutes(db: Db) {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const minCountParam = req.query.minCount as string | undefined;
const minCount = minCountParam ? Math.max(0, Math.min(20, parseInt(minCountParam, 10) || 0)) : 0;
const minCount = readLiveRunsQueryInt(req.query.minCount, 50);
const limit = readLiveRunsQueryInt(req.query.limit, 50);
const columns = {
id: heartbeatRuns.id,
@@ -2515,7 +2521,7 @@ export function agentRoutes(db: Db) {
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
};
const liveRuns = await db
const liveRunsQuery = db
.select(columns)
.from(heartbeatRuns)
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
@@ -2527,7 +2533,10 @@ export function agentRoutes(db: Db) {
)
.orderBy(desc(heartbeatRuns.createdAt));
if (minCount > 0 && liveRuns.length < minCount) {
const liveRuns = limit > 0 ? await liveRunsQuery.limit(limit) : await liveRunsQuery;
const targetRunCount = limit > 0 ? Math.min(minCount, limit) : minCount;
if (targetRunCount > 0 && liveRuns.length < targetRunCount) {
const activeIds = liveRuns.map((r) => r.id);
const recentRuns = await db
.select(columns)
@@ -2541,7 +2550,7 @@ export function agentRoutes(db: Db) {
),
)
.orderBy(desc(heartbeatRuns.createdAt))
.limit(minCount - liveRuns.length);
.limit(targetRunCount - liveRuns.length);
const rows = [...liveRuns, ...recentRuns];
res.json(await Promise.all(rows.map(async (run) => ({
+2
View File
@@ -4,6 +4,7 @@ import { Layout } from "./components/Layout";
import { OnboardingWizard } from "./components/OnboardingWizard";
import { CloudAccessGate } from "./components/CloudAccessGate";
import { Dashboard } from "./pages/Dashboard";
import { DashboardLive } from "./pages/DashboardLive";
import { Companies } from "./pages/Companies";
import { Agents } from "./pages/Agents";
import { AgentDetail } from "./pages/AgentDetail";
@@ -58,6 +59,7 @@ function boardRoutes() {
<>
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="dashboard/live" element={<DashboardLive />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
+30
View File
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockApi = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("./client", () => ({
api: mockApi,
}));
import { heartbeatsApi } from "./heartbeats";
describe("heartbeatsApi.liveRunsForCompany", () => {
beforeEach(() => {
mockApi.get.mockReset();
mockApi.get.mockResolvedValue([]);
});
it("keeps the legacy numeric minCount signature", async () => {
await heartbeatsApi.liveRunsForCompany("company-1", 4);
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=4");
});
it("passes minCount and limit options to the company live-runs endpoint", async () => {
await heartbeatsApi.liveRunsForCompany("company-1", { minCount: 50, limit: 50 });
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=50&limit=50");
});
});
+14 -2
View File
@@ -101,8 +101,20 @@ export const heartbeatsApi = {
api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`),
activeRunForIssue: (issueId: string) =>
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
liveRunsForCompany: (companyId: string, minCount?: number) =>
api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${minCount ? `?minCount=${minCount}` : ""}`),
liveRunsForCompany: (
companyId: string,
options?: number | { minCount?: number; limit?: number },
) => {
const searchParams = new URLSearchParams();
if (typeof options === "number") {
searchParams.set("minCount", String(options));
} else if (options) {
if (options.minCount) searchParams.set("minCount", String(options.minCount));
if (options.limit) searchParams.set("limit", String(options.limit));
}
const qs = searchParams.toString();
return api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${qs ? `?${qs}` : ""}`);
},
listInstanceSchedulerAgents: () =>
api.get<InstanceSchedulerHeartbeatAgent[]>("/instance/scheduler-heartbeats"),
};
@@ -0,0 +1,152 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
<a href={to} {...props}>
{children}
</a>
),
}));
vi.mock("../api/heartbeats", () => ({
heartbeatsApi: mockHeartbeatsApi,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("./Identity", () => ({
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
vi.mock("./RunChatSurface", () => ({
RunChatSurface: () => <div>Run output</div>,
}));
vi.mock("./transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: () => ({
transcriptByRun: new Map(),
hasOutputForRun: () => false,
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function createRun(index: number) {
return {
id: `run-${index}`,
status: "running",
invocationSource: "assignment",
triggerDetail: null,
startedAt: "2026-04-24T12:00:00.000Z",
finishedAt: null,
createdAt: `2026-04-24T12:00:0${index}.000Z`,
agentId: `agent-${index}`,
agentName: `Agent ${index}`,
adapterType: "codex_local",
issueId: null,
};
}
describe("ActiveAgentsPanel", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([1, 2, 3, 4, 5].map(createRun));
mockIssuesApi.list.mockResolvedValue([]);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("links hidden active/recent runs to the full live dashboard", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ActiveAgentsPanel companyId="company-1" />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 4,
limit: undefined,
});
const moreLink = [...container.querySelectorAll("a")].find((anchor) =>
anchor.textContent?.includes("more active/recent"),
);
expect(moreLink?.getAttribute("href")).toBe("/dashboard/live");
await act(async () => {
root.unmount();
});
});
it("can request the full live dashboard page limit without a hidden-runs link", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ActiveAgentsPanel
companyId="company-1"
minRunCount={50}
fetchLimit={50}
cardLimit={50}
queryScope="dashboard-live"
showMoreLink={false}
/>
</QueryClientProvider>,
);
});
await flushReact();
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 50,
limit: 50,
});
expect(container.textContent).not.toContain("more active/recent");
await act(async () => {
root.unmount();
});
});
});
+33 -9
View File
@@ -25,16 +25,36 @@ function isRunActive(run: LiveRunForIssue): boolean {
interface ActiveAgentsPanelProps {
companyId: string;
title?: string;
minRunCount?: number;
fetchLimit?: number;
cardLimit?: number;
gridClassName?: string;
cardClassName?: string;
emptyMessage?: string;
queryScope?: string;
showMoreLink?: boolean;
}
export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
export function ActiveAgentsPanel({
companyId,
title = "Agents",
minRunCount = MIN_DASHBOARD_RUNS,
fetchLimit,
cardLimit = DASHBOARD_RUN_CARD_LIMIT,
gridClassName,
cardClassName,
emptyMessage = "No recent agent runs.",
queryScope = "dashboard",
showMoreLink = true,
}: ActiveAgentsPanelProps) {
const { data: liveRuns } = useQuery({
queryKey: [...queryKeys.liveRuns(companyId), "dashboard"],
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, MIN_DASHBOARD_RUNS),
queryKey: [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }],
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, { minCount: minRunCount, limit: fetchLimit }),
});
const runs = liveRuns ?? [];
const visibleRuns = useMemo(() => runs.slice(0, DASHBOARD_RUN_CARD_LIMIT), [runs]);
const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]);
const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
const { data: issues } = useQuery({
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
@@ -62,14 +82,14 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
return (
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Agents
{title}
</h3>
{runs.length === 0 ? (
<div className="rounded-xl border border-border p-4">
<p className="text-sm text-muted-foreground">No recent agent runs.</p>
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4">
<div className={cn("grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4", gridClassName)}>
{visibleRuns.map((run) => (
<AgentRunCard
key={run.id}
@@ -79,13 +99,14 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
transcript={transcriptByRun.get(run.id) ?? EMPTY_TRANSCRIPT}
hasOutput={hasOutputForRun(run.id)}
isActive={isRunActive(run)}
className={cardClassName}
/>
))}
</div>
)}
{hiddenRunCount > 0 && (
{showMoreLink && hiddenRunCount > 0 && (
<div className="mt-3 flex justify-end text-xs text-muted-foreground">
<Link to="/agents" className="hover:text-foreground hover:underline">
<Link to="/dashboard/live" className="hover:text-foreground hover:underline">
{hiddenRunCount} more active/recent run{hiddenRunCount === 1 ? "" : "s"}
</Link>
</div>
@@ -101,6 +122,7 @@ const AgentRunCard = memo(function AgentRunCard({
transcript,
hasOutput,
isActive,
className,
}: {
companyId: string;
run: LiveRunForIssue;
@@ -108,6 +130,7 @@ const AgentRunCard = memo(function AgentRunCard({
transcript: TranscriptEntry[];
hasOutput: boolean;
isActive: boolean;
className?: string;
}) {
return (
<div className={cn(
@@ -115,6 +138,7 @@ const AgentRunCard = memo(function AgentRunCard({
isActive
? "border-cyan-500/25 bg-cyan-500/[0.04] shadow-[0_16px_40px_rgba(6,182,212,0.08)]"
: "border-border bg-background/70",
className,
)}>
<div className="border-b border-border/60 px-3 py-3">
<div className="flex items-start justify-between gap-2">
+64
View File
@@ -0,0 +1,64 @@
import { useEffect } from "react";
import { ArrowLeft, RadioTower } from "lucide-react";
import { Link } from "@/lib/router";
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import { EmptyState } from "../components/EmptyState";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
const DASHBOARD_LIVE_RUN_LIMIT = 50;
export function DashboardLive() {
const { selectedCompanyId, companies } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Live runs" },
]);
}, [setBreadcrumbs]);
if (!selectedCompanyId) {
return (
<EmptyState
icon={RadioTower}
message={companies.length === 0 ? "Create a company to view live runs." : "Select a company to view live runs."}
/>
);
}
return (
<div className="space-y-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<Link
to="/dashboard"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
Dashboard
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-normal text-foreground">Live agent runs</h1>
<p className="mt-1 text-sm text-muted-foreground">
Active runs first, followed by the most recent completed runs.
</p>
</div>
<div className="text-sm text-muted-foreground">Showing up to {DASHBOARD_LIVE_RUN_LIMIT}</div>
</div>
<ActiveAgentsPanel
companyId={selectedCompanyId}
title="Active / recent"
minRunCount={DASHBOARD_LIVE_RUN_LIMIT}
fetchLimit={DASHBOARD_LIVE_RUN_LIMIT}
cardLimit={DASHBOARD_LIVE_RUN_LIMIT}
gridClassName="gap-3 md:grid-cols-2 2xl:grid-cols-3"
cardClassName="h-[420px]"
emptyMessage="No active or recent agent runs."
queryScope="dashboard-live"
showMoreLink={false}
/>
</div>
);
}