Merge branch 'worktree-agent-a5b08020'
# Conflicts: # .planning/STATE.md # package.json # pnpm-lock.yaml
This commit is contained in:
commit
907a5dfe2f
7 files changed
+420
No files matched your search
@@ -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,6 +27,7 @@ import { MetricCard } from "./MetricsGrid";
|
|||||||
import { ActivityFeed } from "./ActivityFeed";
|
import { ActivityFeed } from "./ActivityFeed";
|
||||||
import { RecoveryBanner } from "./RecoveryBanner";
|
import { RecoveryBanner } from "./RecoveryBanner";
|
||||||
import { BudgetAlertBanner } from "./BudgetAlertBanner";
|
import { BudgetAlertBanner } from "./BudgetAlertBanner";
|
||||||
|
import { ChartSection } from "./ChartSection";
|
||||||
|
|
||||||
export interface DashboardProps {
|
export interface DashboardProps {
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
@@ -132,6 +133,9 @@ export function Dashboard(props: DashboardProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Performance Charts */}
|
||||||
|
{companyId ? <ChartSection runs={runs} costs={costs} agents={agents} companyId={companyId} /> : null}
|
||||||
|
|
||||||
{/* Needs Attention — moved to top, only shows when items exist */}
|
{/* Needs Attention — moved to top, only shows when items exist */}
|
||||||
{hasAttentionItems ? (
|
{hasAttentionItems ? (
|
||||||
<section>
|
<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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user