feat(08-02): create 5 dashboard chart components with ChartSection container
- Add RunActivityChart (area chart), SuccessRateChart (line chart), CostTrendChart (area chart), AgentWorkloadChart (bar chart) using Recharts - Add BudgetUtilization with CSS progress bars, color thresholds (success/warn/danger), and empty state - Create ChartSection container orchestrating all charts with Framer Motion entrance animation - Integrate ChartSection into Dashboard.tsx between MetricsGrid and NeedsAttention - Add companyId prop to DashboardProps and pass from App.tsx - Include Plan 01 infrastructure: chart-utils.ts, ChartCard.tsx, ChartTooltip.tsx, recharts dependency - All charts respect reduced motion via useReducedMotion hook - 16 unit tests pass for aggregation functions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c1de05a7f1
commit
b6c5b2a9ee
14 files changed
+1248
-3
No files matched your search
@@ -783,6 +783,7 @@ export function App() {
|
||||
{section === "overview" ? (
|
||||
<Dashboard
|
||||
companyName={currentCompany?.name}
|
||||
companyId={currentCompany?.id}
|
||||
agents={companyAgents}
|
||||
tasks={tasks}
|
||||
runs={runs}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { RunRecord, CostRecord, AgentRecord } from "@shared/types";
|
||||
import { useReducedMotion } from "../ui";
|
||||
import {
|
||||
aggregateRunsByDay,
|
||||
aggregateCostsByDay,
|
||||
aggregateAgentWorkload,
|
||||
} from "./charts/chart-utils";
|
||||
import { ChartCard } from "./charts/ChartCard";
|
||||
import { RunActivityChart } from "./charts/RunActivityChart";
|
||||
import { SuccessRateChart } from "./charts/SuccessRateChart";
|
||||
import { CostTrendChart } from "./charts/CostTrendChart";
|
||||
import { AgentWorkloadChart } from "./charts/AgentWorkloadChart";
|
||||
import { BudgetUtilization } from "./charts/BudgetUtilization";
|
||||
|
||||
interface ChartSectionProps {
|
||||
runs: RunRecord[];
|
||||
costs: CostRecord[];
|
||||
agents: AgentRecord[];
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export function ChartSection({ runs, costs, agents, companyId }: ChartSectionProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const dailyRuns = useMemo(() => aggregateRunsByDay(runs, 14), [runs]);
|
||||
const dailyCosts = useMemo(() => aggregateCostsByDay(costs, 14), [costs]);
|
||||
const agentWorkload = useMemo(
|
||||
() => aggregateAgentWorkload(runs, agents),
|
||||
[runs, agents],
|
||||
);
|
||||
|
||||
const hasRunData = dailyRuns.some((d) => d.total > 0);
|
||||
const hasCostData = dailyCosts.some((d) => d.total > 0);
|
||||
|
||||
const content = (
|
||||
<div className="space-y-6">
|
||||
{/* Section header */}
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">
|
||||
Performance
|
||||
</div>
|
||||
|
||||
{/* 2-column chart grid */}
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<ChartCard
|
||||
title="Run Activity"
|
||||
ariaLabel={`Run activity chart showing ${dailyRuns.reduce((s, d) => s + d.total, 0)} total runs over 14 days`}
|
||||
isEmpty={!hasRunData}
|
||||
>
|
||||
<RunActivityChart data={dailyRuns} reducedMotion={reducedMotion} />
|
||||
</ChartCard>
|
||||
<ChartCard
|
||||
title="Success Rate"
|
||||
ariaLabel="Success rate chart showing percentage of succeeded runs over 14 days"
|
||||
isEmpty={!hasRunData}
|
||||
>
|
||||
<SuccessRateChart data={dailyRuns} reducedMotion={reducedMotion} />
|
||||
</ChartCard>
|
||||
<ChartCard
|
||||
title="Cost Trend"
|
||||
ariaLabel="Cost trend chart showing daily spend over 14 days"
|
||||
isEmpty={!hasCostData}
|
||||
>
|
||||
<CostTrendChart data={dailyCosts} reducedMotion={reducedMotion} />
|
||||
</ChartCard>
|
||||
<ChartCard
|
||||
title="Agent Workload"
|
||||
ariaLabel="Agent workload chart showing run distribution across agents"
|
||||
isEmpty={agentWorkload.length === 0}
|
||||
>
|
||||
<AgentWorkloadChart data={agentWorkload} reducedMotion={reducedMotion} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Budget Utilization section */}
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">
|
||||
Budget Utilization
|
||||
</div>
|
||||
<BudgetUtilization companyId={companyId} agents={agents} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (reducedMotion) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -27,9 +27,11 @@ import { MetricCard } from "./MetricsGrid";
|
||||
import { ActivityFeed } from "./ActivityFeed";
|
||||
import { RecoveryBanner } from "./RecoveryBanner";
|
||||
import { BudgetAlertBanner } from "./BudgetAlertBanner";
|
||||
import { ChartSection } from "./ChartSection";
|
||||
|
||||
export interface DashboardProps {
|
||||
companyName?: string;
|
||||
companyId?: string;
|
||||
agents: AgentRecord[];
|
||||
tasks: TaskRecord[];
|
||||
runs: RunRecord[];
|
||||
@@ -44,7 +46,7 @@ export interface DashboardProps {
|
||||
|
||||
export function Dashboard(props: DashboardProps) {
|
||||
const t = useT();
|
||||
const { companyName, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
|
||||
const { companyName, companyId, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
|
||||
|
||||
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||||
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
|
||||
@@ -131,6 +133,9 @@ export function Dashboard(props: DashboardProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Performance Charts */}
|
||||
{companyId ? <ChartSection runs={runs} costs={costs} agents={agents} companyId={companyId} /> : null}
|
||||
|
||||
{/* Needs Attention — moved to top, only shows when items exist */}
|
||||
{hasAttentionItems ? (
|
||||
<section>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import type { AgentWorkloadData } from "./chart-utils";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
|
||||
interface AgentWorkloadChartProps {
|
||||
data: AgentWorkloadData[];
|
||||
reducedMotion: boolean;
|
||||
}
|
||||
|
||||
export function AgentWorkloadChart({ data, reducedMotion }: AgentWorkloadChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--line)"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={{ stroke: "var(--line)" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip valueSuffix=" runs" />} />
|
||||
<Bar
|
||||
dataKey="runs"
|
||||
fill="var(--accent)"
|
||||
fillOpacity={0.85}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={40}
|
||||
isAnimationActive={!reducedMotion}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
|
||||
interface BudgetUtilizationProps {
|
||||
companyId: string;
|
||||
agents: AgentRecord[];
|
||||
}
|
||||
|
||||
interface BudgetRow {
|
||||
id: string;
|
||||
name: string;
|
||||
spent: number;
|
||||
budget: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
function getBarColor(pct: number): string {
|
||||
if (pct >= 100) return "bg-[color:var(--danger)]";
|
||||
if (pct >= 80) return "bg-[color:var(--warn)]";
|
||||
return "bg-[color:var(--success)]";
|
||||
}
|
||||
|
||||
export function BudgetUtilization({ companyId: _companyId, agents }: BudgetUtilizationProps) {
|
||||
const rows = useMemo<BudgetRow[]>(() => {
|
||||
return agents
|
||||
.filter((a) => a.budgetMonthlyUsd > 0)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
spent: a.spentMonthlyUsd,
|
||||
budget: a.budgetMonthlyUsd,
|
||||
pct: Math.round((a.spentMonthlyUsd / a.budgetMonthlyUsd) * 100),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [agents]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4">
|
||||
<div className="flex h-[120px] flex-col items-center justify-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[color:var(--muted)]">
|
||||
No budget policies configured
|
||||
</span>
|
||||
<span className="max-w-[200px] text-center text-[11px] text-[color:var(--muted)]">
|
||||
Set budget policies in Settings to track utilization here.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">
|
||||
{row.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-[13px] text-[color:var(--muted)]"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{row.pct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-1.5 rounded-full bg-[color:var(--panel-soft)]">
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${getBarColor(row.pct)}`}
|
||||
style={{ width: `${Math.min(row.pct, 100)}%` }}
|
||||
/>
|
||||
{/* 80% warning marker */}
|
||||
<div
|
||||
className="absolute top-0 h-full w-px bg-[color:var(--warn)] opacity-50"
|
||||
style={{ left: "80%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,57 @@
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import type { DailyCostData } from "./chart-utils";
|
||||
import { formatShortDate } from "./chart-utils";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
|
||||
interface CostTrendChartProps {
|
||||
data: DailyCostData[];
|
||||
reducedMotion: boolean;
|
||||
}
|
||||
|
||||
export function CostTrendChart({ data, reducedMotion }: CostTrendChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={data}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--line)"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatShortDate}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={{ stroke: "var(--line)" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(val: number) => "$" + val.toFixed(0)}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip valuePrefix="$" />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
stroke="var(--accent)"
|
||||
fill="var(--accent-soft)"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "var(--accent)" }}
|
||||
isAnimationActive={!reducedMotion}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import type { DailyRunData } from "./chart-utils";
|
||||
import { formatShortDate } from "./chart-utils";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
|
||||
interface RunActivityChartProps {
|
||||
data: DailyRunData[];
|
||||
reducedMotion: boolean;
|
||||
}
|
||||
|
||||
export function RunActivityChart({ data, reducedMotion }: RunActivityChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={data}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--line)"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatShortDate}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={{ stroke: "var(--line)" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip valueSuffix=" runs" />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
stroke="var(--accent)"
|
||||
fill="var(--accent-soft)"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "var(--accent)" }}
|
||||
isAnimationActive={!reducedMotion}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import type { DailyRunData } from "./chart-utils";
|
||||
import { formatShortDate } from "./chart-utils";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
|
||||
interface SuccessRateChartProps {
|
||||
data: DailyRunData[];
|
||||
reducedMotion: boolean;
|
||||
}
|
||||
|
||||
export function SuccessRateChart({ data, reducedMotion }: SuccessRateChartProps) {
|
||||
const rateData = useMemo(
|
||||
() =>
|
||||
data.map((d) => ({
|
||||
date: d.date,
|
||||
rate: d.total > 0 ? Math.round((d.succeeded / d.total) * 100) : 0,
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={rateData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--line)"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatShortDate}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={{ stroke: "var(--line)" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(val: number) => `${val}%`}
|
||||
tick={{ fontSize: 11, fill: "var(--muted)" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip valueSuffix="%" />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rate"
|
||||
stroke="var(--success)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "var(--success)" }}
|
||||
isAnimationActive={!reducedMotion}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -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