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 { RecoveryBanner } from "./RecoveryBanner";
|
||||
import { BudgetAlertBanner } from "./BudgetAlertBanner";
|
||||
import { ChartSection } from "./ChartSection";
|
||||
|
||||
export interface DashboardProps {
|
||||
companyName?: string;
|
||||
@@ -132,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,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