mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Add dashboard live runs page
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
23c00829c0
commit
b980e2b9d5
7 files changed
+309
-16
No files matched your search
@@ -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) => ({
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user