Files
roboco/panel/src/app/(dashboard)/metrics/page.tsx
T
Renn F 517bee7d28 [chore] panel: prettier reformat across the codebase
Apply `pnpm format` (prettier 3.8.5, 80-col / double-quote / semi /
trailing-comma-all) to the 223 pre-existing panel files that predated the
prettier infra added in cb5365a4. Pure formatting — no semantic changes:
multi-line arrays/objects collapsed where they fit, trailing newlines added
(.prettierrc.json), import grouping unchanged.

Verified: `pnpm format:check` clean, `pnpm lint` clean, `pnpm typecheck`
clean, `pnpm test` 113/113 pass (7 files).
2026-06-27 01:12:05 +02:00

705 lines
22 KiB
TypeScript

"use client";
import { Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useOrchestratorStatus } from "@/hooks/use-agents";
import { useTasks } from "@/hooks/use-tasks";
import {
useUsageSummary,
useUsageTimeSeries,
useAgentUsage,
useTeamUsage,
useModelUsage,
useUsageProjection,
useCacheEfficiency,
useUsageSessions,
} from "@/hooks/use-usage";
import { TaskStatus, Team } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { OfflineState } from "@/components/ui/offline-state";
import { DeliveryTabContent } from "@/components/metrics/delivery-tab";
import {
UsageTimeSeriesChart,
ModelUsageDonut,
AgentUsageChart,
TeamUsageChart,
SessionsTable,
} from "@/components/metrics";
import {
Activity,
TrendingUp,
TrendingDown,
Clock,
AlertTriangle,
Users,
CheckCircle,
XCircle,
Zap,
Timer,
Coins,
Sparkles,
} from "lucide-react";
import type {
UsageProjection as UP,
CacheEfficiencyResponse as CER,
} from "@/types";
// ─── Humanized number formatting ─────────────────────────────────────────────
/** Format counts with K/M suffix for values >= 1000. */
function humanizeCount(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
return String(n);
}
/** Format token counts (same as humanizeCount but used for token display). */
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
return String(n);
}
// ─── Shared sub-components ────────────────────────────────────────────────────
interface MetricCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: React.ReactNode;
trend?: "up" | "down" | "neutral";
trendValue?: string;
}
function MetricCard({
title,
value,
subtitle,
icon,
trend,
trendValue,
}: MetricCardProps) {
const displayValue = typeof value === "number" ? humanizeCount(value) : value;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{displayValue}</div>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
)}
{trend && trendValue && (
<div
className={
"flex items-center gap-1 mt-2 text-xs " +
(trend === "up"
? "text-green-600"
: trend === "down"
? "text-red-600"
: "text-gray-500")
}
>
<TrendingUp
className={"h-3 w-3 " + (trend === "down" ? "rotate-180" : "")}
/>
{trendValue}
</div>
)}
</CardContent>
</Card>
);
}
interface TeamHealthCardProps {
team: Team;
activeTasks: number;
blockedTasks: number;
completedToday: number;
}
function TeamHealthCard({
team,
activeTasks,
blockedTasks,
completedToday,
}: TeamHealthCardProps) {
const healthScore = Math.max(0, 100 - blockedTasks * 20);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium capitalize">
{team.replace(/_/g, " ")} Cell
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 mb-3">
<Progress value={healthScore} className="flex-1" />
<span className="text-sm font-medium">{healthScore}%</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div>
<div className="font-semibold text-blue-600">{activeTasks}</div>
<div className="text-muted-foreground">Active</div>
</div>
<div>
<div className="font-semibold text-red-600">{blockedTasks}</div>
<div className="text-muted-foreground">Blocked</div>
</div>
<div>
<div className="font-semibold text-green-600">{completedToday}</div>
<div className="text-muted-foreground">Done</div>
</div>
</div>
</CardContent>
</Card>
);
}
// ─── Performance tab content ─────────────────────────────────────────────────
function PerformanceTabContent() {
const { data: tasks, error: tasksError, refetch: refetchTasks } = useTasks();
const {
data: status,
error: statusError,
refetch: refetchStatus,
} = useOrchestratorStatus();
const isOffline =
(tasksError || statusError) &&
(tasksError?.message?.includes("Network Error") ||
statusError?.message?.includes("Network Error"));
const refetch = () => {
refetchTasks();
refetchStatus();
};
const taskList = tasks || [];
const agentList = status?.agents || [];
// Velocity metrics
const completedToday = taskList.filter((t) => {
if (!t.completed_at) return false;
const completed = new Date(t.completed_at);
const today = new Date();
return completed.toDateString() === today.toDateString();
}).length;
const completedThisWeek = taskList.filter((t) => {
if (!t.completed_at) return false;
const completed = new Date(t.completed_at);
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return completed > weekAgo;
}).length;
// Task status counts
const pending = taskList.filter(
(t) => t.status === TaskStatus.PENDING,
).length;
const inProgress = taskList.filter(
(t) => t.status === TaskStatus.IN_PROGRESS,
).length;
const blocked = taskList.filter(
(t) => t.status === TaskStatus.BLOCKED,
).length;
const awaitingQa = taskList.filter(
(t) => t.status === TaskStatus.AWAITING_QA,
).length;
const completed = taskList.filter(
(t) => t.status === TaskStatus.COMPLETED,
).length;
// Agent counts
const runningAgents =
status?.by_state?.running ||
agentList.filter((a) => a.state === "running").length;
const idleAgents =
status?.by_state?.idle ||
agentList.filter((a) => a.state === "idle" || a.state === "stopped").length;
const waitingAgents =
status?.waiting_count ||
agentList.filter((a) => a.state === "waiting_long").length;
const errorAgents =
status?.by_state?.error ||
agentList.filter((a) => a.state === "error").length;
// Team metrics
const teamMetrics = Object.values(Team).map((team) => {
const teamTasks = taskList.filter((t) => t.team === team);
return {
team,
activeTasks: teamTasks.filter((t) =>
[TaskStatus.IN_PROGRESS, TaskStatus.CLAIMED].includes(t.status),
).length,
blockedTasks: teamTasks.filter((t) => t.status === TaskStatus.BLOCKED)
.length,
completedToday: teamTasks.filter((t) => {
if (!t.completed_at) return false;
const c = new Date(t.completed_at);
const today = new Date();
return c.toDateString() === today.toDateString();
}).length,
};
});
if (isOffline) {
return (
<OfflineState
title="Cannot Load Performance Metrics"
description="Start the RoboCo orchestrator to view performance analytics."
onRetry={refetch}
/>
);
}
return (
<div className="space-y-6">
{/* Velocity Metrics */}
<div>
<h2 className="text-lg font-semibold mb-3">Velocity</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4">
<MetricCard
title="Completed Today"
value={completedToday}
subtitle="Tasks finished"
icon={<Zap className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Completed This Week"
value={completedThisWeek}
subtitle="Rolling 7 days"
icon={<TrendingUp className="h-4 w-4 text-blue-500" />}
/>
<MetricCard
title="Total Completed"
value={completed}
subtitle="All time"
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Completion Rate"
value={
taskList.length > 0
? Math.round((completed / taskList.length) * 100) + "%"
: "0%"
}
subtitle="Of all tasks"
icon={<Activity className="h-4 w-4 text-purple-500" />}
/>
</div>
</div>
{/* Task Status */}
<div>
<h2 className="text-lg font-semibold mb-3">Task Status</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5">
<MetricCard
title="Pending"
value={pending}
icon={<Clock className="h-4 w-4 text-gray-500" />}
/>
<MetricCard
title="In Progress"
value={inProgress}
icon={<Activity className="h-4 w-4 text-blue-500" />}
/>
<MetricCard
title="Blocked"
value={blocked}
icon={<AlertTriangle className="h-4 w-4 text-red-500" />}
/>
<MetricCard
title="Awaiting QA"
value={awaitingQa}
icon={<Timer className="h-4 w-4 text-yellow-500" />}
/>
<MetricCard
title="Completed"
value={completed}
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
/>
</div>
</div>
{/* Agent Status */}
<div>
<h2 className="text-lg font-semibold mb-3">Agent Status</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4">
<MetricCard
title="Running"
value={runningAgents}
subtitle="Active agents"
icon={<Users className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Idle"
value={idleAgents}
subtitle="Available"
icon={<Users className="h-4 w-4 text-gray-500" />}
/>
<MetricCard
title="Waiting"
value={waitingAgents}
subtitle="Needs input"
icon={<Clock className="h-4 w-4 text-yellow-500" />}
/>
<MetricCard
title="Errors"
value={errorAgents}
subtitle="Failed agents"
icon={<XCircle className="h-4 w-4 text-red-500" />}
/>
</div>
</div>
{/* Team Health */}
<div>
<h2 className="text-lg font-semibold mb-3">Team Health</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
{teamMetrics.map((tm) => (
<TeamHealthCard
key={tm.team}
team={tm.team}
activeTasks={tm.activeTasks}
blockedTasks={tm.blockedTasks}
completedToday={tm.completedToday}
/>
))}
</div>
</div>
</div>
);
}
// ─── Token Usage & Costs tab content ─────────────────────────────────────────
function TokenUsageCostsSection() {
const { data: summary, isLoading: loadingSnap } = useUsageSummary("24h");
const { data: timeSeries, isLoading: loadingTS } = useUsageTimeSeries("24h");
const { data: agentUsage, isLoading: loadingAgents } = useAgentUsage("24h");
const { data: teamUsage, isLoading: loadingTeams } = useTeamUsage("24h");
const { data: sessions, isLoading: loadingSessions } = useUsageSessions(100);
const { data: modelUsage, isLoading: loadingModels } = useModelUsage("24h");
const { data: projection, isLoading: loadingProj } = useUsageProjection();
const { data: cacheStats, isLoading: loadingCache } =
useCacheEfficiency("24h");
const trendUp = (summary?.trend_pct ?? 0) >= 0;
return (
<div className="space-y-6">
{/* Row 1 — Summary cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 2xl:grid-cols-6">
<SummaryCard
title="Tokens Input"
value={summary ? fmtTokens(summary.tokens_input) : undefined}
icon={<Zap className="h-4 w-4 text-yellow-500" />}
isLoading={loadingSnap}
/>
<SummaryCard
title="Tokens Output"
value={summary ? fmtTokens(summary.tokens_output) : undefined}
icon={<Zap className="h-4 w-4 text-blue-500" />}
isLoading={loadingSnap}
/>
<SummaryCard
title="Total Cost (24h)"
value={summary ? "$" + summary.total_cost_usd.toFixed(4) : undefined}
icon={<Coins className="h-4 w-4 text-green-500" />}
isLoading={loadingSnap}
/>
<SummaryCard
title="Trend vs Prior"
value={
summary
? (trendUp ? "+" : "") + summary.trend_pct.toFixed(1) + "%"
: undefined
}
icon={
trendUp ? (
<TrendingUp className="h-4 w-4 text-red-500" />
) : (
<TrendingDown className="h-4 w-4 text-green-500" />
)
}
isLoading={loadingSnap}
/>
<SummaryCard
title="Total Tokens"
value={summary ? fmtTokens(summary.total_tokens) : undefined}
icon={<Activity className="h-4 w-4 text-blue-500" />}
isLoading={loadingSnap}
/>
<SummaryCard
title="Cache Saved"
value={
cacheStats
? "$" + cacheStats.cost_saved_by_cache_usd.toFixed(4)
: undefined
}
icon={<Sparkles className="h-4 w-4 text-purple-500" />}
isLoading={loadingCache}
/>
</div>
{/* Row 2 — Time series + model donut */}
<div className="grid gap-4 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3">
<div className="lg:col-span-2">
<UsageTimeSeriesChart data={timeSeries} isLoading={loadingTS} />
</div>
<ModelUsageDonut data={modelUsage} isLoading={loadingModels} />
</div>
{/* Row 3 — Agent bar + team bar */}
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2">
<AgentUsageChart data={agentUsage} isLoading={loadingAgents} />
<TeamUsageChart data={teamUsage} isLoading={loadingTeams} />
</div>
{/* Row 4 — Projection + cache efficiency */}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2">
<ProjectionCard projection={projection} isLoading={loadingProj} />
<CacheEfficiencyCard cacheStats={cacheStats} isLoading={loadingCache} />
</div>
{/* Row 5 — Sessions table */}
<SessionsTable data={sessions} isLoading={loadingSessions} />
</div>
);
}
// ─── Helper sub-components ────────────────────────────────────────────────────
interface SummaryCardProps {
title: string;
value: string | undefined;
icon: React.ReactNode;
trend?: { dir: "up" | "down"; label: string };
isLoading: boolean;
}
function SummaryCard({
title,
value,
icon,
trend,
isLoading,
}: SummaryCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{icon}
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-7 w-24" />
) : (
<>
<div className="text-2xl font-bold">{value ?? "—"}</div>
{trend && (
<p
className={
"text-xs mt-1 " +
(trend.dir === "up" ? "text-red-500" : "text-green-500")
}
>
{trend.label}
</p>
)}
</>
)}
</CardContent>
</Card>
);
}
interface ProjectionCardProps {
projection: UP | undefined;
isLoading: boolean;
}
function ProjectionCard({ projection, isLoading }: ProjectionCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-blue-500" />
Monthly Projection
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<div>
<div className="text-3xl font-bold">
{projection != null
? "$" + projection.projected_monthly_cost_usd.toFixed(2)
: "—"}
</div>
<p className="text-xs text-muted-foreground mt-1">
Based on {projection?.basis_days ?? 7}-day rolling average ($
{projection?.avg_daily_cost_usd.toFixed(4) ?? "—"}/day)
</p>
</div>
)}
</CardContent>
</Card>
);
}
interface CacheEfficiencyCardProps {
cacheStats: CER | undefined;
isLoading: boolean;
}
function CacheEfficiencyCard({
cacheStats,
isLoading,
}: CacheEfficiencyCardProps) {
const pct = cacheStats ? cacheStats.cache_hit_rate * 100 : 0;
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-500" />
Cache Efficiency
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<div>
<div className="text-3xl font-bold">{pct.toFixed(1)}%</div>
<p className="text-xs text-muted-foreground mt-1">
{cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache
reads · saved $
{cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
</p>
<Progress value={pct} className="mt-2" />
</div>
)}
</CardContent>
</Card>
);
}
// ─── Tab types ────────────────────────────────────────────────────────────────
type MetricsTab = "performance" | "token-usage" | "delivery";
const VALID_METRICS_TABS: MetricsTab[] = [
"performance",
"token-usage",
"delivery",
];
function isValidMetricsTab(value: string | null): value is MetricsTab {
return VALID_METRICS_TABS.includes(value as MetricsTab);
}
// ─── Main page content (uses useSearchParams) ─────────────────────────────────
function MetricsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read ?tab= from URL, default to "performance"
const rawTab = searchParams.get("tab");
const activeTab: MetricsTab = isValidMetricsTab(rawTab)
? rawTab
: "performance";
function handleTabChange(value: string) {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.push(`?${params.toString()}`);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Metrics</h1>
<p className="text-muted-foreground">
Performance analytics and operational insights
</p>
</div>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="performance">Performance</TabsTrigger>
<TabsTrigger value="token-usage">Token Usage</TabsTrigger>
<TabsTrigger value="delivery">Delivery</TabsTrigger>
</TabsList>
<TabsContent value="performance" className="mt-6">
<PerformanceTabContent />
</TabsContent>
<TabsContent value="token-usage" className="mt-6">
<TokenUsageCostsSection />
</TabsContent>
<TabsContent value="delivery" className="mt-6">
<DeliveryTabContent />
</TabsContent>
</Tabs>
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function MetricsPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-32 mb-2" />
<Skeleton className="h-5 w-72" />
</div>
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
</div>
}
>
<MetricsPageContent />
</Suspense>
);
}