Merge branch 'worktree-agent-a1f54db6'
# Conflicts: # .planning/STATE.md
This commit is contained in:
commit
40b01f24e9
6 files changed
+825
-2
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { ChartLine } from "@phosphor-icons/react";
|
||||
|
||||
interface ChartCardProps {
|
||||
title: string;
|
||||
period?: string;
|
||||
isEmpty?: boolean;
|
||||
emptyHeading?: string;
|
||||
emptyBody?: string;
|
||||
ariaLabel?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
period = "14d",
|
||||
isEmpty = false,
|
||||
emptyHeading = "No activity yet",
|
||||
emptyBody = "Charts will appear once your company starts running tasks.",
|
||||
ariaLabel,
|
||||
children,
|
||||
}: ChartCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-[11px] text-[color:var(--muted)]">
|
||||
{period}
|
||||
</span>
|
||||
</div>
|
||||
{isEmpty ? (
|
||||
<div className="flex h-[220px] flex-col items-center justify-center gap-2">
|
||||
<ChartLine
|
||||
size={32}
|
||||
className="text-[color:var(--muted)] opacity-50"
|
||||
/>
|
||||
<span className="text-[13px] font-semibold text-[color:var(--muted)]">
|
||||
{emptyHeading}
|
||||
</span>
|
||||
<span className="max-w-[200px] text-center text-[11px] text-[color:var(--muted)]">
|
||||
{emptyBody}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: 220 }}>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { TooltipContentProps } from "recharts";
|
||||
|
||||
interface ChartTooltipExtraProps {
|
||||
valuePrefix?: string;
|
||||
valueSuffix?: string;
|
||||
}
|
||||
|
||||
type Props = Partial<TooltipContentProps<number, string>> & ChartTooltipExtraProps;
|
||||
|
||||
export function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
valuePrefix = "",
|
||||
valueSuffix = "",
|
||||
}: Props) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const dateStr =
|
||||
typeof label === "string"
|
||||
? new Date(label + "T00:00:00").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: String(label);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-[6px] border border-[color:var(--line-strong)] bg-[color:var(--panel)] px-3 py-2 shadow-md"
|
||||
style={{ maxWidth: 200, zIndex: 50 }}
|
||||
>
|
||||
<div className="text-[11px] text-[color:var(--muted)]">{dateStr}</div>
|
||||
{payload.map((entry, i) => (
|
||||
<div key={i} className="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span
|
||||
className="text-[13px] font-semibold text-[color:var(--text)]"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{valuePrefix}
|
||||
{typeof entry.value === "number"
|
||||
? entry.value.toLocaleString()
|
||||
: entry.value}
|
||||
{valueSuffix}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { RunRecord, CostRecord, AgentRecord } from "@shared/types";
|
||||
|
||||
export interface DailyRunData {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface DailyCostData {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
total: number; // USD amount (NOT cents)
|
||||
}
|
||||
|
||||
export interface AgentWorkloadData {
|
||||
name: string; // agent name, truncated to 10 chars
|
||||
runs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate runs into daily buckets for chart consumption.
|
||||
* Returns `days` entries sorted ascending by date (oldest first).
|
||||
* Days with no runs have {total:0, succeeded:0, failed:0}.
|
||||
*/
|
||||
export function aggregateRunsByDay(runs: RunRecord[], days: number): DailyRunData[] {
|
||||
const now = new Date();
|
||||
const buckets = new Map<string, { total: number; succeeded: number; failed: number }>();
|
||||
|
||||
// Initialize all day buckets with zero values
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
buckets.set(key, { total: 0, succeeded: 0, failed: 0 });
|
||||
}
|
||||
|
||||
// Fill buckets from actual data
|
||||
for (const run of runs) {
|
||||
const day = run.createdAt.slice(0, 10);
|
||||
const bucket = buckets.get(day);
|
||||
if (!bucket) continue;
|
||||
bucket.total++;
|
||||
if (run.status === "succeeded") bucket.succeeded++;
|
||||
if (run.status === "failed" || run.status === "timed_out" || run.status === "interrupted") {
|
||||
bucket.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Return sorted ascending by date
|
||||
return Array.from(buckets.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, data]) => ({ date, ...data }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate costs into daily buckets for chart consumption.
|
||||
* Returns `days` entries sorted ascending by date.
|
||||
* Days with no costs have {total:0}. Null amountUsd is treated as 0.
|
||||
*/
|
||||
export function aggregateCostsByDay(costs: CostRecord[], days: number): DailyCostData[] {
|
||||
const now = new Date();
|
||||
const buckets = new Map<string, number>();
|
||||
|
||||
// Initialize all day buckets with zero
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
buckets.set(key, 0);
|
||||
}
|
||||
|
||||
// Sum costs per day
|
||||
for (const cost of costs) {
|
||||
const day = cost.createdAt.slice(0, 10);
|
||||
const current = buckets.get(day);
|
||||
if (current === undefined) continue;
|
||||
buckets.set(day, current + (cost.amountUsd ?? 0));
|
||||
}
|
||||
|
||||
// Return sorted ascending by date
|
||||
return Array.from(buckets.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, total]) => ({ date, total }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Group runs by agent and return workload data sorted by run count descending.
|
||||
* If more than maxAgents, keep top maxAgents and aggregate rest into "Others".
|
||||
* Agent names longer than 10 chars are truncated with ellipsis.
|
||||
*/
|
||||
export function aggregateAgentWorkload(
|
||||
runs: RunRecord[],
|
||||
agents: AgentRecord[],
|
||||
maxAgents: number = 8,
|
||||
): AgentWorkloadData[] {
|
||||
// Build name lookup from agents array
|
||||
const nameMap = new Map<string, string>();
|
||||
for (const agent of agents) {
|
||||
nameMap.set(agent.id, agent.name);
|
||||
}
|
||||
|
||||
// Count runs per agent
|
||||
const counts = new Map<string, number>();
|
||||
for (const run of runs) {
|
||||
counts.set(run.agentId, (counts.get(run.agentId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Sort by count descending
|
||||
const sorted = Array.from(counts.entries())
|
||||
.sort(([, a], [, b]) => b - a);
|
||||
|
||||
const truncateName = (name: string): string =>
|
||||
name.length > 10 ? name.slice(0, 10) + "..." : name;
|
||||
|
||||
// If within limit, return all
|
||||
if (sorted.length <= maxAgents) {
|
||||
return sorted.map(([agentId, runCount]) => ({
|
||||
name: truncateName(nameMap.get(agentId) ?? agentId),
|
||||
runs: runCount,
|
||||
}));
|
||||
}
|
||||
|
||||
// Split into top agents and "Others"
|
||||
const top = sorted.slice(0, maxAgents);
|
||||
const rest = sorted.slice(maxAgents);
|
||||
const othersRuns = rest.reduce((sum, [, count]) => sum + count, 0);
|
||||
|
||||
const result: AgentWorkloadData[] = top.map(([agentId, runCount]) => ({
|
||||
name: truncateName(nameMap.get(agentId) ?? agentId),
|
||||
runs: runCount,
|
||||
}));
|
||||
|
||||
result.push({ name: "Others", runs: othersRuns });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD date string as "MMM dd" (e.g., "Mar 15").
|
||||
* Uses T00:00:00 suffix to avoid timezone shift.
|
||||
*/
|
||||
export function formatShortDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
Reference in new issue
Block a user