[codex] Harden dashboard run activity charts (#4126)
## Thinking Path > - Paperclip gives operators a live view of agent work across dashboards, transcripts, and run activity charts > - Those views consume live run updates and aggregate run activity from backend dashboard data > - Missing or partial run data could make charts brittle, and live transcript updates were heavier than needed > - Operators need dashboard data to stay stable even when recent run payloads are incomplete > - This pull request hardens dashboard run aggregation, guards chart rendering, and lightens live run update handling > - The benefit is a more reliable dashboard during active agent execution ## What Changed - Added dashboard run activity types and backend aggregation coverage. - Guarded activity chart rendering when run data is missing or partial. - Reduced live transcript update churn in active agent and run chat surfaces. - Fixed issue chat avatar alignment in the thread renderer. - Added focused dashboard, activity chart, and live transcript tests. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm exec vitest run server/src/__tests__/dashboard-service.test.ts ui/src/components/ActivityCharts.test.tsx ui/src/components/transcript/useLiveRunTranscripts.test.tsx` - Result: 8 tests passed, 1 skipped. The embedded Postgres dashboard service test skipped on this host with the existing PGlite/Postgres init warning; UI chart and transcript tests passed. ## Risks - Medium-low risk: aggregation semantics changed, but the UI remains guarded around incomplete data. - The dashboard service test is host-skipped here, so CI should confirm the embedded database path. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent based on GPT-5, tool-enabled local shell and GitHub workflow, exact runtime context window not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots, or documented why targeted component tests are sufficient here - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -331,6 +331,7 @@ export type {
|
|||||||
AgentWakeupRequest,
|
AgentWakeupRequest,
|
||||||
InstanceSchedulerHeartbeatAgent,
|
InstanceSchedulerHeartbeatAgent,
|
||||||
LiveEvent,
|
LiveEvent,
|
||||||
|
DashboardRunActivityDay,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
ActivityEvent,
|
ActivityEvent,
|
||||||
UserProfileActivitySummary,
|
UserProfileActivitySummary,
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
export interface DashboardRunActivityDay {
|
||||||
|
date: string;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
other: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
agents: {
|
agents: {
|
||||||
@@ -24,4 +32,5 @@ export interface DashboardSummary {
|
|||||||
pausedAgents: number;
|
pausedAgents: number;
|
||||||
pausedProjects: number;
|
pausedProjects: number;
|
||||||
};
|
};
|
||||||
|
runActivity: DashboardRunActivityDay[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export type {
|
|||||||
InstanceSchedulerHeartbeatAgent,
|
InstanceSchedulerHeartbeatAgent,
|
||||||
} from "./heartbeat.js";
|
} from "./heartbeat.js";
|
||||||
export type { LiveEvent } from "./live.js";
|
export type { LiveEvent } from "./live.js";
|
||||||
export type { DashboardSummary } from "./dashboard.js";
|
export type { DashboardRunActivityDay, DashboardSummary } from "./dashboard.js";
|
||||||
export type { ActivityEvent } from "./activity.js";
|
export type { ActivityEvent } from "./activity.js";
|
||||||
export type {
|
export type {
|
||||||
UserProfileActivitySummary,
|
UserProfileActivitySummary,
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
getEmbeddedPostgresTestSupport,
|
||||||
|
startEmbeddedPostgresTestDatabase,
|
||||||
|
} from "./helpers/embedded-postgres.js";
|
||||||
|
import { dashboardService } from "../services/dashboard.ts";
|
||||||
|
|
||||||
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
|
|
||||||
|
if (!embeddedPostgresSupport.supported) {
|
||||||
|
console.warn(
|
||||||
|
`Skipping embedded Postgres dashboard service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcDay(offsetDays: number): Date {
|
||||||
|
const now = new Date();
|
||||||
|
const day = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offsetDays, 12);
|
||||||
|
return new Date(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcDateKey(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
describeEmbeddedPostgres("dashboard service", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-dashboard-service-");
|
||||||
|
db = createDb(tempDb.connectionString);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(heartbeatRuns);
|
||||||
|
await db.delete(agents);
|
||||||
|
await db.delete(companies);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await tempDb?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aggregates the full 14-day run activity window without recent-run truncation", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const otherCompanyId = randomUUID();
|
||||||
|
const agentId = randomUUID();
|
||||||
|
const otherAgentId = randomUUID();
|
||||||
|
const today = utcDay(0);
|
||||||
|
const weekAgo = utcDay(-7);
|
||||||
|
|
||||||
|
await db.insert(companies).values([
|
||||||
|
{
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherCompanyId,
|
||||||
|
name: "Other",
|
||||||
|
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(agents).values([
|
||||||
|
{
|
||||||
|
id: agentId,
|
||||||
|
companyId,
|
||||||
|
name: "CodexCoder",
|
||||||
|
role: "engineer",
|
||||||
|
status: "running",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherAgentId,
|
||||||
|
companyId: otherCompanyId,
|
||||||
|
name: "OtherAgent",
|
||||||
|
role: "engineer",
|
||||||
|
status: "running",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(heartbeatRuns).values([
|
||||||
|
...Array.from({ length: 105 }, () => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
status: "succeeded",
|
||||||
|
createdAt: today,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
status: "failed",
|
||||||
|
createdAt: weekAgo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
status: "timed_out",
|
||||||
|
createdAt: weekAgo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
status: "cancelled",
|
||||||
|
createdAt: weekAgo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
companyId: otherCompanyId,
|
||||||
|
agentId: otherAgentId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
status: "succeeded",
|
||||||
|
createdAt: weekAgo,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const summary = await dashboardService(db).summary(companyId);
|
||||||
|
|
||||||
|
expect(summary.runActivity).toHaveLength(14);
|
||||||
|
const todayBucket = summary.runActivity.find((bucket) => bucket.date === utcDateKey(today));
|
||||||
|
const weekAgoBucket = summary.runActivity.find((bucket) => bucket.date === utcDateKey(weekAgo));
|
||||||
|
|
||||||
|
expect(todayBucket).toMatchObject({
|
||||||
|
succeeded: 105,
|
||||||
|
failed: 0,
|
||||||
|
other: 0,
|
||||||
|
total: 105,
|
||||||
|
});
|
||||||
|
expect(weekAgoBucket).toMatchObject({
|
||||||
|
succeeded: 0,
|
||||||
|
failed: 2,
|
||||||
|
other: 1,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
import { and, eq, gte, sql } from "drizzle-orm";
|
import { and, eq, gte, sql } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { agents, approvals, companies, costEvents, issues } from "@paperclipai/db";
|
import { agents, approvals, companies, costEvents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||||
import { notFound } from "../errors.js";
|
import { notFound } from "../errors.js";
|
||||||
import { budgetService } from "./budgets.js";
|
import { budgetService } from "./budgets.js";
|
||||||
|
|
||||||
|
const DASHBOARD_RUN_ACTIVITY_DAYS = 14;
|
||||||
|
|
||||||
|
function formatUtcDateKey(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecentUtcDateKeys(now: Date, days: number): string[] {
|
||||||
|
const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||||
|
return Array.from({ length: days }, (_, index) => {
|
||||||
|
const dayOffset = index - (days - 1);
|
||||||
|
return formatUtcDateKey(new Date(todayUtc + dayOffset * 24 * 60 * 60 * 1000));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function dashboardService(db: Db) {
|
export function dashboardService(db: Db) {
|
||||||
const budgets = budgetService(db);
|
const budgets = budgetService(db);
|
||||||
return {
|
return {
|
||||||
@@ -62,7 +76,9 @@ export function dashboardService(db: Db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
const runActivityDays = getRecentUtcDateKeys(now, DASHBOARD_RUN_ACTIVITY_DAYS);
|
||||||
|
const runActivityStart = new Date(`${runActivityDays[0]}T00:00:00.000Z`);
|
||||||
const [{ monthSpend }] = await db
|
const [{ monthSpend }] = await db
|
||||||
.select({
|
.select({
|
||||||
monthSpend: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
monthSpend: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||||
@@ -76,6 +92,38 @@ export function dashboardService(db: Db) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const monthSpendCents = Number(monthSpend);
|
const monthSpendCents = Number(monthSpend);
|
||||||
|
const runActivityDayExpr = sql<string>`to_char(${heartbeatRuns.createdAt} at time zone 'UTC', 'YYYY-MM-DD')`;
|
||||||
|
const runActivityRows = await db
|
||||||
|
.select({
|
||||||
|
date: runActivityDayExpr,
|
||||||
|
status: heartbeatRuns.status,
|
||||||
|
count: sql<number>`count(*)::double precision`,
|
||||||
|
})
|
||||||
|
.from(heartbeatRuns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(heartbeatRuns.companyId, companyId),
|
||||||
|
gte(heartbeatRuns.createdAt, runActivityStart),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(runActivityDayExpr, heartbeatRuns.status);
|
||||||
|
|
||||||
|
const runActivity = new Map(
|
||||||
|
runActivityDays.map((date) => [
|
||||||
|
date,
|
||||||
|
{ date, succeeded: 0, failed: 0, other: 0, total: 0 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
for (const row of runActivityRows) {
|
||||||
|
const bucket = runActivity.get(row.date);
|
||||||
|
if (!bucket) continue;
|
||||||
|
const count = Number(row.count);
|
||||||
|
if (row.status === "succeeded") bucket.succeeded += count;
|
||||||
|
else if (row.status === "failed" || row.status === "timed_out") bucket.failed += count;
|
||||||
|
else bucket.other += count;
|
||||||
|
bucket.total += count;
|
||||||
|
}
|
||||||
|
|
||||||
const utilization =
|
const utilization =
|
||||||
company.budgetMonthlyCents > 0
|
company.budgetMonthlyCents > 0
|
||||||
? (monthSpendCents / company.budgetMonthlyCents) * 100
|
? (monthSpendCents / company.budgetMonthlyCents) * 100
|
||||||
@@ -103,6 +151,7 @@ export function dashboardService(db: Db) {
|
|||||||
pausedAgents: budgetOverview.pausedAgentCount,
|
pausedAgents: budgetOverview.pausedAgentCount,
|
||||||
pausedProjects: budgetOverview.pausedProjectCount,
|
pausedProjects: budgetOverview.pausedProjectCount,
|
||||||
},
|
},
|
||||||
|
runActivity: Array.from(runActivity.values()),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
@@ -13,6 +13,11 @@ import { RunChatSurface } from "./RunChatSurface";
|
|||||||
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
||||||
|
|
||||||
const MIN_DASHBOARD_RUNS = 4;
|
const MIN_DASHBOARD_RUNS = 4;
|
||||||
|
const DASHBOARD_RUN_CARD_LIMIT = 4;
|
||||||
|
const DASHBOARD_LOG_POLL_INTERVAL_MS = 15_000;
|
||||||
|
const DASHBOARD_LOG_READ_LIMIT_BYTES = 64_000;
|
||||||
|
const DASHBOARD_MAX_CHUNKS_PER_RUN = 40;
|
||||||
|
const EMPTY_TRANSCRIPT: TranscriptEntry[] = [];
|
||||||
|
|
||||||
function isRunActive(run: LiveRunForIssue): boolean {
|
function isRunActive(run: LiveRunForIssue): boolean {
|
||||||
return run.status === "queued" || run.status === "running";
|
return run.status === "queued" || run.status === "running";
|
||||||
@@ -29,10 +34,12 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const runs = liveRuns ?? [];
|
const runs = liveRuns ?? [];
|
||||||
|
const visibleRuns = useMemo(() => runs.slice(0, DASHBOARD_RUN_CARD_LIMIT), [runs]);
|
||||||
|
const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
|
||||||
const { data: issues } = useQuery({
|
const { data: issues } = useQuery({
|
||||||
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
|
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
|
||||||
queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
|
queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
|
||||||
enabled: runs.length > 0,
|
enabled: visibleRuns.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const issueById = useMemo(() => {
|
const issueById = useMemo(() => {
|
||||||
@@ -44,9 +51,12 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
|||||||
}, [issues]);
|
}, [issues]);
|
||||||
|
|
||||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||||
runs,
|
runs: visibleRuns,
|
||||||
companyId,
|
companyId,
|
||||||
maxChunksPerRun: 120,
|
maxChunksPerRun: DASHBOARD_MAX_CHUNKS_PER_RUN,
|
||||||
|
logPollIntervalMs: DASHBOARD_LOG_POLL_INTERVAL_MS,
|
||||||
|
logReadLimitBytes: DASHBOARD_LOG_READ_LIMIT_BYTES,
|
||||||
|
enableRealtimeUpdates: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,24 +70,31 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4">
|
||||||
{runs.map((run) => (
|
{visibleRuns.map((run) => (
|
||||||
<AgentRunCard
|
<AgentRunCard
|
||||||
key={run.id}
|
key={run.id}
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
run={run}
|
run={run}
|
||||||
issue={run.issueId ? issueById.get(run.issueId) : undefined}
|
issue={run.issueId ? issueById.get(run.issueId) : undefined}
|
||||||
transcript={transcriptByRun.get(run.id) ?? []}
|
transcript={transcriptByRun.get(run.id) ?? EMPTY_TRANSCRIPT}
|
||||||
hasOutput={hasOutputForRun(run.id)}
|
hasOutput={hasOutputForRun(run.id)}
|
||||||
isActive={isRunActive(run)}
|
isActive={isRunActive(run)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hiddenRunCount > 0 && (
|
||||||
|
<div className="mt-3 flex justify-end text-xs text-muted-foreground">
|
||||||
|
<Link to="/agents" className="hover:text-foreground hover:underline">
|
||||||
|
{hiddenRunCount} more active/recent run{hiddenRunCount === 1 ? "" : "s"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentRunCard({
|
const AgentRunCard = memo(function AgentRunCard({
|
||||||
companyId,
|
companyId,
|
||||||
run,
|
run,
|
||||||
issue,
|
issue,
|
||||||
@@ -153,4 +170,4 @@ function AgentRunCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import type { HeartbeatRun } from "@paperclipai/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RunActivityChart, SuccessRateChart } from "./ActivityCharts";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-04-20T12:00:00.000Z"));
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(ui: ReactNode) {
|
||||||
|
act(() => {
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
|
||||||
|
return {
|
||||||
|
id: "run-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
invocationSource: "on_demand",
|
||||||
|
triggerDetail: "manual",
|
||||||
|
status: "succeeded",
|
||||||
|
startedAt: new Date("2026-04-20T11:58:00.000Z"),
|
||||||
|
finishedAt: new Date("2026-04-20T11:59:00.000Z"),
|
||||||
|
error: null,
|
||||||
|
wakeupRequestId: null,
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
usageJson: null,
|
||||||
|
resultJson: null,
|
||||||
|
sessionIdBefore: null,
|
||||||
|
sessionIdAfter: null,
|
||||||
|
logStore: null,
|
||||||
|
logRef: null,
|
||||||
|
logBytes: null,
|
||||||
|
logSha256: null,
|
||||||
|
logCompressed: false,
|
||||||
|
stdoutExcerpt: null,
|
||||||
|
stderrExcerpt: null,
|
||||||
|
errorCode: null,
|
||||||
|
externalRunId: null,
|
||||||
|
processPid: null,
|
||||||
|
processGroupId: null,
|
||||||
|
processStartedAt: null,
|
||||||
|
retryOfRunId: null,
|
||||||
|
processLossRetryCount: 0,
|
||||||
|
livenessState: null,
|
||||||
|
livenessReason: null,
|
||||||
|
continuationAttempt: 0,
|
||||||
|
lastUsefulActionAt: null,
|
||||||
|
nextAction: null,
|
||||||
|
contextSnapshot: null,
|
||||||
|
createdAt: new Date("2026-04-20T11:58:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-20T11:59:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ActivityCharts", () => {
|
||||||
|
it("renders empty run charts when dashboard aggregate data is temporarily missing", () => {
|
||||||
|
render(<RunActivityChart activity={undefined} />);
|
||||||
|
expect(container.textContent).toContain("No runs yet");
|
||||||
|
|
||||||
|
render(<SuccessRateChart activity={undefined} />);
|
||||||
|
expect(container.textContent).toContain("No runs yet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still aggregates raw agent runs for detail charts", () => {
|
||||||
|
render(
|
||||||
|
<RunActivityChart
|
||||||
|
runs={[
|
||||||
|
createRun({ id: "run-success", status: "succeeded" }),
|
||||||
|
createRun({ id: "run-failed", status: "failed" }),
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain("No runs yet");
|
||||||
|
expect(container.querySelector("[title='2026-04-20: 2 runs']")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HeartbeatRun } from "@paperclipai/shared";
|
import type { DashboardRunActivityDay, HeartbeatRun } from "@paperclipai/shared";
|
||||||
|
|
||||||
/* ---- Utilities ---- */
|
/* ---- Utilities ---- */
|
||||||
|
|
||||||
@@ -58,11 +58,14 @@ export function ChartCard({ title, subtitle, children }: { title: string; subtit
|
|||||||
|
|
||||||
/* ---- Chart Components ---- */
|
/* ---- Chart Components ---- */
|
||||||
|
|
||||||
export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
|
type RunChartProps =
|
||||||
const days = getLast14Days();
|
| { activity?: DashboardRunActivityDay[] | null; runs?: never }
|
||||||
|
| { runs?: HeartbeatRun[] | null; activity?: never };
|
||||||
|
|
||||||
const grouped = new Map<string, { succeeded: number; failed: number; other: number }>();
|
function aggregateRuns(runs: readonly HeartbeatRun[] = []): DashboardRunActivityDay[] {
|
||||||
for (const day of days) grouped.set(day, { succeeded: 0, failed: 0, other: 0 });
|
const days = getLast14Days();
|
||||||
|
const grouped = new Map<string, DashboardRunActivityDay>();
|
||||||
|
for (const day of days) grouped.set(day, { date: day, succeeded: 0, failed: 0, other: 0, total: 0 });
|
||||||
for (const run of runs) {
|
for (const run of runs) {
|
||||||
const day = new Date(run.createdAt).toISOString().slice(0, 10);
|
const day = new Date(run.createdAt).toISOString().slice(0, 10);
|
||||||
const entry = grouped.get(day);
|
const entry = grouped.get(day);
|
||||||
@@ -70,10 +73,24 @@ export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
|
|||||||
if (run.status === "succeeded") entry.succeeded++;
|
if (run.status === "succeeded") entry.succeeded++;
|
||||||
else if (run.status === "failed" || run.status === "timed_out") entry.failed++;
|
else if (run.status === "failed" || run.status === "timed_out") entry.failed++;
|
||||||
else entry.other++;
|
else entry.other++;
|
||||||
|
entry.total++;
|
||||||
}
|
}
|
||||||
|
return Array.from(grouped.values());
|
||||||
|
}
|
||||||
|
|
||||||
const maxValue = Math.max(...Array.from(grouped.values()).map(v => v.succeeded + v.failed + v.other), 1);
|
function resolveRunActivity(props: RunChartProps): DashboardRunActivityDay[] {
|
||||||
const hasData = Array.from(grouped.values()).some(v => v.succeeded + v.failed + v.other > 0);
|
if (Array.isArray(props.activity)) return props.activity;
|
||||||
|
if (Array.isArray(props.runs)) return aggregateRuns(props.runs);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunActivityChart(props: RunChartProps) {
|
||||||
|
const activity = resolveRunActivity(props);
|
||||||
|
const days = activity.length > 0 ? activity.map((day) => day.date) : getLast14Days();
|
||||||
|
const grouped = new Map(activity.map((day) => [day.date, day]));
|
||||||
|
|
||||||
|
const maxValue = Math.max(...activity.map(v => v.total), 1);
|
||||||
|
const hasData = activity.some(v => v.total > 0);
|
||||||
|
|
||||||
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
|
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
|
||||||
|
|
||||||
@@ -81,8 +98,8 @@ export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-end gap-[3px] h-20">
|
<div className="flex items-end gap-[3px] h-20">
|
||||||
{days.map(day => {
|
{days.map(day => {
|
||||||
const entry = grouped.get(day)!;
|
const entry = grouped.get(day) ?? { date: day, succeeded: 0, failed: 0, other: 0, total: 0 };
|
||||||
const total = entry.succeeded + entry.failed + entry.other;
|
const total = entry.total;
|
||||||
const heightPct = (total / maxValue) * 100;
|
const heightPct = (total / maxValue) * 100;
|
||||||
return (
|
return (
|
||||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} runs`}>
|
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} runs`}>
|
||||||
@@ -224,26 +241,19 @@ export function IssueStatusChart({ issues }: { issues: { status: string; created
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SuccessRateChart({ runs }: { runs: HeartbeatRun[] }) {
|
export function SuccessRateChart(props: RunChartProps) {
|
||||||
const days = getLast14Days();
|
const activity = resolveRunActivity(props);
|
||||||
const grouped = new Map<string, { succeeded: number; total: number }>();
|
const days = activity.length > 0 ? activity.map((day) => day.date) : getLast14Days();
|
||||||
for (const day of days) grouped.set(day, { succeeded: 0, total: 0 });
|
const grouped = new Map(activity.map((day) => [day.date, day]));
|
||||||
for (const run of runs) {
|
|
||||||
const day = new Date(run.createdAt).toISOString().slice(0, 10);
|
|
||||||
const entry = grouped.get(day);
|
|
||||||
if (!entry) continue;
|
|
||||||
entry.total++;
|
|
||||||
if (run.status === "succeeded") entry.succeeded++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasData = Array.from(grouped.values()).some(v => v.total > 0);
|
const hasData = activity.some(v => v.total > 0);
|
||||||
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
|
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-end gap-[3px] h-20">
|
<div className="flex items-end gap-[3px] h-20">
|
||||||
{days.map(day => {
|
{days.map(day => {
|
||||||
const entry = grouped.get(day)!;
|
const entry = grouped.get(day) ?? { date: day, succeeded: 0, failed: 0, other: 0, total: 0 };
|
||||||
const rate = entry.total > 0 ? entry.succeeded / entry.total : 0;
|
const rate = entry.total > 0 ? entry.succeeded / entry.total : 0;
|
||||||
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444";
|
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444";
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1060,7 +1060,7 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
|
|||||||
userProfileMap,
|
userProfileMap,
|
||||||
});
|
});
|
||||||
const authorAvatar = (
|
const authorAvatar = (
|
||||||
<Avatar size="sm" className="mt-1 shrink-0">
|
<Avatar size="sm" className="shrink-0">
|
||||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={resolvedAuthorName} /> : null}
|
{avatarUrl ? <AvatarImage src={avatarUrl} alt={resolvedAuthorName} /> : null}
|
||||||
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
|
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@@ -1248,7 +1248,7 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
|
|||||||
return (
|
return (
|
||||||
<div id={anchorId}>
|
<div id={anchorId}>
|
||||||
<div className="flex items-start gap-2.5 py-1.5">
|
<div className="flex items-start gap-2.5 py-1.5">
|
||||||
<Avatar size="sm" className="mt-0.5 shrink-0">
|
<Avatar size="sm" className="shrink-0">
|
||||||
{agentIcon ? (
|
{agentIcon ? (
|
||||||
<AvatarFallback><AgentIcon icon={agentIcon} className="h-3.5 w-3.5" /></AvatarFallback>
|
<AvatarFallback><AgentIcon icon={agentIcon} className="h-3.5 w-3.5" /></AvatarFallback>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import type { TranscriptEntry } from "../adapters";
|
import type { TranscriptEntry } from "../adapters";
|
||||||
import type { LiveRunForIssue } from "../api/heartbeats";
|
import type { LiveRunForIssue } from "../api/heartbeats";
|
||||||
import { IssueChatThread } from "./IssueChatThread";
|
import { IssueChatThread } from "./IssueChatThread";
|
||||||
import type { IssueChatLinkedRun } from "../lib/issue-chat-messages";
|
import type { IssueChatLinkedRun } from "../lib/issue-chat-messages";
|
||||||
|
|
||||||
|
const EMPTY_COMMENTS: [] = [];
|
||||||
|
const EMPTY_TIMELINE_EVENTS: [] = [];
|
||||||
|
const EMPTY_LIVE_RUNS: [] = [];
|
||||||
|
const EMPTY_LINKED_RUNS: [] = [];
|
||||||
|
const handleEmbeddedAdd = async () => {};
|
||||||
|
|
||||||
function isRunActive(run: LiveRunForIssue) {
|
function isRunActive(run: LiveRunForIssue) {
|
||||||
return run.status === "queued" || run.status === "running";
|
return run.status === "queued" || run.status === "running";
|
||||||
}
|
}
|
||||||
@@ -15,18 +21,18 @@ interface RunChatSurfaceProps {
|
|||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RunChatSurface({
|
export const RunChatSurface = memo(function RunChatSurface({
|
||||||
run,
|
run,
|
||||||
transcript,
|
transcript,
|
||||||
hasOutput,
|
hasOutput,
|
||||||
companyId,
|
companyId,
|
||||||
}: RunChatSurfaceProps) {
|
}: RunChatSurfaceProps) {
|
||||||
const active = isRunActive(run);
|
const active = isRunActive(run);
|
||||||
const liveRuns = active ? [run] : [];
|
const liveRuns = useMemo(() => (active ? [run] : EMPTY_LIVE_RUNS), [active, run]);
|
||||||
const linkedRuns = useMemo<IssueChatLinkedRun[]>(
|
const linkedRuns = useMemo<IssueChatLinkedRun[]>(
|
||||||
() =>
|
() =>
|
||||||
active
|
active
|
||||||
? []
|
? EMPTY_LINKED_RUNS
|
||||||
: [{
|
: [{
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
status: run.status,
|
status: run.status,
|
||||||
@@ -45,12 +51,12 @@ export function RunChatSurface({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<IssueChatThread
|
<IssueChatThread
|
||||||
comments={[]}
|
comments={EMPTY_COMMENTS}
|
||||||
linkedRuns={linkedRuns}
|
linkedRuns={linkedRuns}
|
||||||
timelineEvents={[]}
|
timelineEvents={EMPTY_TIMELINE_EVENTS}
|
||||||
liveRuns={liveRuns}
|
liveRuns={liveRuns}
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
onAdd={async () => {}}
|
onAdd={handleEmbeddedAdd}
|
||||||
showComposer={false}
|
showComposer={false}
|
||||||
showJumpToLatest={false}
|
showJumpToLatest={false}
|
||||||
variant="embedded"
|
variant="embedded"
|
||||||
@@ -61,4 +67,4 @@ export function RunChatSurface({
|
|||||||
includeSucceededRunsWithoutOutput
|
includeSucceededRunsWithoutOutput
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
import { useLiveRunTranscripts } from "./useLiveRunTranscripts";
|
import { useLiveRunTranscripts } from "./useLiveRunTranscripts";
|
||||||
|
|
||||||
const { useQueryMock, logMock } = vi.hoisted(() => ({
|
const { useQueryMock, logMock, buildTranscriptMock } = vi.hoisted(() => ({
|
||||||
useQueryMock: vi.fn(() => ({ data: { censorUsernameInLogs: false } })),
|
useQueryMock: vi.fn(() => ({ data: { censorUsernameInLogs: false } })),
|
||||||
logMock: vi.fn(async () => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 })),
|
logMock: vi.fn(async () => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 })),
|
||||||
|
buildTranscriptMock: vi.fn((chunks: unknown[]) => chunks),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tanstack/react-query", () => ({
|
vi.mock("@tanstack/react-query", () => ({
|
||||||
@@ -28,7 +29,7 @@ vi.mock("../../api/heartbeats", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../adapters", () => ({
|
vi.mock("../../adapters", () => ({
|
||||||
buildTranscript: (chunks: unknown[]) => chunks,
|
buildTranscript: buildTranscriptMock,
|
||||||
getUIAdapter: () => null,
|
getUIAdapter: () => null,
|
||||||
onAdapterChange: () => () => {},
|
onAdapterChange: () => () => {},
|
||||||
}));
|
}));
|
||||||
@@ -73,7 +74,9 @@ describe("useLiveRunTranscripts", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
FakeWebSocket.instances = [];
|
FakeWebSocket.instances = [];
|
||||||
useQueryMock.mockClear();
|
useQueryMock.mockClear();
|
||||||
logMock.mockClear();
|
logMock.mockReset();
|
||||||
|
logMock.mockImplementation(async () => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 }));
|
||||||
|
buildTranscriptMock.mockClear();
|
||||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,4 +228,91 @@ describe("useLiveRunTranscripts", () => {
|
|||||||
});
|
});
|
||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can hydrate active runs without opening the live event socket", async () => {
|
||||||
|
function Harness() {
|
||||||
|
useLiveRunTranscripts({
|
||||||
|
companyId: "company-1",
|
||||||
|
runs: [{ id: "run-1", status: "running", adapterType: "codex_local" }],
|
||||||
|
enableRealtimeUpdates: false,
|
||||||
|
logReadLimitBytes: 64_000,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Harness />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(0);
|
||||||
|
expect(logMock).toHaveBeenCalledWith("run-1", 0, 64_000);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds only the transcript for the run that receives live output", async () => {
|
||||||
|
function Harness() {
|
||||||
|
useLiveRunTranscripts({
|
||||||
|
companyId: "company-1",
|
||||||
|
runs: [
|
||||||
|
{ id: "run-1", status: "running", adapterType: "codex_local" },
|
||||||
|
{ id: "run-2", status: "running", adapterType: "codex_local" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Harness />);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
expect(buildTranscriptMock).toHaveBeenCalledTimes(2);
|
||||||
|
buildTranscriptMock.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
FakeWebSocket.instances[0]!.onmessage?.(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: JSON.stringify({
|
||||||
|
companyId: "company-1",
|
||||||
|
type: "heartbeat.run.log",
|
||||||
|
createdAt: "2026-04-20T00:00:00.000Z",
|
||||||
|
payload: {
|
||||||
|
runId: "run-1",
|
||||||
|
ts: "2026-04-20T00:00:00.000Z",
|
||||||
|
stream: "stdout",
|
||||||
|
chunk: "hello from run 1\n",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buildTranscriptMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(buildTranscriptMock).toHaveBeenCalledWith(
|
||||||
|
[{ ts: "2026-04-20T00:00:00.000Z", stream: "stdout", chunk: "hello from run 1\n" }],
|
||||||
|
null,
|
||||||
|
{ censorUsernameInLogs: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { queryKeys } from "../../lib/queryKeys";
|
|||||||
|
|
||||||
const LOG_POLL_INTERVAL_MS = 2000;
|
const LOG_POLL_INTERVAL_MS = 2000;
|
||||||
const LOG_READ_LIMIT_BYTES = 256_000;
|
const LOG_READ_LIMIT_BYTES = 256_000;
|
||||||
|
const EMPTY_RUN_LOG_CHUNKS: RunLogChunk[] = [];
|
||||||
|
|
||||||
export interface RunTranscriptSource {
|
export interface RunTranscriptSource {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,6 +22,9 @@ interface UseLiveRunTranscriptsOptions {
|
|||||||
runs: RunTranscriptSource[];
|
runs: RunTranscriptSource[];
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
maxChunksPerRun?: number;
|
maxChunksPerRun?: number;
|
||||||
|
logPollIntervalMs?: number;
|
||||||
|
logReadLimitBytes?: number;
|
||||||
|
enableRealtimeUpdates?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readString(value: unknown): string | null {
|
function readString(value: unknown): string | null {
|
||||||
@@ -71,6 +75,9 @@ export function useLiveRunTranscripts({
|
|||||||
runs,
|
runs,
|
||||||
companyId,
|
companyId,
|
||||||
maxChunksPerRun = 200,
|
maxChunksPerRun = 200,
|
||||||
|
logPollIntervalMs = LOG_POLL_INTERVAL_MS,
|
||||||
|
logReadLimitBytes = LOG_READ_LIMIT_BYTES,
|
||||||
|
enableRealtimeUpdates = true,
|
||||||
}: UseLiveRunTranscriptsOptions) {
|
}: UseLiveRunTranscriptsOptions) {
|
||||||
const runsKey = useMemo(
|
const runsKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -87,6 +94,13 @@ export function useLiveRunTranscripts({
|
|||||||
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
|
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
|
||||||
const logOffsetByRunRef = useRef(new Map<string, number>());
|
const logOffsetByRunRef = useRef(new Map<string, number>());
|
||||||
const missingTerminalLogRunIdsRef = useRef(new Set<string>());
|
const missingTerminalLogRunIdsRef = useRef(new Set<string>());
|
||||||
|
const transcriptCacheRef = useRef(new Map<string, {
|
||||||
|
adapterType: string;
|
||||||
|
chunks: RunLogChunk[];
|
||||||
|
censorUsernameInLogs: boolean;
|
||||||
|
parserTick: number;
|
||||||
|
transcript: TranscriptEntry[];
|
||||||
|
}>());
|
||||||
// Tick counter to force transcript recomputation when dynamic parser loads
|
// Tick counter to force transcript recomputation when dynamic parser loads
|
||||||
const [parserTick, setParserTick] = useState(0);
|
const [parserTick, setParserTick] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -167,6 +181,11 @@ export function useLiveRunTranscripts({
|
|||||||
missingTerminalLogRunIdsRef.current.delete(runId);
|
missingTerminalLogRunIdsRef.current.delete(runId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const runId of transcriptCacheRef.current.keys()) {
|
||||||
|
if (!knownRunIds.has(runId)) {
|
||||||
|
transcriptCacheRef.current.delete(runId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [normalizedRuns]);
|
}, [normalizedRuns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,7 +199,7 @@ export function useLiveRunTranscripts({
|
|||||||
}
|
}
|
||||||
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
|
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
|
||||||
try {
|
try {
|
||||||
const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES);
|
const result = await heartbeatsApi.log(run.id, offset, logReadLimitBytes);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
appendChunks(run.id, parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current));
|
appendChunks(run.id, parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current));
|
||||||
@@ -214,19 +233,20 @@ export function useLiveRunTranscripts({
|
|||||||
|
|
||||||
void readAll();
|
void readAll();
|
||||||
const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status));
|
const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status));
|
||||||
const interval = activeRuns.length > 0
|
const interval = activeRuns.length > 0 && logPollIntervalMs > 0
|
||||||
? window.setInterval(() => {
|
? window.setInterval(() => {
|
||||||
void Promise.all(activeRuns.map((run) => readRunLog(run)));
|
void Promise.all(activeRuns.map((run) => readRunLog(run)));
|
||||||
}, LOG_POLL_INTERVAL_MS)
|
}, logPollIntervalMs)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (interval !== null) window.clearInterval(interval);
|
if (interval !== null) window.clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [normalizedRuns, runIdsKey]);
|
}, [logPollIntervalMs, logReadLimitBytes, normalizedRuns, runIdsKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enableRealtimeUpdates) return;
|
||||||
if (!companyId || activeRunIds.size === 0) return;
|
if (!companyId || activeRunIds.size === 0) return;
|
||||||
|
|
||||||
let closed = false;
|
let closed = false;
|
||||||
@@ -334,19 +354,45 @@ export function useLiveRunTranscripts({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [activeRunIds, companyId, runById]);
|
}, [activeRunIds, companyId, enableRealtimeUpdates, runById]);
|
||||||
|
|
||||||
const transcriptByRun = useMemo(() => {
|
const transcriptByRun = useMemo(() => {
|
||||||
const next = new Map<string, TranscriptEntry[]>();
|
const next = new Map<string, TranscriptEntry[]>();
|
||||||
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
|
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
|
||||||
|
const cache = transcriptCacheRef.current;
|
||||||
|
const currentRunIds = new Set<string>();
|
||||||
for (const run of normalizedRuns) {
|
for (const run of normalizedRuns) {
|
||||||
|
currentRunIds.add(run.id);
|
||||||
|
const chunks = chunksByRun.get(run.id) ?? EMPTY_RUN_LOG_CHUNKS;
|
||||||
|
const cached = cache.get(run.id);
|
||||||
|
if (
|
||||||
|
cached &&
|
||||||
|
cached.adapterType === run.adapterType &&
|
||||||
|
cached.chunks === chunks &&
|
||||||
|
cached.censorUsernameInLogs === censorUsernameInLogs &&
|
||||||
|
cached.parserTick === parserTick
|
||||||
|
) {
|
||||||
|
next.set(run.id, cached.transcript);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const adapter = getUIAdapter(run.adapterType);
|
const adapter = getUIAdapter(run.adapterType);
|
||||||
next.set(
|
const transcript = buildTranscript(chunks, adapter, {
|
||||||
run.id,
|
censorUsernameInLogs,
|
||||||
buildTranscript(chunksByRun.get(run.id) ?? [], adapter, {
|
});
|
||||||
censorUsernameInLogs,
|
cache.set(run.id, {
|
||||||
}),
|
adapterType: run.adapterType,
|
||||||
);
|
chunks,
|
||||||
|
censorUsernameInLogs,
|
||||||
|
parserTick,
|
||||||
|
transcript,
|
||||||
|
});
|
||||||
|
next.set(run.id, transcript);
|
||||||
|
}
|
||||||
|
for (const runId of cache.keys()) {
|
||||||
|
if (!currentRunIds.has(runId)) {
|
||||||
|
cache.delete(runId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
}, [chunksByRun, generalSettings?.censorUsernameInLogs, normalizedRuns, parserTick]);
|
}, [chunksByRun, generalSettings?.censorUsernameInLogs, normalizedRuns, parserTick]);
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ const dashboard: DashboardSummary = {
|
|||||||
pausedAgents: 0,
|
pausedAgents: 0,
|
||||||
pausedProjects: 0,
|
pausedProjects: 0,
|
||||||
},
|
},
|
||||||
|
runActivity: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("inbox helpers", () => {
|
describe("inbox helpers", () => {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { accessApi } from "../api/access";
|
|||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
|
||||||
import { buildCompanyUserProfileMap } from "../lib/company-members";
|
import { buildCompanyUserProfileMap } from "../lib/company-members";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
@@ -28,8 +27,6 @@ import { PageSkeleton } from "../components/PageSkeleton";
|
|||||||
import type { Agent, Issue } from "@paperclipai/shared";
|
import type { Agent, Issue } from "@paperclipai/shared";
|
||||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||||
|
|
||||||
const DASHBOARD_HEARTBEAT_RUN_LIMIT = 100;
|
|
||||||
|
|
||||||
function getRecentIssues(issues: Issue[]): Issue[] {
|
function getRecentIssues(issues: Issue[]): Issue[] {
|
||||||
return [...issues]
|
return [...issues]
|
||||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
@@ -78,12 +75,6 @@ export function Dashboard() {
|
|||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: runs } = useQuery({
|
|
||||||
queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "limit", DASHBOARD_HEARTBEAT_RUN_LIMIT],
|
|
||||||
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, DASHBOARD_HEARTBEAT_RUN_LIMIT),
|
|
||||||
enabled: !!selectedCompanyId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: companyMembers } = useQuery({
|
const { data: companyMembers } = useQuery({
|
||||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||||
@@ -300,7 +291,7 @@ export function Dashboard() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
||||||
<RunActivityChart runs={runs ?? []} />
|
<RunActivityChart activity={data.runActivity} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
|
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
|
||||||
<PriorityChart issues={issues ?? []} />
|
<PriorityChart issues={issues ?? []} />
|
||||||
@@ -309,7 +300,7 @@ export function Dashboard() {
|
|||||||
<IssueStatusChart issues={issues ?? []} />
|
<IssueStatusChart issues={issues ?? []} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
||||||
<SuccessRateChart runs={runs ?? []} />
|
<SuccessRateChart activity={data.runActivity} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user