4aae61297f
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
3341 lines
131 KiB
TypeScript
3341 lines
131 KiB
TypeScript
/**
|
||
* @file Dashboard.tsx
|
||
* @description Main dashboard page showing real-time stats, active agents, recent activity feed, an Analytics tab, and a System Health tab for Claude Code sessions; a recent event's right-click opens its session's `SessionRowMenu`.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
/* =============================================================================
|
||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||
* =============================================================================
|
||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||
*
|
||
* ## Design constraints
|
||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||
*
|
||
* ## Remote data & SSH
|
||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||
*
|
||
* ## Internal dependencies
|
||
* - `../lib/api`
|
||
* - `../lib/eventBus`
|
||
* - `../lib/dataScope`
|
||
* - `../components/StatCard`
|
||
* - `../components/AgentCard`
|
||
* - `../components/StatusBadge`
|
||
* - `../components/EmptyState`
|
||
* - `../components/Tip`
|
||
* - `../lib/format`
|
||
* - `../lib/types`
|
||
*
|
||
* ## Public surface
|
||
* - `Dashboard` — exported API; see TSDoc on the symbol for behavior.
|
||
*
|
||
* ## Testing pointers
|
||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||
*
|
||
* ## Related docs
|
||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||
* - `docs/API.md` — REST reference.
|
||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||
* ============================================================================= */
|
||
/* -----------------------------------------------------------------------------
|
||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||
* -----------------------------------------------------------------------------
|
||
* **Dashboard**
|
||
* Part of this module's public contract. Downstream imports should treat
|
||
* the signature and return type as stable unless release notes say otherwise.
|
||
* When behavior changes, update the `@file` overview and relevant tests.
|
||
*
|
||
* ----------------------------------------------------------------------------- */
|
||
|
||
import {
|
||
useEffect,
|
||
useState,
|
||
useCallback,
|
||
useSyncExternalStore,
|
||
useMemo,
|
||
useRef,
|
||
lazy,
|
||
Suspense,
|
||
type ReactNode,
|
||
} from "react";
|
||
import { Link, useSearchParams } from "react-router-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
LayoutDashboard,
|
||
FolderOpen,
|
||
Bot,
|
||
Zap,
|
||
DollarSign,
|
||
Activity,
|
||
ArrowRight,
|
||
RefreshCw,
|
||
GitBranch,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
CircleDot,
|
||
Server,
|
||
HardDrive,
|
||
Plug,
|
||
Cpu,
|
||
BarChart3,
|
||
ShieldCheck,
|
||
Database,
|
||
Search,
|
||
Download,
|
||
Clock,
|
||
AlertCircle,
|
||
} from "lucide-react";
|
||
import { api } from "../lib/api";
|
||
import { eventBus } from "../lib/eventBus";
|
||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||
import { useDataScope } from "../lib/dataScope";
|
||
import { StatCard } from "../components/StatCard";
|
||
import { AgentCard } from "../components/AgentCard";
|
||
import { useContextMenu } from "../components/ContextMenu";
|
||
import { SessionRowMenu } from "../components/SessionRowMenu";
|
||
import { NeedsYouStrip } from "../components/NeedsYouStrip";
|
||
import { RouteErrorBoundary } from "../components/RouteErrorBoundary";
|
||
import { AgentStatusBadge } from "../components/StatusBadge";
|
||
import { EmptyState } from "../components/EmptyState";
|
||
import { Tip } from "../components/Tip";
|
||
import { Skeleton } from "../components/Skeleton";
|
||
import { Select } from "../components/Select";
|
||
import { AiToolIcon } from "../components/AiToolIcon";
|
||
import { useAiTools } from "../hooks/useAiTools";
|
||
import { spendPace } from "../lib/burnRate";
|
||
import { RANGE_PRESETS, parsePreset, rangeDates } from "../lib/dateRange";
|
||
import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format";
|
||
import { statusFromEventType } from "../lib/event-grouping";
|
||
import type {
|
||
BudgetStatus,
|
||
Stats,
|
||
Agent,
|
||
DashboardEvent,
|
||
WSMessage,
|
||
WorkflowData,
|
||
Session,
|
||
Analytics as AnalyticsData,
|
||
CostResult,
|
||
} from "../lib/types";
|
||
|
||
interface SystemInfo {
|
||
db: {
|
||
path: string;
|
||
size: number;
|
||
counts: Record<string, number>;
|
||
pragmas: {
|
||
journal_mode: string;
|
||
synchronous: number;
|
||
auto_vacuum: number;
|
||
encoding: string;
|
||
foreign_keys: number;
|
||
busy_timeout: number;
|
||
};
|
||
load_stats: { m5: number; m15: number; h1: number };
|
||
};
|
||
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||
server: {
|
||
uptime: number;
|
||
node_version: string;
|
||
platform: string;
|
||
ws_connections: number;
|
||
memory: {
|
||
rss: number;
|
||
heapTotal: number;
|
||
heapUsed: number;
|
||
external: number;
|
||
/** V8's heap_size_limit; absent on older servers (fall back to heapTotal). */
|
||
heapLimit?: number;
|
||
};
|
||
cpu_load: number[];
|
||
arch: string;
|
||
total_mem: number;
|
||
free_mem: number;
|
||
cpus: number;
|
||
};
|
||
transcript_cache: {
|
||
size: number;
|
||
maxSize: number;
|
||
hits: number;
|
||
misses: number;
|
||
keys: string[];
|
||
};
|
||
}
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function formatUptime(seconds: number): string {
|
||
const d = Math.floor(seconds / 86400);
|
||
const h = Math.floor((seconds % 86400) / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||
if (h > 0) return `${h}h ${m}m`;
|
||
return `${m}m`;
|
||
}
|
||
|
||
// ── Analytics tab (merged from the former standalone Analytics page) ─────────
|
||
|
||
function ChartTooltip({ x, y, children }: { x: number; y: number; children: ReactNode }) {
|
||
const nearRight = x > window.innerWidth - 200;
|
||
return (
|
||
<div
|
||
className="fixed z-50 px-2 py-1.5 text-xs bg-surface-2 border border-border rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||
style={{
|
||
left: nearRight ? x - 14 : x + 14,
|
||
top: y - 10,
|
||
transform: nearRight ? "translateX(-100%)" : undefined,
|
||
}}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function useTooltip() {
|
||
const [tooltip, setTooltip] = useState<{
|
||
x: number;
|
||
y: number;
|
||
content: ReactNode;
|
||
} | null>(null);
|
||
|
||
const show = (e: React.MouseEvent, content: ReactNode) => {
|
||
setTooltip({ x: e.clientX, y: e.clientY, content });
|
||
};
|
||
const move = (e: React.MouseEvent) => {
|
||
setTooltip((t) => t && { ...t, x: e.clientX, y: e.clientY });
|
||
};
|
||
const hide = () => setTooltip(null);
|
||
|
||
const node = tooltip ? (
|
||
<ChartTooltip x={tooltip.x} y={tooltip.y}>
|
||
{tooltip.content}
|
||
</ChartTooltip>
|
||
) : null;
|
||
|
||
return { show, move, hide, node };
|
||
}
|
||
|
||
/** Heatmap cell fill: the page surface mixed toward the accent on a log
|
||
* scale. Built from theme tokens so the ramp reads surface → accent in every
|
||
* theme; the old fixed indigo ramp went near-black on light themes. Exported
|
||
* for its test. */
|
||
export function cellColor(count: number, max: number) {
|
||
if (count === 0) return "rgb(var(--surface-0))";
|
||
const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1);
|
||
// Floor at 20% so a single event still reads as distinct from an empty day.
|
||
const pct = Math.round(20 + 80 * t);
|
||
return `color-mix(in srgb, rgb(var(--accent)) ${pct}%, rgb(var(--surface-0)))`;
|
||
}
|
||
|
||
function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }>> }) {
|
||
const { show, move, hide, node } = useTooltip();
|
||
const { t, i18n } = useTranslation(["analytics", "common"]);
|
||
const locale = i18n.resolvedLanguage ?? i18n.language;
|
||
|
||
const monthLabels = useMemo(
|
||
() =>
|
||
Array.from({ length: 12 }, (_, month) =>
|
||
new Intl.DateTimeFormat(locale, { month: "short" }).format(new Date(2026, month, 1))
|
||
),
|
||
[locale]
|
||
);
|
||
|
||
const dayNames = useMemo(
|
||
() =>
|
||
Array.from({ length: 7 }, (_, day) =>
|
||
new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
|
||
new Date(2026, 0, 4 + day) // Jan 4, 2026 is Sunday
|
||
)
|
||
),
|
||
[locale]
|
||
);
|
||
|
||
// Labels on the left: Sun (0), Tue (2), Thu (4)
|
||
const dayLabels = [dayNames[0], "", dayNames[2], "", dayNames[4], "", ""];
|
||
const maxCount = Math.max(...weeks.flatMap((w) => w.map((c) => c.count)), 1);
|
||
|
||
// Compute month label positions accurately. A label is ~3 columns wide (each
|
||
// column is 16px, a 3-letter month ≈ 22px). The very first month is often a
|
||
// stub - the 52-week window starts mid-month, so e.g. "May" sits at col 0 and
|
||
// "Jun" at col 1 and they overlap. Drop the *stub* (the earlier, partial one)
|
||
// rather than the full month after it, so the next label isn't pushed away.
|
||
const monthPositions = useMemo(() => {
|
||
const MIN_LABEL_GAP = 3; // columns a label needs to itself
|
||
const starts: Array<{ label: string; col: number }> = [];
|
||
let prevMonth = -1;
|
||
|
||
weeks.forEach((week, wi) => {
|
||
const firstCell = week[0];
|
||
if (!firstCell) return;
|
||
const parts = firstCell.date.split("-").map(Number);
|
||
const m = (parts[1] || 1) - 1; // 0-indexed month
|
||
if (m !== prevMonth) {
|
||
starts.push({ label: monthLabels[m] ?? "", col: wi });
|
||
prevMonth = m;
|
||
}
|
||
});
|
||
|
||
// Drop a label when the *next* month starts too soon after it (it would
|
||
// collide). Suppressing the earlier label leaves the later, full month in
|
||
// place - no gap is introduced.
|
||
return starts.filter((s, i) => {
|
||
const next = starts[i + 1];
|
||
return !next || next.col - s.col >= MIN_LABEL_GAP;
|
||
});
|
||
}, [weeks, monthLabels]);
|
||
|
||
return (
|
||
<div className="relative">
|
||
{node}
|
||
{/* Month labels */}
|
||
<div className="flex mb-2 ml-[32px] relative h-4">
|
||
{monthPositions.map((mp, i) => (
|
||
<div
|
||
key={i}
|
||
className="absolute text-[10px] text-fg-muted font-medium whitespace-nowrap"
|
||
style={{ left: mp.col * 16 }}
|
||
>
|
||
{mp.label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="flex" style={{ gap: "3px" }}>
|
||
{/* Day labels */}
|
||
<div className="flex flex-col mr-1 w-7" style={{ gap: "3px" }}>
|
||
{dayLabels.map((d, i) => (
|
||
<div
|
||
key={i}
|
||
className="text-[9px] text-fg-muted flex items-center justify-end pr-1.5"
|
||
style={{ height: 13 }}
|
||
>
|
||
{d}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* Cells */}
|
||
{weeks.map((week, wi) => (
|
||
<div key={wi} className="flex flex-col" style={{ gap: "3px" }}>
|
||
{week.map((cell) => (
|
||
<div
|
||
key={cell.date}
|
||
onMouseEnter={(e) => {
|
||
const parts = cell.date.split("-").map(Number);
|
||
const y = parts[0] || 0;
|
||
const m = (parts[1] || 1) - 1;
|
||
const d = parts[2] || 1;
|
||
const date = new Date(y, m, d, 12);
|
||
const dow = date.getDay();
|
||
show(
|
||
e,
|
||
<>
|
||
<span className="text-fg-secondary">
|
||
{dayNames[dow] ?? ""}, {cell.date}
|
||
</span>
|
||
<span className="ml-2 font-medium">
|
||
{t("eventCountLabel", { count: cell.count })}
|
||
</span>
|
||
</>
|
||
);
|
||
}}
|
||
onMouseMove={move}
|
||
onMouseLeave={hide}
|
||
style={{
|
||
width: 13,
|
||
height: 13,
|
||
borderRadius: 2,
|
||
backgroundColor: cellColor(cell.count, maxCount),
|
||
border: "1px solid rgb(var(--border) / 0.4)",
|
||
flexShrink: 0,
|
||
cursor: "default",
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* Legend */}
|
||
<div className="flex items-center gap-2 mt-3 text-[11px] text-fg-muted">
|
||
<span>{t("less")}</span>
|
||
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
|
||
const v = Math.round(f * maxCount);
|
||
return (
|
||
<div
|
||
key={f}
|
||
style={{
|
||
width: 13,
|
||
height: 13,
|
||
borderRadius: 2,
|
||
backgroundColor: cellColor(v, maxCount),
|
||
border: "1px solid rgb(var(--border) / 0.5)",
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
<span>{t("more")}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AnalyticsSparkline({
|
||
data,
|
||
color = "rgb(var(--accent))",
|
||
}: {
|
||
data: Array<{ date: string; count: number }>;
|
||
color?: string;
|
||
}) {
|
||
const { t } = useTranslation("analytics");
|
||
const { show, move, hide, node } = useTooltip();
|
||
const max = Math.max(...data.map((d) => d.count), 1);
|
||
return (
|
||
<div className="relative flex items-end gap-px h-16">
|
||
{node}
|
||
{data.map(({ date, count }) => (
|
||
<div
|
||
key={date}
|
||
className="flex-1 rounded-sm transition-all cursor-default"
|
||
style={{
|
||
height: `${Math.max(4, Math.round((count / max) * 100))}%`,
|
||
backgroundColor: color,
|
||
opacity: count === 0 ? 0.15 : 0.85,
|
||
}}
|
||
onMouseEnter={(e) =>
|
||
show(
|
||
e,
|
||
<>
|
||
<span className="text-fg-secondary">{date}</span>
|
||
<span className="ml-2 font-medium">{t("eventCountLabel", { count })}</span>
|
||
</>
|
||
)
|
||
}
|
||
onMouseMove={move}
|
||
onMouseLeave={hide}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CostTrendLine({
|
||
data,
|
||
color = "rgb(var(--status-success))",
|
||
}: {
|
||
data: Array<{ date: string; cost: number }>;
|
||
color?: string;
|
||
}) {
|
||
const { show, move, hide, node } = useTooltip();
|
||
if (data.length === 0) return null;
|
||
|
||
const width = 320;
|
||
const height = 88;
|
||
const padX = 8;
|
||
const padY = 8;
|
||
const min = Math.min(...data.map((d) => d.cost), 0);
|
||
const max = Math.max(...data.map((d) => d.cost), 0);
|
||
const span = Math.max(max - min, 0.0001);
|
||
const step = data.length > 1 ? (width - padX * 2) / (data.length - 1) : 0;
|
||
|
||
const points = data.map(({ date, cost }, i) => {
|
||
const x = padX + i * step;
|
||
const y = height - padY - ((cost - min) / span) * (height - padY * 2);
|
||
return { date, cost, x, y };
|
||
});
|
||
|
||
const linePoints = points.map((p) => `${p.x},${p.y}`).join(" ");
|
||
const firstX = points[0]?.x ?? padX;
|
||
const lastX = points[points.length - 1]?.x ?? padX;
|
||
const areaPoints = `${firstX},${height - padY} ${linePoints} ${lastX},${height - padY}`;
|
||
|
||
return (
|
||
<div className="relative">
|
||
{node}
|
||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[88px] overflow-visible">
|
||
<defs>
|
||
<linearGradient id="daily-cost-fill" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor={color} stopOpacity={0.35} />
|
||
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
|
||
</linearGradient>
|
||
</defs>
|
||
<polyline points={areaPoints} fill="url(#daily-cost-fill)" stroke="none" />
|
||
<polyline
|
||
points={linePoints}
|
||
fill="none"
|
||
stroke={color}
|
||
strokeWidth={2}
|
||
strokeLinejoin="round"
|
||
strokeLinecap="round"
|
||
/>
|
||
{points.map((point) => (
|
||
<g key={point.date}>
|
||
<circle
|
||
cx={point.x}
|
||
cy={point.y}
|
||
r={2.5}
|
||
fill={color}
|
||
style={{ pointerEvents: "none" }}
|
||
/>
|
||
<circle
|
||
cx={point.x}
|
||
cy={point.y}
|
||
r={8}
|
||
fill="transparent"
|
||
onMouseEnter={(e) =>
|
||
show(
|
||
e,
|
||
<>
|
||
<span className="text-fg-secondary">{point.date}</span>
|
||
<span className="ml-2 font-medium">{fmtCostFull(point.cost)}</span>
|
||
</>
|
||
)
|
||
}
|
||
onMouseMove={move}
|
||
onMouseLeave={hide}
|
||
/>
|
||
</g>
|
||
))}
|
||
</svg>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BarRow({
|
||
label,
|
||
count,
|
||
max,
|
||
color = "bg-accent",
|
||
pct,
|
||
}: {
|
||
label: string;
|
||
count: number;
|
||
max: number;
|
||
color?: string;
|
||
pct?: number;
|
||
}) {
|
||
const width = pct !== undefined ? pct : max > 0 ? Math.round((count / max) * 100) : 0;
|
||
return (
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs text-fg-secondary w-28 truncate flex-shrink-0" title={label}>
|
||
{label}
|
||
</span>
|
||
<div className="flex-1 bg-surface-3 rounded-full h-2">
|
||
<div
|
||
className={`${color} h-2 rounded-full transition-all`}
|
||
style={{ width: `${width}%` }}
|
||
/>
|
||
</div>
|
||
<Tip raw={count.toLocaleString()}>
|
||
<span className="text-xs text-fg-muted w-10 text-right flex-shrink-0">{fmt(count)}</span>
|
||
</Tip>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CostBarRow({
|
||
label,
|
||
cost,
|
||
max,
|
||
color = "bg-status-success",
|
||
}: {
|
||
label: string;
|
||
cost: number;
|
||
max: number;
|
||
color?: string;
|
||
}) {
|
||
const width = max > 0 ? Math.max(2, Math.round((cost / max) * 100)) : 0;
|
||
return (
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs text-fg-secondary w-24 truncate flex-shrink-0" title={label}>
|
||
{label}
|
||
</span>
|
||
<div className="flex-1 bg-surface-3 rounded-full h-2">
|
||
<div
|
||
className={`${color} h-2 rounded-full transition-all`}
|
||
style={{ width: `${width}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-status-success font-mono w-16 text-right flex-shrink-0">
|
||
<Tip raw={fmtCostFull(cost)}>{fmtCost(cost)}</Tip>
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DonutChart({
|
||
segments,
|
||
formatTotal,
|
||
}: {
|
||
segments: Array<{ label: string; value: number; color: string }>;
|
||
formatTotal?: (total: number) => string;
|
||
}) {
|
||
const { t } = useTranslation(["analytics", "common"]);
|
||
const { show, move, hide, node } = useTooltip();
|
||
const total = segments.reduce((s, g) => s + g.value, 0);
|
||
if (total === 0) return <div className="text-xs text-fg-muted">{t("common:noData")}</div>;
|
||
|
||
const r = 52;
|
||
const cx = 64;
|
||
const cy = 64;
|
||
const stroke = 18;
|
||
const circumference = 2 * Math.PI * r;
|
||
|
||
// offset starts at circumference/4 (top of circle) and decrements by each segment's arc.
|
||
// strokeDashoffset = offset (not negated) is the correct formula for starting at 12 o'clock.
|
||
let offset = circumference / 4;
|
||
return (
|
||
<div className="flex items-center justify-center gap-6 w-full">
|
||
{node}
|
||
<svg width={128} height={128} viewBox="0 0 128 128" className="flex-shrink-0">
|
||
<circle
|
||
cx={cx}
|
||
cy={cy}
|
||
r={r}
|
||
fill="none"
|
||
stroke="rgb(var(--border))"
|
||
strokeWidth={stroke}
|
||
/>
|
||
{segments.map(({ label, value, color }, i) => {
|
||
const dash = (value / total) * circumference;
|
||
const gap = circumference - dash;
|
||
const pct = Math.round((value / total) * 100);
|
||
const currentOffset = offset;
|
||
offset -= dash;
|
||
return (
|
||
<circle
|
||
key={i}
|
||
cx={cx}
|
||
cy={cy}
|
||
r={r}
|
||
fill="none"
|
||
stroke={color}
|
||
strokeWidth={stroke}
|
||
strokeDasharray={`${dash} ${gap}`}
|
||
strokeDashoffset={currentOffset}
|
||
style={{ cursor: "default" }}
|
||
onMouseEnter={(e) =>
|
||
show(
|
||
e,
|
||
<>
|
||
<span style={{ color }}>{label}</span>
|
||
<span className="ml-2 font-medium">{pct}%</span>
|
||
</>
|
||
)
|
||
}
|
||
onMouseMove={move}
|
||
onMouseLeave={hide}
|
||
/>
|
||
);
|
||
})}
|
||
<text x={cx} y={cy - 6} textAnchor="middle" className="fill-fg-secondary" fontSize={11}>
|
||
{(formatTotal ?? fmt)(total)}
|
||
</text>
|
||
<text x={cx} y={cy + 10} textAnchor="middle" className="fill-fg-muted" fontSize={9}>
|
||
{t("common:total_lower")}
|
||
</text>
|
||
</svg>
|
||
<div className="space-y-2">
|
||
{segments.map(({ label, value, color }) => (
|
||
<div key={label} className="flex items-center gap-2 text-xs">
|
||
<span
|
||
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||
style={{ backgroundColor: color }}
|
||
/>
|
||
<span className="text-fg-secondary">{label}</span>
|
||
<span className="text-fg-muted ml-auto pl-4">{Math.round((value / total) * 100)}%</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** A single chart-card placeholder: title bar, chart body, two footer lines.
|
||
* Uses the shared pulsing <Skeleton> so the whole grid shimmers while data
|
||
* loads (mirrors the chart-card layout so nothing jumps when data arrives). */
|
||
function ChartCardSkeleton({
|
||
className = "",
|
||
bodyH = "h-32",
|
||
}: {
|
||
className?: string;
|
||
bodyH?: string;
|
||
}) {
|
||
return (
|
||
<div className={`card p-5 space-y-4 ${className}`} aria-busy="true">
|
||
<Skeleton className="h-4 w-1/3" />
|
||
<Skeleton className={`block w-full ${bodyH}`} rounded="lg" />
|
||
<div className="space-y-2 pt-1">
|
||
<Skeleton className="block h-3 w-2/3" />
|
||
<Skeleton className="block h-3 w-1/2" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Pulsing placeholder for the Analytics chart region (heatmap + sparkline row,
|
||
* tab bar, and the three-up chart grid) shown while the analytics payload is
|
||
* still being fetched. */
|
||
function AnalyticsChartsSkeleton() {
|
||
return (
|
||
<div className="space-y-6" aria-busy="true">
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<ChartCardSkeleton className="lg:col-span-2" bodyH="h-32" />
|
||
<ChartCardSkeleton bodyH="h-32" />
|
||
</div>
|
||
<Skeleton className="block h-9 w-80" rounded="lg" />
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
<ChartCardSkeleton bodyH="h-44" />
|
||
<ChartCardSkeleton bodyH="h-44" />
|
||
<ChartCardSkeleton bodyH="h-44" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AnalyticsTab() {
|
||
const { t, i18n } = useTranslation("analytics");
|
||
const [data, setData] = useState<AnalyticsData | null>(null);
|
||
const [costData, setCostData] = useState<CostResult | null>(null);
|
||
const [budgets, setBudgets] = useState<BudgetStatus[]>([]);
|
||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||
const [loading, setLoading] = useState(true);
|
||
// `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`).
|
||
// The URL is the state: `?sub=` is read on every render and written back.
|
||
const [params, setParams] = useSearchParams();
|
||
const subParam = params.get("sub");
|
||
const analyticsSubTab: "tokens" | "cost" | "workflow" | "productivity" =
|
||
subParam === "tokens" || subParam === "workflow" || subParam === "productivity"
|
||
? subParam
|
||
: "cost";
|
||
const setAnalyticsSubTab = (sub: typeof analyticsSubTab) =>
|
||
setParams(
|
||
(prev) => {
|
||
const next = new URLSearchParams(prev);
|
||
next.set("tab", "analytics");
|
||
if (sub === "cost") next.delete("sub");
|
||
else next.set("sub", sub);
|
||
return next;
|
||
},
|
||
{ replace: true }
|
||
);
|
||
// Global data scope; a change re-runs `load` (api injects the `sources` param).
|
||
const [scope] = useDataScope();
|
||
// One AI tool's sessions only ("" = every tool); a change re-runs `load`.
|
||
const [tool, setTool] = useState("");
|
||
const aiTools = useAiTools();
|
||
// The date range, kept in the URL (`?range=`, plus `from`/`to` when custom).
|
||
// Headline numbers, cost/token cards and by-model/by-tool follow it; the
|
||
// 52-week and 30-day charts keep their own windows.
|
||
const preset = parsePreset(params.get("range"));
|
||
const customFrom = params.get("from") ?? "";
|
||
const customTo = params.get("to") ?? "";
|
||
const { from: rangeFrom, to: rangeTo } = rangeDates(preset, { from: customFrom, to: customTo });
|
||
const range = useMemo(() => ({ from: rangeFrom, to: rangeTo }), [rangeFrom, rangeTo]);
|
||
const setRangeParam = (patch: Record<string, string>) =>
|
||
setParams(
|
||
(p) => {
|
||
for (const [k, v] of Object.entries(patch)) {
|
||
if (v) p.set(k, v);
|
||
else p.delete(k);
|
||
}
|
||
return p;
|
||
},
|
||
{ replace: true }
|
||
);
|
||
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
// Responses can land out of order (a slow poll after a fast refresh); only
|
||
// the newest request may write state.
|
||
const loadSeq = useRef(0);
|
||
const load = useCallback(async () => {
|
||
const seq = ++loadSeq.current;
|
||
try {
|
||
const [result, cost, budgetRes] = await Promise.all([
|
||
api.analytics.get({ tool, range }),
|
||
api.pricing.totalCost({ tool, range }).catch(() => null),
|
||
api.budgets.status().catch(() => null),
|
||
]);
|
||
if (seq !== loadSeq.current) return;
|
||
setData(result);
|
||
setCostData(cost);
|
||
setLoadError(null);
|
||
if (budgetRes) setBudgets(budgetRes.budgets);
|
||
setLastUpdate(new Date());
|
||
} catch (err) {
|
||
if (seq === loadSeq.current)
|
||
setLoadError(err instanceof Error ? err.message : t("dashboard:failedLoad"));
|
||
} finally {
|
||
if (seq === loadSeq.current) setLoading(false);
|
||
}
|
||
}, [scope, tool, range]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, [load]);
|
||
|
||
useLiveRefresh(load, {
|
||
intervalMs: 15000,
|
||
match: (msg) =>
|
||
msg.type === "session_created" ||
|
||
msg.type === "session_updated" ||
|
||
msg.type === "new_event" ||
|
||
msg.type === "agent_created" ||
|
||
isRemoteDataRefreshMessage(msg),
|
||
});
|
||
|
||
function handleExport() {
|
||
if (!data) return;
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `analytics-${new Date().toISOString().slice(0, 10)}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// Format a local Date object as a YYYY-MM-DD string for heatmap lookups
|
||
function localDateStr(d: Date): string {
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
// Server now returns dates grouped by the user's local timezone (via tz_offset),
|
||
// so we can use them directly without conversion.
|
||
const dailyMap: Record<string, number> = {};
|
||
for (const d of data?.daily_events ?? []) {
|
||
dailyMap[d.date] = (dailyMap[d.date] ?? 0) + d.count;
|
||
}
|
||
|
||
// Build heatmap: 52 weeks × 7 days
|
||
// Align start to Sunday (day 0) so row indices match day-of-week labels correctly.
|
||
const today = new Date();
|
||
today.setHours(12, 0, 0, 0); // Normalize to noon
|
||
|
||
const startDate = new Date(today);
|
||
startDate.setDate(today.getDate() - 364); // Exactly 52 weeks ago
|
||
// Roll back to the previous Sunday
|
||
const startDow = startDate.getDay();
|
||
startDate.setDate(startDate.getDate() - startDow);
|
||
|
||
const weeks: Array<Array<{ date: string; count: number }>> = [];
|
||
for (let w = 0; w < 53; w++) {
|
||
const week: Array<{ date: string; count: number }> = [];
|
||
for (let d = 0; d < 7; d++) {
|
||
const cell = new Date(startDate);
|
||
cell.setDate(startDate.getDate() + w * 7 + d);
|
||
if (cell > today) break;
|
||
const dateStr = localDateStr(cell);
|
||
week.push({ date: dateStr, count: dailyMap[dateStr] ?? 0 });
|
||
}
|
||
if (week.length > 0) weeks.push(week);
|
||
}
|
||
|
||
// Last 30 days for sparkline
|
||
const last30 = Array.from({ length: 30 }, (_, i) => {
|
||
const d = new Date(today);
|
||
d.setDate(today.getDate() - (29 - i));
|
||
const dateStr = localDateStr(d);
|
||
return { date: dateStr, count: dailyMap[dateStr] ?? 0 };
|
||
});
|
||
|
||
// Convert daily_sessions - server already returns local dates
|
||
const dailySessionsLocal: Array<{ date: string; count: number }> = [];
|
||
const sessMap: Record<string, number> = {};
|
||
for (const d of data?.daily_sessions ?? []) {
|
||
sessMap[d.date] = (sessMap[d.date] ?? 0) + d.count;
|
||
}
|
||
for (const [date, count] of Object.entries(sessMap)) {
|
||
dailySessionsLocal.push({ date, count });
|
||
}
|
||
dailySessionsLocal.sort((a, b) => a.date.localeCompare(b.date));
|
||
// Calendar days, zero-filled: "last 30/7 days" means days, not active days.
|
||
const sessionsLast30 = Array.from({ length: 30 }, (_, i) => {
|
||
const d = new Date(today);
|
||
d.setDate(today.getDate() - (29 - i));
|
||
const dateStr = localDateStr(d);
|
||
return { date: dateStr, count: sessMap[dateStr] ?? 0 };
|
||
});
|
||
const maxSessionsDay = Math.max(1, ...sessionsLast30.map((d) => d.count));
|
||
|
||
// Convert daily_costs - server already returns local dates
|
||
const dailyCostsLocal: Array<{ date: string; cost: number }> = [];
|
||
const costMap: Record<string, number> = {};
|
||
for (const d of costData?.daily_costs ?? []) {
|
||
costMap[d.date] = (costMap[d.date] ?? 0) + d.cost;
|
||
}
|
||
for (const [date, cost] of Object.entries(costMap)) {
|
||
dailyCostsLocal.push({ date, cost });
|
||
}
|
||
dailyCostsLocal.sort((a, b) => a.date.localeCompare(b.date));
|
||
|
||
const dailyCostLast30 = Array.from({ length: 30 }, (_, i) => {
|
||
const d = new Date(today);
|
||
d.setDate(today.getDate() - (29 - i));
|
||
const dateStr = localDateStr(d);
|
||
return { date: dateStr, cost: costMap[dateStr] ?? 0 };
|
||
});
|
||
const peakCostDay = dailyCostLast30.reduce(
|
||
(max, curr) => (curr.cost > max.cost ? curr : max),
|
||
dailyCostLast30[0] ?? { date: "", cost: 0 }
|
||
);
|
||
const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0);
|
||
const pace = spendPace(dailyCostsLocal, localDateStr(today));
|
||
const costBreakdown = [...(costData?.breakdown ?? [])]
|
||
.filter((b) => b.cost > 0)
|
||
.sort((a, b) => b.cost - a.cost);
|
||
const locale = i18n.resolvedLanguage ?? i18n.language;
|
||
const weekdayOrder = [1, 2, 3, 4, 5, 6, 0];
|
||
// 2026-01-04 is a Sunday; a LOCAL date, so the label never shifts a day
|
||
// west of UTC. 30 days hold some weekdays 5 times and others 4, so the bar
|
||
// is the average per occurrence, not the sum.
|
||
const weekdayCosts = weekdayOrder.map((dow) => {
|
||
const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
|
||
new Date(2026, 0, 4 + dow, 12)
|
||
);
|
||
const days = dailyCostLast30.filter((day) => new Date(day.date + "T12:00:00").getDay() === dow);
|
||
const cost = days.length ? days.reduce((sum, day) => sum + day.cost, 0) / days.length : 0;
|
||
return { label, cost };
|
||
});
|
||
const maxWeekdayCost = Math.max(...weekdayCosts.map((d) => d.cost), 1);
|
||
|
||
const totalTokens =
|
||
(data?.tokens.total_input ?? 0) +
|
||
(data?.tokens.total_output ?? 0) +
|
||
(data?.tokens.total_cache_read ?? 0) +
|
||
(data?.tokens.total_cache_write ?? 0);
|
||
const tokenMixSegments = [
|
||
{
|
||
label: t("common:token.input"),
|
||
value: data?.tokens.total_input ?? 0,
|
||
color: "rgb(var(--syn-builtin))",
|
||
},
|
||
{
|
||
label: t("common:token.output"),
|
||
value: data?.tokens.total_output ?? 0,
|
||
color: "rgb(var(--status-success))",
|
||
},
|
||
{
|
||
label: t("common:token.cacheRead"),
|
||
value: data?.tokens.total_cache_read ?? 0,
|
||
color: "rgb(var(--syn-keyword))",
|
||
},
|
||
{
|
||
label: t("common:token.cacheWrite"),
|
||
value: data?.tokens.total_cache_write ?? 0,
|
||
color: "rgb(var(--status-warning))",
|
||
},
|
||
].filter((s) => s.value > 0);
|
||
|
||
const maxToolCount = data?.tool_usage[0]?.count ?? 1;
|
||
const maxAgentTypeCount = data?.agent_types[0]?.count ?? 1;
|
||
const maxEventTypeCount = data?.event_types[0]?.count ?? 1;
|
||
|
||
// Hit rate = cache reads over every prompt token (fresh input + cache read +
|
||
// cache write); output tokens are never cacheable.
|
||
const promptTokens =
|
||
(data?.tokens.total_input ?? 0) +
|
||
(data?.tokens.total_cache_read ?? 0) +
|
||
(data?.tokens.total_cache_write ?? 0);
|
||
const cacheHitPct =
|
||
promptTokens > 0 ? Math.round(((data?.tokens.total_cache_read ?? 0) / promptTokens) * 100) : 0;
|
||
// The headline excludes cache reads, which dwarf everything else and cost a tenth.
|
||
const nonCacheReadTokens = totalTokens - (data?.tokens.total_cache_read ?? 0);
|
||
|
||
const sessionOutcomeSegments = [
|
||
{
|
||
label: t("common:status.completed"),
|
||
value: data?.sessions_by_status?.completed ?? 0,
|
||
color: "rgb(var(--syn-keyword))",
|
||
},
|
||
{
|
||
label: t("common:status.active"),
|
||
value: data?.sessions_by_status?.active ?? 0,
|
||
color: "rgb(var(--status-success))",
|
||
},
|
||
{
|
||
label: t("common:status.error"),
|
||
value: data?.sessions_by_status?.error ?? 0,
|
||
color: "rgb(var(--status-danger))",
|
||
},
|
||
{
|
||
label: t("common:status.abandoned"),
|
||
value: data?.sessions_by_status?.abandoned ?? 0,
|
||
color: "rgb(var(--status-warning))",
|
||
},
|
||
].filter((s) => s.value > 0);
|
||
|
||
const agentStatusSegments = [
|
||
{
|
||
label: t("common:status.completed"),
|
||
value: data?.agents_by_status?.completed ?? 0,
|
||
color: "rgb(var(--syn-keyword))",
|
||
},
|
||
{
|
||
label: t("common:status.working"),
|
||
value: data?.agents_by_status?.working ?? 0,
|
||
color: "rgb(var(--status-success))",
|
||
},
|
||
{
|
||
label: t("common:status.waiting"),
|
||
value: data?.agents_by_status?.waiting ?? 0,
|
||
color: "rgb(var(--status-warning))",
|
||
},
|
||
{
|
||
label: t("common:status.error"),
|
||
value: data?.agents_by_status?.error ?? 0,
|
||
color: "rgb(var(--status-danger))",
|
||
},
|
||
].filter((s) => s.value > 0);
|
||
|
||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||
PreToolUse: "bg-status-success",
|
||
PostToolUse: "bg-syntax-builtin",
|
||
Stop: "bg-syntax-keyword",
|
||
SubagentStop: "bg-status-warning",
|
||
Notification: "bg-syntax-number",
|
||
};
|
||
|
||
return (
|
||
<div className="animate-fade-in space-y-8">
|
||
{/* Toolbar */}
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<span className="inline-flex items-center gap-1.5 text-[11px] text-fg-muted bg-surface-2 border border-border px-2 py-0.5 rounded-md font-mono">
|
||
<Clock className="w-3 h-3" />
|
||
{lastUpdate.toLocaleTimeString()}
|
||
</span>
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
<div className="w-40">
|
||
<Select
|
||
value={preset}
|
||
onChange={(v) =>
|
||
setRangeParam(
|
||
v === "custom"
|
||
? { range: v, from: rangeFrom ?? "", to: rangeTo ?? "" }
|
||
: { range: v === "all" ? "" : v, from: "", to: "" }
|
||
)
|
||
}
|
||
label={t("range")}
|
||
options={RANGE_PRESETS.map((v) => ({ value: v, label: t(`rangePreset.${v}`) }))}
|
||
/>
|
||
</div>
|
||
{preset === "custom" && (
|
||
<>
|
||
<input
|
||
type="date"
|
||
className="input text-sm"
|
||
value={customFrom}
|
||
max={customTo || undefined}
|
||
onChange={(e) => setRangeParam({ from: e.target.value })}
|
||
aria-label={t("rangeFrom")}
|
||
title={t("rangeFrom")}
|
||
/>
|
||
<input
|
||
type="date"
|
||
className="input text-sm"
|
||
value={customTo}
|
||
min={customFrom || undefined}
|
||
onChange={(e) => setRangeParam({ to: e.target.value })}
|
||
aria-label={t("rangeTo")}
|
||
title={t("rangeTo")}
|
||
/>
|
||
</>
|
||
)}
|
||
<div className="w-44">
|
||
<Select
|
||
value={tool}
|
||
onChange={setTool}
|
||
label={t("toolFilter")}
|
||
options={[
|
||
{ value: "", label: t("allTools") },
|
||
...aiTools.tools.map((x) => ({ value: x.id, label: x.label })),
|
||
]}
|
||
/>
|
||
</div>
|
||
<button onClick={load} className="btn-ghost" disabled={loading}>
|
||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||
{t("common:refresh")}
|
||
</button>
|
||
<button onClick={handleExport} className="btn-ghost" disabled={!data}>
|
||
<Download className="w-4 h-4" />
|
||
{t("export")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{loadError && (
|
||
<div
|
||
role="alert"
|
||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||
>
|
||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||
<span className="flex-1 min-w-0">{loadError}</span>
|
||
<button onClick={load} className="btn-ghost text-xs">
|
||
{t("common:retry")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Key stats */}
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||
<StatCard
|
||
label={t("totalSessions")}
|
||
to="/sessions"
|
||
value={data ? fmt(data.overview.total_sessions) : ""}
|
||
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
|
||
trend={data ? `${data.overview.active_sessions} ${t("common:active")}` : undefined}
|
||
icon={FolderOpen}
|
||
accentColor="text-syntax-builtin"
|
||
loading={!data}
|
||
/>
|
||
<StatCard
|
||
label={t("totalAgents")}
|
||
value={data ? fmt(data.overview.total_agents) : ""}
|
||
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
|
||
trend={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
|
||
icon={Bot}
|
||
accentColor="text-status-success"
|
||
loading={!data}
|
||
/>
|
||
<StatCard
|
||
label={t("tokensExCacheReads")}
|
||
value={data ? fmt(nonCacheReadTokens) : ""}
|
||
raw={data ? nonCacheReadTokens.toLocaleString() : undefined}
|
||
trend={
|
||
data
|
||
? t("cacheReadsSub", {
|
||
reads: fmt(data.tokens.total_cache_read ?? 0),
|
||
pct: cacheHitPct,
|
||
})
|
||
: undefined
|
||
}
|
||
icon={Cpu}
|
||
accentColor="text-syntax-keyword"
|
||
loading={!data}
|
||
/>
|
||
<StatCard
|
||
label={t("totalCost")}
|
||
value={costData ? fmtCost(costData.total_cost) : ""}
|
||
raw={costData ? fmtCostFull(costData.total_cost) : undefined}
|
||
trend={
|
||
costData
|
||
? `${costData.breakdown.length} ${t("common:cost.model", { count: costData.breakdown.length })}`
|
||
: undefined
|
||
}
|
||
icon={DollarSign}
|
||
accentColor="text-status-success"
|
||
loading={!costData}
|
||
/>
|
||
<StatCard
|
||
label={t("totalEvents")}
|
||
to="/sessions?view=events"
|
||
value={data ? fmt(data.overview.total_events) : ""}
|
||
raw={data ? data.overview.total_events.toLocaleString() : undefined}
|
||
trend={data ? `~${data.avg_events_per_session}${t("perSession")}` : undefined}
|
||
icon={Zap}
|
||
accentColor="text-status-warning"
|
||
loading={!data}
|
||
/>
|
||
</div>
|
||
|
||
{!data ? (
|
||
<AnalyticsChartsSkeleton />
|
||
) : (
|
||
<>
|
||
{/* Activity heatmap + 30-day sparkline */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<div className="card p-5 lg:col-span-2">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-4">{t("eventActivity")}</h3>
|
||
<div
|
||
className="overflow-x-auto"
|
||
tabIndex={0}
|
||
role="region"
|
||
aria-label={t("eventActivity")}
|
||
>
|
||
<div className="w-fit min-w-max mx-auto">
|
||
<Heatmap weeks={weeks} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-1">{t("last30Days")}</h3>
|
||
<p className="text-[11px] text-fg-muted mb-4">{t("dailyEventCount")}</p>
|
||
<AnalyticsSparkline data={last30} />
|
||
<div className="flex justify-between text-[11px] text-fg-muted mt-2">
|
||
<span>{last30[0]?.date?.slice(5)}</span>
|
||
<span>{last30[last30.length - 1]?.date?.slice(5)}</span>
|
||
</div>
|
||
<div className="mt-4 pt-4 border-t border-border space-y-1">
|
||
<div className="flex justify-between text-xs">
|
||
<span className="text-fg-muted">{t("peakDay")}</span>
|
||
<span className="text-fg-secondary font-mono">
|
||
<Tip raw={Math.max(...last30.map((d) => d.count)).toLocaleString()}>
|
||
{fmt(Math.max(...last30.map((d) => d.count)))}
|
||
</Tip>{" "}
|
||
{t("common:events")}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between text-xs">
|
||
<span className="text-fg-muted">{t("total30d")}</span>
|
||
<span className="text-fg-secondary font-mono">
|
||
<Tip raw={last30.reduce((s, d) => s + d.count, 0).toLocaleString()}>
|
||
{fmt(last30.reduce((s, d) => s + d.count, 0))}
|
||
</Tip>{" "}
|
||
{t("common:events")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div>
|
||
<div className="flex gap-1 bg-surface-2 rounded-lg p-1 mb-6 w-fit">
|
||
{(
|
||
[
|
||
{ key: "cost" as const, label: t("tabs.costAnalytics") },
|
||
{ key: "tokens" as const, label: t("tabs.tokenAnalytics") },
|
||
{ key: "productivity" as const, label: t("tabs.productivityAnalytics") },
|
||
{ key: "workflow" as const, label: t("tabs.workflowIntelligence") },
|
||
] as const
|
||
).map(({ key, label }) => (
|
||
<button
|
||
key={key}
|
||
onClick={() => setAnalyticsSubTab(key)}
|
||
aria-pressed={analyticsSubTab === key}
|
||
className={`px-4 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||
analyticsSubTab === key
|
||
? "bg-surface-4 text-fg-secondary"
|
||
: "text-fg-muted hover:text-fg-secondary"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{analyticsSubTab === "tokens" && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{/* Token bars */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">
|
||
{t("tokenDistribution")}
|
||
</h3>
|
||
<div className="space-y-4">
|
||
{[
|
||
{
|
||
label: t("common:token.input"),
|
||
value: data?.tokens.total_input ?? 0,
|
||
color: "bg-syntax-builtin",
|
||
},
|
||
{
|
||
label: t("common:token.output"),
|
||
value: data?.tokens.total_output ?? 0,
|
||
color: "bg-status-success",
|
||
},
|
||
{
|
||
label: t("common:token.cacheRead"),
|
||
value: data?.tokens.total_cache_read ?? 0,
|
||
color: "bg-syntax-keyword",
|
||
},
|
||
{
|
||
label: t("common:token.cacheWrite"),
|
||
value: data?.tokens.total_cache_write ?? 0,
|
||
color: "bg-status-warning",
|
||
},
|
||
].map(({ label, value, color }) => (
|
||
<BarRow
|
||
key={label}
|
||
label={label}
|
||
count={value}
|
||
max={Math.max(totalTokens, 1)}
|
||
color={color}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="mt-6 pt-4 border-t border-border space-y-1.5">
|
||
<div className="flex justify-between text-xs text-fg-muted">
|
||
<span>{t("common:token.totalTokens")}</span>
|
||
<Tip raw={totalTokens.toLocaleString()}>
|
||
<span className="text-fg-secondary font-mono">{fmt(totalTokens)}</span>
|
||
</Tip>
|
||
</div>
|
||
<div className="flex justify-between text-xs text-fg-muted">
|
||
<span>{t("cacheEfficiency")}</span>
|
||
<span className="text-syntax-keyword font-mono">{cacheHitPct}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Token summary */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">
|
||
{t("tokenBreakdown")}
|
||
</h3>
|
||
<div className="space-y-3">
|
||
{[
|
||
{
|
||
label: t("common:token.input"),
|
||
value: data?.tokens.total_input ?? 0,
|
||
color: "text-syntax-builtin",
|
||
},
|
||
{
|
||
label: t("common:token.output"),
|
||
value: data?.tokens.total_output ?? 0,
|
||
color: "text-status-success",
|
||
},
|
||
{
|
||
label: t("common:token.cacheRead"),
|
||
value: data?.tokens.total_cache_read ?? 0,
|
||
color: "text-syntax-keyword",
|
||
},
|
||
{
|
||
label: t("common:token.cacheWrite"),
|
||
value: data?.tokens.total_cache_write ?? 0,
|
||
color: "text-status-warning",
|
||
},
|
||
{ label: t("common:total"), value: totalTokens, color: "text-fg-primary" },
|
||
].map(({ label, value, color }) => (
|
||
<div
|
||
key={label}
|
||
className="flex justify-between items-center py-2 border-b border-border last:border-0"
|
||
>
|
||
<span className="text-xs text-fg-secondary">{label}</span>
|
||
<span className={`text-sm font-mono font-medium ${color}`}>
|
||
{value.toLocaleString()}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{totalTokens === 0 && (
|
||
<p className="text-[11px] text-fg-muted mt-4">{t("tokenInfo")}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Token mix donut */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("tokenMix")}</h3>
|
||
{tokenMixSegments.length === 0 ? (
|
||
<p className="text-sm text-fg-muted">{t("common:noData")}</p>
|
||
) : (
|
||
<>
|
||
<DonutChart segments={tokenMixSegments} formatTotal={(total) => fmt(total)} />
|
||
<div className="mt-4 pt-4 border-t border-border space-y-2">
|
||
{tokenMixSegments.map((segment) => (
|
||
<div key={segment.label} className="flex justify-between text-xs">
|
||
<span className="text-fg-secondary">{segment.label}</span>
|
||
<span className="text-fg-secondary font-mono">
|
||
<Tip raw={segment.value.toLocaleString()}>{fmt(segment.value)}</Tip>
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{analyticsSubTab === "cost" && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{/* Daily cost trends */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-1">
|
||
{t("dailyCostTrends")}
|
||
</h3>
|
||
{dailyCostsLocal.length === 0 ? (
|
||
<p className="text-sm text-fg-muted">{t("noDailyCostData")}</p>
|
||
) : (
|
||
<>
|
||
<p className="text-[11px] text-fg-muted mb-4">{t("costPerDay")}</p>
|
||
<CostTrendLine data={dailyCostLast30} />
|
||
<div className="flex justify-between text-[11px] text-fg-muted mt-2">
|
||
<span>{dailyCostLast30[0]?.date?.slice(5)}</span>
|
||
<span>{dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)}</span>
|
||
</div>
|
||
<div className="mt-4 pt-4 border-t border-border space-y-1">
|
||
<div className="flex justify-between text-xs">
|
||
<span className="text-fg-muted">{t("peakCostDay")}</span>
|
||
<span className="text-status-success font-mono">
|
||
<Tip raw={`${peakCostDay.date} • ${fmtCostFull(peakCostDay.cost)}`}>
|
||
{fmtCost(peakCostDay.cost)}
|
||
</Tip>
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between text-xs">
|
||
<span className="text-fg-muted">{t("totalCost30d")}</span>
|
||
<span className="text-status-success font-mono">
|
||
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Cost by model */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("costByModel")}</h3>
|
||
{costBreakdown.length > 0 ? (
|
||
<>
|
||
<DonutChart
|
||
segments={costBreakdown.map((b, i) => ({
|
||
label: formatModelName(b.model) ?? b.model,
|
||
value: Math.round(b.cost * 100),
|
||
color:
|
||
[
|
||
"rgb(var(--syn-keyword))",
|
||
"rgb(var(--syn-builtin))",
|
||
"rgb(var(--status-success))",
|
||
"rgb(var(--status-warning))",
|
||
"rgb(var(--status-danger))",
|
||
"rgb(var(--syn-operator))",
|
||
][i % 6] ?? "rgb(var(--fg-muted))",
|
||
}))}
|
||
formatTotal={(cents) => fmtCost(cents / 100)}
|
||
/>
|
||
<div className="mt-4 pt-4 border-t border-border space-y-2">
|
||
{costBreakdown.map((b) => (
|
||
<div key={b.model} className="flex justify-between text-xs">
|
||
<span className="text-fg-secondary font-mono truncate">
|
||
{formatModelName(b.model)}
|
||
</span>
|
||
<span className="text-status-success font-mono font-medium ml-2">
|
||
<Tip raw={fmtCostFull(b.cost)}>{fmtCost(b.cost)}</Tip>
|
||
</span>
|
||
</div>
|
||
))}
|
||
<div className="flex justify-between text-xs pt-2 border-t border-border">
|
||
<span className="text-fg-secondary font-medium">{t("common:total")}</span>
|
||
<span className="text-status-success font-mono font-semibold">
|
||
<Tip raw={fmtCostFull(costData?.total_cost ?? 0)}>
|
||
{fmtCost(costData?.total_cost ?? 0)}
|
||
</Tip>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="text-sm text-fg-muted">{t("noCostData")}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Spend pace: burn rate, month to date, month-end forecast */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("spendPace")}</h3>
|
||
<div className="space-y-3">
|
||
{[
|
||
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
|
||
{ label: t("monthToDate"), value: pace.monthToDate },
|
||
{
|
||
label: t("forecastMonth"),
|
||
value: pace.forecastMonth,
|
||
hint: t("forecastHint"),
|
||
},
|
||
].map(({ label, value, hint }) => (
|
||
<div
|
||
key={label}
|
||
className="flex justify-between items-center py-2 border-b border-border last:border-0"
|
||
>
|
||
<span className="text-xs text-fg-secondary" title={hint}>
|
||
{label}
|
||
</span>
|
||
<span className="text-sm font-mono font-medium text-status-success">
|
||
<Tip raw={fmtCostFull(value)}>{fmtCost(value)}</Tip>
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Budgets (budget alert rules) */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("budgets")}</h3>
|
||
{budgets.length === 0 ? (
|
||
<p className="text-sm text-fg-muted">
|
||
{t("noBudgets")}{" "}
|
||
<Link to="/settings/notifications" className="text-accent hover:underline">
|
||
{t("addBudget")}
|
||
</Link>
|
||
</p>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{budgets.map((b) => {
|
||
const over = b.pct >= 100;
|
||
const warn = b.crossed.length > 0;
|
||
return (
|
||
<div key={b.id} className="space-y-1.5">
|
||
<div className="flex justify-between text-xs gap-2">
|
||
<span className="text-fg-secondary truncate" title={b.name}>
|
||
{b.name}
|
||
</span>
|
||
<span className="font-mono text-fg-secondary flex-shrink-0">
|
||
{fmtCost(b.spend)} / {fmtCost(b.config.limit_usd ?? 0)}
|
||
</span>
|
||
</div>
|
||
<div className="bg-surface-3 rounded-full h-2">
|
||
<div
|
||
className={`h-2 rounded-full ${over ? "bg-status-danger" : warn ? "bg-status-warning" : "bg-status-success"}`}
|
||
style={{ width: `${Math.min(100, Math.max(2, b.pct))}%` }}
|
||
/>
|
||
</div>
|
||
<div className="flex justify-between text-[11px] text-fg-muted">
|
||
<span>
|
||
{b.period_start} → {b.period_end}
|
||
</span>
|
||
<span title={t("projectedHint")}>
|
||
{t("projected")} {fmtCost(b.projected)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Cost by AI tool */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("costByTool")}</h3>
|
||
{(costData?.by_tool?.length ?? 0) > 0 ? (
|
||
<div className="space-y-3">
|
||
{(costData?.by_tool ?? []).map((b) => (
|
||
<div key={b.tool} className="flex items-center gap-2">
|
||
<AiToolIcon
|
||
tool={{ id: b.tool, label: aiTools.label(b.tool) }}
|
||
className="h-5 w-5"
|
||
/>
|
||
<div className="flex-1 min-w-0">
|
||
<CostBarRow
|
||
label={aiTools.label(b.tool)}
|
||
cost={b.cost}
|
||
max={Math.max(costData?.by_tool?.[0]?.cost ?? 0, 0.0001)}
|
||
color="bg-accent"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-fg-muted">{t("noCostData")}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Cost by weekday */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-1">
|
||
{t("costByWeekday")}
|
||
</h3>
|
||
{dailyCostsLocal.length === 0 ? (
|
||
<p className="text-sm text-fg-muted">{t("noDailyCostData")}</p>
|
||
) : (
|
||
<>
|
||
<p className="text-[11px] text-fg-muted mb-4">{t("avgPerWeekday30d")}</p>
|
||
<div className="space-y-3">
|
||
{weekdayCosts.map(({ label, cost }) => (
|
||
<CostBarRow
|
||
key={label}
|
||
label={label}
|
||
cost={cost}
|
||
max={maxWeekdayCost}
|
||
color="bg-syntax-property"
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="mt-4 pt-4 border-t border-border text-xs flex justify-between">
|
||
<span className="text-fg-muted">{t("common:total")}</span>
|
||
<span className="text-syntax-property font-mono">
|
||
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{analyticsSubTab === "workflow" && (
|
||
<div className="space-y-8">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{/* Agent type distribution */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">
|
||
{t("subagentTypes")}
|
||
</h3>
|
||
{(data?.agent_types ?? []).length === 0 ? (
|
||
<p className="text-sm text-fg-muted">{t("noSubagentData")}</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => (
|
||
<BarRow
|
||
key={subagent_type}
|
||
label={subagent_type}
|
||
count={count}
|
||
max={maxAgentTypeCount}
|
||
color="bg-syntax-keyword"
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Agent status donut */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">
|
||
{t("agentStatus")}
|
||
</h3>
|
||
<DonutChart segments={agentStatusSegments} />
|
||
<div className="mt-4 pt-4 border-t border-border space-y-1.5">
|
||
<div className="flex justify-between text-xs text-fg-muted">
|
||
<span>{t("totalAgentsLabel")}</span>
|
||
<Tip raw={(data?.overview.total_agents ?? 0).toLocaleString()}>
|
||
<span className="text-fg-secondary font-mono">
|
||
{fmt(data?.overview.total_agents ?? 0)}
|
||
</span>
|
||
</Tip>
|
||
</div>
|
||
{agentStatusSegments.map((s) => (
|
||
<div
|
||
key={s.label}
|
||
className="flex items-center justify-between text-xs text-fg-muted"
|
||
>
|
||
<span className="flex items-center gap-1.5">
|
||
<span
|
||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||
style={{ backgroundColor: s.color }}
|
||
/>
|
||
{s.label}
|
||
</span>
|
||
<Tip raw={s.value.toLocaleString()}>
|
||
<span className="text-fg-secondary font-mono">{fmt(s.value)}</span>
|
||
</Tip>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Event type breakdown */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">
|
||
{t("eventTypes")}
|
||
</h3>
|
||
{(data?.event_types ?? []).length === 0 ? (
|
||
<p className="text-sm text-fg-muted">{t("noEventData")}</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{(data?.event_types ?? []).map(({ event_type, count }) => (
|
||
<BarRow
|
||
key={event_type}
|
||
label={event_type}
|
||
count={count}
|
||
max={maxEventTypeCount}
|
||
color={EVENT_TYPE_COLORS[event_type] ?? "bg-surface-4"}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* The former Workflows page, folded into this sub-tab. */}
|
||
<RouteErrorBoundary>
|
||
<Suspense fallback={<AnalyticsChartsSkeleton />}>
|
||
<Workflows embedded tool={tool || undefined} range={range} />
|
||
</Suspense>
|
||
</RouteErrorBoundary>
|
||
</div>
|
||
)}
|
||
|
||
{analyticsSubTab === "productivity" && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{/* Top tools */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("toolUsage")}</h3>
|
||
{(data?.tool_usage ?? []).length === 0 ? (
|
||
<p className="text-sm text-fg-muted">{t("noToolData")}</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => (
|
||
<BarRow
|
||
key={tool_name}
|
||
label={tool_name}
|
||
count={count}
|
||
max={maxToolCount}
|
||
color="bg-status-warning"
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Session outcomes donut */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">
|
||
{t("sessionOutcomes")}
|
||
</h3>
|
||
<DonutChart segments={sessionOutcomeSegments} />
|
||
<div className="mt-4 pt-4 border-t border-border space-y-1.5">
|
||
<div className="flex justify-between text-xs text-fg-muted">
|
||
<span>{t("totalSessionsLabel")}</span>
|
||
<Tip raw={(data?.overview.total_sessions ?? 0).toLocaleString()}>
|
||
<span className="text-fg-secondary font-mono">
|
||
{fmt(data?.overview.total_sessions ?? 0)}
|
||
</span>
|
||
</Tip>
|
||
</div>
|
||
{sessionOutcomeSegments.map((s) => (
|
||
<div
|
||
key={s.label}
|
||
className="flex items-center justify-between text-xs text-fg-muted"
|
||
>
|
||
<span className="flex items-center gap-1.5">
|
||
<span
|
||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||
style={{ backgroundColor: s.color }}
|
||
/>
|
||
{s.label}
|
||
</span>
|
||
<Tip raw={s.value.toLocaleString()}>
|
||
<span className="text-fg-secondary font-mono">{fmt(s.value)}</span>
|
||
</Tip>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Daily session trends */}
|
||
<div className="card p-5">
|
||
<h3 className="text-sm font-medium text-fg-secondary mb-5">
|
||
{t("dailySessionTrends")}
|
||
</h3>
|
||
{dailySessionsLocal.length === 0 ? (
|
||
<p className="text-sm text-fg-muted">{t("noSessionTrendData")}</p>
|
||
) : (
|
||
<>
|
||
<AnalyticsSparkline data={sessionsLast30} color="rgb(var(--accent))" />
|
||
<div className="mt-4 space-y-2">
|
||
{sessionsLast30
|
||
.slice(-7)
|
||
.reverse()
|
||
.map(({ date, count }) => {
|
||
const maxD = maxSessionsDay;
|
||
return (
|
||
<div key={date} className="flex items-center gap-3">
|
||
<span className="text-[11px] text-fg-muted font-mono w-20 flex-shrink-0">
|
||
{date.slice(5)}
|
||
</span>
|
||
<div className="flex-1 bg-surface-3 rounded-full h-1.5">
|
||
<div
|
||
className="bg-accent h-1.5 rounded-full"
|
||
style={{
|
||
width: `${Math.round((count / Math.max(maxD, 1)) * 100)}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
<span className="text-[11px] text-fg-muted w-4 text-right">
|
||
{count}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<p className="text-[11px] text-fg-muted mt-3">{t("last7Days")}</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// The workflow sections the health cards read; the server computes only these.
|
||
const HEALTH_SECTIONS = [
|
||
"stats",
|
||
"toolFlow",
|
||
"effectiveness",
|
||
"modelDelegation",
|
||
"errorPropagation",
|
||
"concurrency",
|
||
"compaction",
|
||
] as const satisfies readonly (keyof WorkflowData)[];
|
||
type HealthWorkflow = Pick<WorkflowData, (typeof HEALTH_SECTIONS)[number]>;
|
||
|
||
function SystemHealthTab() {
|
||
const { t } = useTranslation("dashboard");
|
||
const [info, setInfo] = useState<SystemInfo | null>(null);
|
||
const [workflow, setWorkflow] = useState<HealthWorkflow | null>(null);
|
||
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
const [infoRes, workflowRes] = await Promise.all([
|
||
api.settings.info(),
|
||
api.workflows.get(undefined, undefined, undefined, HEALTH_SECTIONS),
|
||
]);
|
||
setInfo(infoRes as any);
|
||
setWorkflow(workflowRes);
|
||
setLoadError(null);
|
||
} catch (e) {
|
||
setLoadError(e instanceof Error ? e.message : String(e));
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [loadData]);
|
||
// 30s is sufficient for health metrics; skipped while the tab is hidden.
|
||
useLiveRefresh(loadData, { intervalMs: 30000 });
|
||
|
||
const stats = useMemo(() => {
|
||
if (!info || !workflow) return null;
|
||
|
||
const totalEntries =
|
||
(info.db.counts?.sessions || 0) +
|
||
(info.db.counts?.agents || 0) +
|
||
(info.db.counts?.events || 0);
|
||
const sessPct = totalEntries > 0 ? ((info.db.counts?.sessions || 0) / totalEntries) * 100 : 0;
|
||
const agentPct = totalEntries > 0 ? ((info.db.counts?.agents || 0) / totalEntries) * 100 : 0;
|
||
const eventPct = totalEntries > 0 ? ((info.db.counts?.events || 0) / totalEntries) * 100 : 0;
|
||
|
||
const modelStats = [...(workflow.modelDelegation?.tokensByModel || [])]
|
||
.sort((a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens))
|
||
.slice(0, 6);
|
||
const totalTokens = modelStats.reduce((sum, m) => sum + m.input_tokens + m.output_tokens, 0);
|
||
|
||
const memUsedPct =
|
||
info.server.total_mem > 0 ? (1 - info.server.free_mem / info.server.total_mem) * 100 : 0;
|
||
// Against the heap LIMIT: heapTotal is only what V8 has grown to so far, so
|
||
// used/total reads 70-90% on a healthy process and dragged the health score.
|
||
const heapMax = info.server.memory.heapLimit || info.server.memory.heapTotal;
|
||
const heapUsedPct = heapMax > 0 ? (info.server.memory.heapUsed / heapMax) * 100 : 0;
|
||
|
||
return {
|
||
totalEntries,
|
||
sessPct,
|
||
agentPct,
|
||
eventPct,
|
||
modelStats,
|
||
totalTokens,
|
||
memUsedPct,
|
||
heapUsedPct,
|
||
};
|
||
}, [info, workflow]);
|
||
|
||
if (loadError && !info) {
|
||
return (
|
||
<div role="alert" className="card flex flex-col items-center gap-3 py-12 text-sm">
|
||
<AlertCircle className="w-8 h-8 text-status-danger" />
|
||
<p className="text-status-danger">
|
||
{t("failedLoad")}: {loadError}
|
||
</p>
|
||
<button onClick={loadData} className="btn-primary text-sm">
|
||
{t("common:retry")}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!info || !workflow || !stats) {
|
||
return (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||
<div key={i} className="animate-pulse h-48 bg-surface-2 rounded-2xl" />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const {
|
||
totalEntries,
|
||
sessPct,
|
||
agentPct,
|
||
eventPct,
|
||
modelStats,
|
||
totalTokens,
|
||
memUsedPct,
|
||
heapUsedPct,
|
||
} = stats;
|
||
const successRate = Math.max(0, Math.min(100, workflow.stats.successRate));
|
||
const errorRate = Math.max(
|
||
0,
|
||
Math.min(100, workflow.errorPropagation?.errorRate ?? 100 - successRate)
|
||
);
|
||
const cacheHitRate = Math.max(
|
||
0,
|
||
Math.min(
|
||
100,
|
||
((info.transcript_cache?.hits ?? 0) /
|
||
((info.transcript_cache?.hits ?? 0) + (info.transcript_cache?.misses ?? 0) || 1)) *
|
||
100
|
||
)
|
||
);
|
||
|
||
// Concurrency histogram data
|
||
const lanes = workflow.concurrency?.aggregateLanes || [];
|
||
const maxLaneCount = Math.max(...lanes.map((l) => l.count), 1);
|
||
|
||
// Tool flow top tools
|
||
const topTools = (workflow.toolFlow?.toolCounts || []).slice(0, 8);
|
||
const maxToolCount = topTools.length > 0 ? (topTools[0]?.count ?? 1) : 1;
|
||
|
||
// Subagent effectiveness
|
||
const effectiveness = (workflow.effectiveness || []).slice(0, 6);
|
||
|
||
// Composite health score - clamped to [0, 100] for display safety
|
||
const healthScore = Math.max(
|
||
0,
|
||
Math.min(
|
||
100,
|
||
successRate * 0.4 +
|
||
cacheHitRate * 0.25 +
|
||
Math.max(0, 100 - errorRate) * 0.25 +
|
||
Math.max(0, 100 - Math.min(100, heapUsedPct)) * 0.1
|
||
)
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in pb-10">
|
||
{/* Row 1: Runtime + Storage + Health Score */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{/* Runtime Environment */}
|
||
<div className="card p-5 flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<Server className="w-4 h-4 text-status-success" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">
|
||
{t("health.runtime")}
|
||
</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-fg-muted">
|
||
{t("health.cores", { count: info.server.cpus })} · {info.server.arch}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-2.5">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
|
||
{t("health.uptime")}
|
||
</span>
|
||
<span className="text-xs text-fg-secondary font-mono ml-auto">
|
||
{formatUptime(info.server.uptime)}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
|
||
{t("health.cpu")}
|
||
</span>
|
||
<div className="flex gap-1 ml-auto">
|
||
{(info.server.cpu_load || []).slice(0, 3).map((load, i) => (
|
||
<span
|
||
key={i}
|
||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > info.server.cpus ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
|
||
>
|
||
{load.toFixed(2)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
|
||
{t("health.nodeRss")}
|
||
</span>
|
||
<span className="text-xs text-fg-secondary font-mono ml-auto">
|
||
{formatBytes(info.server.memory.rss)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||
<Tip
|
||
block
|
||
raw={t("health.hostMemoryTip", {
|
||
pct: memUsedPct.toFixed(1),
|
||
used: formatBytes(info.server.total_mem - info.server.free_mem),
|
||
total: formatBytes(info.server.total_mem),
|
||
})}
|
||
>
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between text-[10px]">
|
||
<span className="text-fg-muted">{t("health.hostMemory")}</span>
|
||
<span className="text-fg-secondary font-mono">{memUsedPct.toFixed(0)}%</span>
|
||
</div>
|
||
<div className="w-full bg-surface-3 rounded-full h-2">
|
||
<div
|
||
className={`h-2 rounded-full transition-all duration-700 ${memUsedPct > 90 ? "bg-status-danger" : memUsedPct > 70 ? "bg-status-warning" : "bg-status-success"}`}
|
||
style={{ width: `${memUsedPct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Tip>
|
||
<Tip
|
||
block
|
||
raw={t("health.heapTip", {
|
||
pct: heapUsedPct.toFixed(1),
|
||
used: formatBytes(info.server.memory.heapUsed),
|
||
limit: formatBytes(info.server.memory.heapLimit || info.server.memory.heapTotal),
|
||
allocated: formatBytes(info.server.memory.heapTotal),
|
||
external: formatBytes(info.server.memory.external),
|
||
})}
|
||
>
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between text-[10px]">
|
||
<span className="text-fg-muted">{t("health.heap")}</span>
|
||
<span className="text-fg-secondary font-mono">{heapUsedPct.toFixed(0)}%</span>
|
||
</div>
|
||
<div className="w-full bg-surface-3 rounded-full h-2">
|
||
<div
|
||
className={`h-2 rounded-full transition-all duration-700 ${heapUsedPct > 85 ? "bg-status-danger" : heapUsedPct > 60 ? "bg-status-warning" : "bg-syntax-builtin"}`}
|
||
style={{ width: `${heapUsedPct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Tip>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Storage Engine */}
|
||
<div className="card p-5 flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<Database className="w-4 h-4 text-syntax-builtin" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">
|
||
{t("health.storage")}
|
||
</span>
|
||
</div>
|
||
<Tip
|
||
raw={t("health.writeVelocityTip", {
|
||
m5: info.db.load_stats?.m5 ?? 0,
|
||
m15: info.db.load_stats?.m15 ?? 0,
|
||
h1: info.db.load_stats?.h1 ?? 0,
|
||
})}
|
||
>
|
||
<span className="text-[10px] font-mono text-status-success bg-status-success/5 px-2 py-0.5 rounded border border-status-success/10 cursor-default">
|
||
⚡ {info.db.load_stats?.m5 ?? 0}/{info.db.load_stats?.m15 ?? 0}/
|
||
{info.db.load_stats?.h1 ?? 0}
|
||
</span>
|
||
</Tip>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
|
||
{t("health.database")}
|
||
</span>
|
||
<span className="text-xs text-fg-secondary font-mono ml-auto">
|
||
{formatBytes(info.db.size)} · {info.db.pragmas?.journal_mode?.toUpperCase() || "WAL"}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Pie chart + legend using same pattern as Analytics DonutChart */}
|
||
<div className="flex items-center justify-center gap-5 flex-1">
|
||
<Tip
|
||
raw={t("health.recordsTip", {
|
||
sessions: (info.db.counts?.sessions ?? 0).toLocaleString(),
|
||
sessPct: sessPct.toFixed(1),
|
||
agents: (info.db.counts?.agents ?? 0).toLocaleString(),
|
||
agentPct: agentPct.toFixed(1),
|
||
events: (info.db.counts?.events ?? 0).toLocaleString(),
|
||
eventPct: eventPct.toFixed(1),
|
||
total: totalEntries.toLocaleString(),
|
||
})}
|
||
>
|
||
<svg
|
||
width={96}
|
||
height={96}
|
||
viewBox="0 0 96 96"
|
||
className="flex-shrink-0 cursor-default"
|
||
>
|
||
<circle
|
||
cx="48"
|
||
cy="48"
|
||
r="38"
|
||
fill="none"
|
||
stroke="rgb(var(--border))"
|
||
strokeWidth="14"
|
||
/>
|
||
{(() => {
|
||
const r = 38,
|
||
cx = 48,
|
||
cy = 48,
|
||
circumference = 2 * Math.PI * r;
|
||
const segments = [
|
||
{ pct: sessPct, color: "rgb(var(--syn-builtin))" },
|
||
{ pct: agentPct, color: "rgb(var(--syn-keyword))" },
|
||
{ pct: eventPct, color: "rgb(var(--status-success))" },
|
||
];
|
||
let offset = circumference / 4;
|
||
return segments.map((seg, i) => {
|
||
if (seg.pct <= 0) return null;
|
||
const dash = (seg.pct / 100) * circumference;
|
||
const gap = circumference - dash;
|
||
const currentOffset = offset;
|
||
offset -= dash;
|
||
return (
|
||
<circle
|
||
key={i}
|
||
cx={cx}
|
||
cy={cy}
|
||
r={r}
|
||
fill="none"
|
||
stroke={seg.color}
|
||
strokeWidth="14"
|
||
strokeDasharray={`${dash} ${gap}`}
|
||
strokeDashoffset={currentOffset}
|
||
/>
|
||
);
|
||
});
|
||
})()}
|
||
<text
|
||
x="48"
|
||
y="46"
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
className="fill-fg-secondary"
|
||
fontSize="12"
|
||
fontWeight="700"
|
||
fontFamily="monospace"
|
||
>
|
||
{fmt(totalEntries)}
|
||
</text>
|
||
<text
|
||
x="48"
|
||
y="60"
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
className="fill-fg-muted"
|
||
fontSize="8"
|
||
>
|
||
{t("health.total")}
|
||
</text>
|
||
</svg>
|
||
</Tip>
|
||
<div className="space-y-2.5">
|
||
{[
|
||
{
|
||
label: t("health.sessions"),
|
||
value: info.db.counts?.sessions ?? 0,
|
||
color: "rgb(var(--syn-builtin))",
|
||
pct: sessPct,
|
||
},
|
||
{
|
||
label: t("health.agents"),
|
||
value: info.db.counts?.agents ?? 0,
|
||
color: "rgb(var(--syn-keyword))",
|
||
pct: agentPct,
|
||
},
|
||
{
|
||
label: t("health.events"),
|
||
value: info.db.counts?.events ?? 0,
|
||
color: "rgb(var(--status-success))",
|
||
pct: eventPct,
|
||
},
|
||
].map((item) => (
|
||
<Tip
|
||
block
|
||
key={item.label}
|
||
raw={`${item.label}: ${item.value.toLocaleString()} (${item.pct.toFixed(1)}%)`}
|
||
>
|
||
<div className="flex items-center gap-2 text-xs cursor-default">
|
||
<span
|
||
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||
style={{ backgroundColor: item.color }}
|
||
/>
|
||
<span className="text-fg-secondary">{item.label}</span>
|
||
<span className="text-fg-muted ml-auto pl-3 font-mono">
|
||
{Math.round(item.pct)}%
|
||
</span>
|
||
</div>
|
||
</Tip>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Health Score */}
|
||
<div className="card p-5 flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<ShieldCheck className="w-4 h-4 text-status-success" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">
|
||
{t("health.score")}
|
||
</span>
|
||
</div>
|
||
<Tip
|
||
raw={t("health.formulaTip", {
|
||
success: successRate.toFixed(1),
|
||
cache: cacheHitRate.toFixed(1),
|
||
noError: (100 - errorRate).toFixed(1),
|
||
noHeap: (100 - heapUsedPct).toFixed(1),
|
||
score: healthScore.toFixed(1),
|
||
})}
|
||
>
|
||
<span className="text-[10px] text-fg-muted cursor-help border-b border-dashed border-border-light">
|
||
ⓘ {t("health.formula")}
|
||
</span>
|
||
</Tip>
|
||
</div>
|
||
|
||
{/* Ring gauge - centered */}
|
||
<div className="flex items-center justify-center flex-1">
|
||
<Tip
|
||
raw={t("health.scoreTip", {
|
||
score: healthScore.toFixed(1),
|
||
success: successRate.toFixed(1),
|
||
cache: cacheHitRate.toFixed(1),
|
||
noError: (100 - errorRate).toFixed(1),
|
||
noHeap: (100 - heapUsedPct).toFixed(1),
|
||
})}
|
||
>
|
||
<svg width="120" height="120" viewBox="0 0 120 120" className="cursor-default">
|
||
<circle
|
||
cx="60"
|
||
cy="60"
|
||
r="48"
|
||
fill="none"
|
||
stroke="rgb(var(--border))"
|
||
strokeWidth="10"
|
||
/>
|
||
<circle
|
||
cx="60"
|
||
cy="60"
|
||
r="48"
|
||
fill="none"
|
||
stroke={
|
||
healthScore >= 90
|
||
? "rgb(var(--status-success))"
|
||
: healthScore >= 70
|
||
? "rgb(var(--status-warning))"
|
||
: "rgb(var(--status-danger))"
|
||
}
|
||
strokeWidth="10"
|
||
strokeLinecap="round"
|
||
strokeDasharray={`${healthScore * 3.016} ${301.6 - healthScore * 3.016}`}
|
||
strokeDashoffset={301.6 / 4}
|
||
className="transition-all duration-1000"
|
||
/>
|
||
<text
|
||
x="60"
|
||
y="57"
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
className="fill-fg-primary"
|
||
fontSize="24"
|
||
fontWeight="800"
|
||
fontFamily="monospace"
|
||
>
|
||
{healthScore.toFixed(0)}
|
||
</text>
|
||
<text
|
||
x="60"
|
||
y="76"
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
className="fill-fg-muted"
|
||
fontSize="9"
|
||
fontWeight="500"
|
||
>
|
||
/ 100
|
||
</text>
|
||
</svg>
|
||
</Tip>
|
||
</div>
|
||
|
||
{/* Sub-metrics row */}
|
||
<div className="grid grid-cols-4 gap-2 border-t border-border/40 pt-3">
|
||
<Tip
|
||
block
|
||
raw={t("health.cacheTip", {
|
||
pct: cacheHitRate.toFixed(1),
|
||
hits: info.transcript_cache?.hits ?? 0,
|
||
misses: info.transcript_cache?.misses ?? 0,
|
||
})}
|
||
>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">{t("health.cache")}</p>
|
||
<p className="text-xs font-mono font-bold text-syntax-builtin">
|
||
{cacheHitRate.toFixed(0)}%
|
||
</p>
|
||
</div>
|
||
</Tip>
|
||
<Tip block raw={t("health.errorsTip", { pct: errorRate.toFixed(2) })}>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">{t("health.errors")}</p>
|
||
<p
|
||
className={`text-xs font-mono font-bold ${errorRate < 5 ? "text-status-success" : errorRate < 15 ? "text-status-warning" : "text-status-danger"}`}
|
||
>
|
||
{errorRate.toFixed(1)}%
|
||
</p>
|
||
</div>
|
||
</Tip>
|
||
<Tip
|
||
block
|
||
raw={t("health.compactTip", { count: workflow.compaction?.totalCompactions ?? 0 })}
|
||
>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">{t("health.compact")}</p>
|
||
<p className="text-xs font-mono font-bold text-syntax-keyword">
|
||
{workflow.compaction?.totalCompactions ?? 0}
|
||
</p>
|
||
</div>
|
||
</Tip>
|
||
<Tip
|
||
block
|
||
raw={t("health.savedTip", {
|
||
tokens: (workflow.compaction?.tokensRecovered ?? 0).toLocaleString(),
|
||
})}
|
||
>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">{t("health.saved")}</p>
|
||
<p className="text-xs font-mono font-bold text-status-success">
|
||
{fmt(workflow.compaction?.tokensRecovered ?? 0)}
|
||
</p>
|
||
</div>
|
||
</Tip>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 2: Model Usage + Concurrency */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
{/* Model Token Distribution */}
|
||
<div className="card p-5 flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<Bot className="w-4 h-4 text-syntax-builtin" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">
|
||
{t("health.tokenUsage")}
|
||
</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-fg-muted">
|
||
{t("health.totalSuffix", { value: fmt(totalTokens) })}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-2.5">
|
||
{modelStats.map((m, i) => {
|
||
const pct =
|
||
totalTokens > 0 ? ((m.input_tokens + m.output_tokens) / totalTokens) * 100 : 0;
|
||
const colors = [
|
||
"bg-syntax-builtin",
|
||
"bg-syntax-keyword",
|
||
"bg-status-success",
|
||
"bg-status-warning",
|
||
"bg-syntax-operator",
|
||
"bg-syntax-property",
|
||
];
|
||
return (
|
||
<Tip
|
||
block
|
||
key={i}
|
||
raw={t("health.modelTip", {
|
||
model: formatModelName(m.model) ?? m.model,
|
||
input: m.input_tokens.toLocaleString(),
|
||
output: m.output_tokens.toLocaleString(),
|
||
cacheRead: m.cache_read_tokens.toLocaleString(),
|
||
pct: pct.toFixed(1),
|
||
})}
|
||
>
|
||
<div className="flex items-center gap-3 cursor-default">
|
||
<span
|
||
className="text-xs text-fg-secondary w-28 truncate flex-shrink-0"
|
||
title={formatModelName(m.model) ?? m.model}
|
||
>
|
||
{formatModelName(m.model) ?? m.model}
|
||
</span>
|
||
<div className="flex-1 bg-surface-3 rounded-full h-2">
|
||
<div
|
||
className={`${colors[i % colors.length]} h-2 rounded-full transition-all duration-700`}
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-fg-muted w-12 text-right flex-shrink-0 font-mono">
|
||
{pct.toFixed(1)}%
|
||
</span>
|
||
</div>
|
||
</Tip>
|
||
);
|
||
})}
|
||
{modelStats.length === 0 && (
|
||
<p className="text-xs text-fg-muted text-center py-4">{t("health.noModelData")}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Concurrency Timeline */}
|
||
<div className="card p-5 flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<BarChart3 className="w-4 h-4 text-syntax-keyword" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">
|
||
{t("health.concurrency")}
|
||
</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-fg-muted">
|
||
{t("health.intervals", { count: lanes.length })}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Sparkline-style bar chart matching Analytics sparkline */}
|
||
<Tip
|
||
block
|
||
raw={t("health.concurrencyTip", {
|
||
peak: maxLaneCount,
|
||
active: lanes.filter((l) => l.count > 0).length,
|
||
avg:
|
||
lanes.length > 0
|
||
? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1)
|
||
: "0",
|
||
})}
|
||
>
|
||
<div className="flex items-end gap-px h-20 cursor-default">
|
||
{lanes.slice(-Math.min(lanes.length, 40)).map((lane, i) => {
|
||
const barPct =
|
||
maxLaneCount > 0 ? Math.max(4, Math.round((lane.count / maxLaneCount) * 100)) : 4;
|
||
const color =
|
||
lane.count > 5
|
||
? "rgb(var(--status-danger))"
|
||
: lane.count > 2
|
||
? "rgb(var(--status-warning))"
|
||
: lane.count > 0
|
||
? "rgb(var(--status-success))"
|
||
: "rgb(var(--border))";
|
||
return (
|
||
<div
|
||
key={i}
|
||
className="flex-1 rounded-sm transition-all"
|
||
style={{
|
||
height: `${barPct}%`,
|
||
backgroundColor: color,
|
||
opacity: lane.count === 0 ? 0.2 : 0.85,
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
{lanes.length === 0 && (
|
||
<p className="text-xs text-fg-muted text-center w-full self-center">
|
||
{t("health.noConcurrencyData")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</Tip>
|
||
|
||
<div className="grid grid-cols-3 gap-3 border-t border-border/40 pt-3">
|
||
<Tip block raw={t("health.peakTip", { count: maxLaneCount })}>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">{t("health.peak")}</p>
|
||
<p className="text-sm font-mono font-bold text-fg-secondary">{maxLaneCount}</p>
|
||
</div>
|
||
</Tip>
|
||
<Tip
|
||
block
|
||
raw={t("health.activeTip", {
|
||
active: lanes.filter((l) => l.count > 0).length,
|
||
total: lanes.length,
|
||
})}
|
||
>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">{t("health.active")}</p>
|
||
<p className="text-sm font-mono font-bold text-status-success">
|
||
{lanes.filter((l) => l.count > 0).length}
|
||
</p>
|
||
</div>
|
||
</Tip>
|
||
<Tip block raw={t("health.avgTip")}>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">{t("health.avg")}</p>
|
||
<p className="text-sm font-mono font-bold text-syntax-builtin">
|
||
{lanes.length > 0
|
||
? (lanes.reduce((s, l) => s + l.count, 0) / lanes.length).toFixed(1)
|
||
: "0"}
|
||
</p>
|
||
</div>
|
||
</Tip>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 3: Tool Usage + Subagent Effectiveness */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
{/* Tool Invocations */}
|
||
<div className="card p-5 flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<Zap className="w-4 h-4 text-status-warning" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">
|
||
{t("health.toolUsage")}
|
||
</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-fg-muted">
|
||
{t("health.top", { count: topTools.length })}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{topTools.map((tool, i) => {
|
||
const pct = maxToolCount > 0 ? Math.round((tool.count / maxToolCount) * 100) : 0;
|
||
const colors = [
|
||
"bg-status-warning",
|
||
"bg-syntax-builtin",
|
||
"bg-status-success",
|
||
"bg-syntax-keyword",
|
||
"bg-syntax-operator",
|
||
"bg-syntax-property",
|
||
"bg-status-danger",
|
||
"bg-accent",
|
||
];
|
||
return (
|
||
<Tip
|
||
block
|
||
key={i}
|
||
raw={t("health.invocationsTip", {
|
||
tool: tool.tool_name,
|
||
count: tool.count,
|
||
formatted: tool.count.toLocaleString(),
|
||
})}
|
||
>
|
||
<div className="flex items-center gap-3 cursor-default">
|
||
<span
|
||
className="text-xs text-fg-secondary w-28 truncate flex-shrink-0"
|
||
title={tool.tool_name}
|
||
>
|
||
{tool.tool_name}
|
||
</span>
|
||
<div className="flex-1 bg-surface-3 rounded-full h-2">
|
||
<div
|
||
className={`${colors[i % colors.length]} h-2 rounded-full transition-all duration-500`}
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-fg-muted w-10 text-right flex-shrink-0 font-mono">
|
||
{fmt(tool.count)}
|
||
</span>
|
||
</div>
|
||
</Tip>
|
||
);
|
||
})}
|
||
{topTools.length === 0 && (
|
||
<p className="text-xs text-fg-muted text-center py-6">{t("health.noToolData")}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Subagent Effectiveness */}
|
||
<div className="card p-5 flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<GitBranch className="w-4 h-4 text-syntax-keyword" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">
|
||
{t("health.subagentEffectiveness")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{effectiveness.map((item, i) => {
|
||
const color =
|
||
item.successRate >= 90
|
||
? "bg-status-success"
|
||
: item.successRate >= 70
|
||
? "bg-status-warning"
|
||
: "bg-status-danger";
|
||
return (
|
||
<Tip
|
||
block
|
||
key={i}
|
||
raw={t("health.effectivenessTip", {
|
||
type: item.subagent_type || t("health.defaultType"),
|
||
pct: item.successRate.toFixed(1),
|
||
total: item.total,
|
||
ok: item.completed,
|
||
errors: item.errors,
|
||
})}
|
||
>
|
||
<div className="flex items-center gap-3 cursor-default">
|
||
<span className="text-xs text-fg-secondary w-28 truncate flex-shrink-0">
|
||
{item.subagent_type || t("health.defaultType")}
|
||
</span>
|
||
<div className="flex-1 bg-surface-3 rounded-full h-2">
|
||
<div
|
||
className={`${color} h-2 rounded-full transition-all duration-500`}
|
||
style={{ width: `${item.successRate}%` }}
|
||
/>
|
||
</div>
|
||
<span
|
||
className={`text-xs w-12 text-right flex-shrink-0 font-mono ${item.successRate >= 90 ? "text-status-success" : item.successRate >= 70 ? "text-status-warning" : "text-status-danger"}`}
|
||
>
|
||
{item.successRate.toFixed(0)}%
|
||
</span>
|
||
</div>
|
||
</Tip>
|
||
);
|
||
})}
|
||
{effectiveness.length === 0 && (
|
||
<p className="text-xs text-fg-muted text-center py-6">{t("health.noSubagentData")}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 4: Integration Gateway + Platform Config */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
{/* Integration Gateway */}
|
||
<div className="card p-5 flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<Plug className="w-4 h-4 text-status-warning" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">
|
||
{t("health.integration")}
|
||
</span>
|
||
</div>
|
||
<span
|
||
className={`text-[10px] font-mono px-2 py-0.5 rounded ${info.hooks.installed ? "bg-status-success/10 text-status-success border border-status-success/20" : "bg-surface-3 text-fg-muted border border-border"}`}
|
||
>
|
||
{info.hooks.installed ? t("health.hooksActive") : t("common:offline")}
|
||
</span>
|
||
</div>
|
||
|
||
{Object.entries(info.hooks.hooks || {}).length > 0 ? (
|
||
<div className="space-y-2">
|
||
{Object.entries(info.hooks.hooks).map(([cwd, active]) => (
|
||
<Tip
|
||
block
|
||
key={cwd}
|
||
raw={t("health.hookTip", {
|
||
path: cwd,
|
||
status: active ? t("health.connected") : t("health.disconnected"),
|
||
})}
|
||
>
|
||
<div className="flex items-center gap-3 bg-surface-2/50 px-3 py-2 rounded-lg border border-border/30 cursor-default">
|
||
<div
|
||
className={`w-2 h-2 rounded-full flex-shrink-0 ${active ? "bg-status-success" : "bg-surface-4"}`}
|
||
/>
|
||
<span className="text-xs text-fg-secondary truncate font-mono">
|
||
{cwd.split("/").pop() || cwd}
|
||
</span>
|
||
</div>
|
||
</Tip>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center py-6 border border-dashed border-border/40 rounded-lg">
|
||
<Search className="w-4 h-4 text-fg-muted mb-2" />
|
||
<p className="text-xs text-fg-muted">{t("health.noHooks")}</p>
|
||
</div>
|
||
)}
|
||
|
||
<Tip block raw={t("health.wsTip", { count: info.server.ws_connections })}>
|
||
<div className="flex items-center gap-3 bg-status-success/5 px-3 py-2.5 rounded-lg border border-status-success/10 cursor-default">
|
||
<Activity className="w-3.5 h-3.5 text-status-success animate-pulse" />
|
||
<div>
|
||
<p className="text-[10px] text-status-success font-medium">
|
||
{t("health.wsActive")}
|
||
</p>
|
||
<p className="text-[10px] text-fg-muted">
|
||
{t("health.connections", { count: info.server.ws_connections })}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Tip>
|
||
</div>
|
||
|
||
{/* Platform Config */}
|
||
<div className="card p-5 flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<Cpu className="w-4 h-4 text-syntax-property" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">
|
||
{t("health.platform")}
|
||
</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-fg-muted">{info.server.node_version}</span>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{[
|
||
{
|
||
label: t("health.journalMode"),
|
||
value: info.db.pragmas?.journal_mode?.toUpperCase() || "WAL",
|
||
},
|
||
{
|
||
label: t("health.synchronous"),
|
||
value:
|
||
info.db.pragmas?.synchronous === 2
|
||
? "FULL"
|
||
: info.db.pragmas?.synchronous === 1
|
||
? "NORMAL"
|
||
: "OFF",
|
||
},
|
||
{
|
||
label: t("health.autoVacuum"),
|
||
value: info.db.pragmas?.auto_vacuum > 0 ? "FULL" : "OFF",
|
||
},
|
||
{
|
||
label: t("health.foreignKeys"),
|
||
value: info.db.pragmas?.foreign_keys ? "ON" : "OFF",
|
||
},
|
||
{
|
||
label: t("health.busyTimeout"),
|
||
value: `${info.db.pragmas?.busy_timeout || 5000}ms`,
|
||
},
|
||
{
|
||
label: t("health.platform"),
|
||
value: `${info.server.platform} / ${info.server.arch}`,
|
||
},
|
||
].map((row) => (
|
||
<div key={row.label} className="flex items-center gap-3">
|
||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{row.label}</span>
|
||
<span className="text-xs text-fg-secondary font-mono ml-auto">{row.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<Tip block raw={info.db.path}>
|
||
<div className="flex items-center gap-2 bg-surface-2/50 px-3 py-2 rounded-lg border border-border/30 cursor-default">
|
||
<HardDrive className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||
<span className="text-[10px] text-fg-secondary font-mono truncate">
|
||
{info.db.path}
|
||
</span>
|
||
</div>
|
||
</Tip>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// The Workflow sub-tab (d3 charts) loads on first open, not with the Dashboard.
|
||
const Workflows = lazy(() => import("./Workflows").then((m) => ({ default: m.Workflows })));
|
||
|
||
/** Rows kept in the Monitor's recent-activity list, on load and on a push alike. */
|
||
const RECENT_LIMIT = 30;
|
||
|
||
/**
|
||
* The Monitor's recent-activity column. Its own component (own state, own
|
||
* fetch and push subscription) so a new event re-renders this list only, not
|
||
* the whole Dashboard and its agent tree.
|
||
*/
|
||
function RecentActivityPanel({ sessionsById }: { sessionsById: Map<string, Session> }) {
|
||
const { t } = useTranslation("dashboard");
|
||
const eventCtx = useContextMenu<string>();
|
||
const [recentEvents, setRecentEvents] = useState<DashboardEvent[]>([]);
|
||
const [scope] = useDataScope();
|
||
|
||
// Dynamic row count based on the column's available height.
|
||
const activityContainerRef = useRef<HTMLDivElement>(null);
|
||
const [visibleActivityCount, setVisibleActivityCount] = useState(8);
|
||
|
||
useEffect(() => {
|
||
const ACTIVITY_ROW_H = 44; // ~px per activity row
|
||
const HEADER_H = 40; // section header height
|
||
|
||
function recalc() {
|
||
if (activityContainerRef.current) {
|
||
const h = activityContainerRef.current.clientHeight;
|
||
setVisibleActivityCount(Math.max(3, Math.floor((h - HEADER_H) / ACTIVITY_ROW_H)));
|
||
}
|
||
}
|
||
|
||
const ro = new ResizeObserver(recalc);
|
||
if (activityContainerRef.current) ro.observe(activityContainerRef.current);
|
||
recalc();
|
||
|
||
return () => ro.disconnect();
|
||
}, []);
|
||
|
||
const load = useCallback(() => {
|
||
api.events
|
||
.list({ limit: RECENT_LIMIT })
|
||
.then((r) => setRecentEvents(r.events))
|
||
.catch(() => undefined); // a failed refresh keeps the last list
|
||
}, [scope]);
|
||
useEffect(() => {
|
||
load();
|
||
}, [load]);
|
||
useLiveRefresh(load, { intervalMs: 10000 });
|
||
|
||
// A new event is prepended at once (no reload); the poll reconciles.
|
||
useEffect(
|
||
() =>
|
||
eventBus.subscribe((msg: WSMessage) => {
|
||
if (msg.type !== "new_event") return;
|
||
const newEvent = msg.data as DashboardEvent;
|
||
setRecentEvents((prev) => {
|
||
// Deduplicate by event ID to prevent WS + polling race condition
|
||
if (newEvent.id && prev.some((e) => e.id === newEvent.id)) return prev;
|
||
return [newEvent, ...prev].slice(0, RECENT_LIMIT);
|
||
});
|
||
}),
|
||
[]
|
||
);
|
||
|
||
return (
|
||
<div ref={activityContainerRef} className="min-w-0 overflow-y-auto pl-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-sm font-medium text-fg-secondary">{t("recentActivity")}</h3>
|
||
<Link to="/sessions?view=events" className="btn-ghost text-xs">
|
||
{t("viewAll")} <ArrowRight className="w-3 h-3" />
|
||
</Link>
|
||
</div>
|
||
{recentEvents.length === 0 ? (
|
||
<EmptyState icon={Activity} title={t("noActivity")} description={t("noActivityDesc")} />
|
||
) : (
|
||
<div className="card divide-y divide-border">
|
||
{eventCtx.menu && (
|
||
<SessionRowMenu
|
||
session={{
|
||
id: eventCtx.menu.item,
|
||
lane_id: sessionsById.get(eventCtx.menu.item)?.lane_id,
|
||
cwd: sessionsById.get(eventCtx.menu.item)?.cwd,
|
||
}}
|
||
x={eventCtx.menu.x}
|
||
y={eventCtx.menu.y}
|
||
onClose={eventCtx.close}
|
||
/>
|
||
)}
|
||
{recentEvents.slice(0, visibleActivityCount).map((event, i) => (
|
||
<Link
|
||
key={event.id ?? i}
|
||
to={`/sessions/${event.session_id}`}
|
||
className="px-4 py-3 flex items-center gap-3 hover:bg-surface-4 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||
onContextMenu={(e) => eventCtx.open(e, event.session_id)}
|
||
>
|
||
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
|
||
<span className="text-sm text-fg-secondary truncate flex-1">
|
||
{event.summary || event.event_type}
|
||
</span>
|
||
{(() => {
|
||
// Session label: real name when one exists, else the
|
||
// short ID - keeps every activity row attributable.
|
||
const sname =
|
||
(event.session_name ?? sessionsById.get(event.session_id)?.name)?.trim() || "";
|
||
const isAuto = /^Session [0-9a-f]{8}$/i.test(sname);
|
||
return (
|
||
<span
|
||
className="text-[11px] text-fg-muted truncate max-w-[9rem] flex-shrink-0"
|
||
title={event.session_id}
|
||
>
|
||
{sname && !isAuto ? (
|
||
sname
|
||
) : (
|
||
<span className="font-mono">{event.session_id.slice(0, 8)}</span>
|
||
)}
|
||
</span>
|
||
);
|
||
})()}
|
||
{event.tool_name && (
|
||
<span className="text-[11px] text-fg-muted font-mono">{event.tool_name}</span>
|
||
)}
|
||
<span className="text-[11px] text-fg-muted flex-shrink-0">
|
||
{timeAgo(event.created_at)}
|
||
</span>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type DashboardTab = "monitor" | "analytics" | "health";
|
||
function isDashboardTab(v: string | null): v is DashboardTab {
|
||
return v === "monitor" || v === "analytics" || v === "health";
|
||
}
|
||
|
||
export function Dashboard() {
|
||
const { t } = useTranslation("dashboard");
|
||
|
||
// Tab state: `?tab=` wins, else the last tab used, else Analytics. Written
|
||
// back to the URL (replace) so a reload or a shared link lands on it.
|
||
const [tabParams, setTabParams] = useSearchParams();
|
||
const urlTab = tabParams.get("tab");
|
||
const storedTab = localStorage.getItem("dashboard_tab");
|
||
const activeTab: DashboardTab = isDashboardTab(urlTab)
|
||
? urlTab
|
||
: isDashboardTab(storedTab)
|
||
? storedTab
|
||
: "analytics";
|
||
const setActiveTab = (tab: DashboardTab) => {
|
||
localStorage.setItem("dashboard_tab", tab);
|
||
setTabParams(
|
||
(prev) => {
|
||
const next = new URLSearchParams(prev);
|
||
next.set("tab", tab);
|
||
if (tab !== "analytics") next.delete("sub");
|
||
return next;
|
||
},
|
||
{ replace: true }
|
||
);
|
||
};
|
||
|
||
const [stats, setStats] = useState<Stats | null>(null);
|
||
const [activeAgents, setActiveAgents] = useState<Agent[]>([]);
|
||
const [totalCost, setTotalCost] = useState<number | null>(null);
|
||
const [allSubagents, setAllSubagents] = useState<Agent[]>([]);
|
||
const [sessionsById, setSessionsById] = useState<Map<string, Session>>(new Map());
|
||
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(new Set());
|
||
// Calls that failed on the last load; each panel keeps its last good data.
|
||
const [error, setError] = useState<string | null>(null);
|
||
// The agents column scrolls, so no main agent is ever dropped.
|
||
const agentsContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Global data scope; a change re-runs `load` (the api layer injects the
|
||
// matching `sources` param into the calls below).
|
||
const [scope] = useDataScope();
|
||
|
||
const monitorSeq = useRef(0);
|
||
const load = useCallback(async () => {
|
||
const seq = ++monitorSeq.current;
|
||
const failed: string[] = [];
|
||
// One failing endpoint must not blank the others: each call keeps its own
|
||
// catch and the panel it feeds keeps its last good value.
|
||
const soft = <T,>(p: Promise<T>): Promise<T | null> =>
|
||
p.catch((err: unknown) => {
|
||
failed.push(err instanceof Error ? err.message : String(err));
|
||
return null;
|
||
});
|
||
const [statsRes, workingRes, waitingRes, costRes, sessionsRes] = await Promise.all([
|
||
soft(api.stats.get()),
|
||
soft(api.agents.list({ status: "working", limit: 20 })),
|
||
soft(api.agents.list({ status: "waiting", limit: 20 })),
|
||
soft(api.pricing.totalCost()),
|
||
soft(api.sessions.list({ status: "active", limit: 100 })),
|
||
]);
|
||
if (seq !== monitorSeq.current) return;
|
||
if (statsRes) setStats(statsRes);
|
||
if (costRes) setTotalCost(costRes.total_cost);
|
||
if (sessionsRes) setSessionsById(new Map(sessionsRes.sessions.map((s) => [s.id, s])));
|
||
if (workingRes && waitingRes) {
|
||
const active = [...workingRes.agents, ...waitingRes.agents];
|
||
setActiveAgents(active);
|
||
// Every subagent of the active main agents' sessions, in ONE call.
|
||
const activeSessionIds = [
|
||
...new Set(active.filter((a) => a.type === "main").map((a) => a.session_id)),
|
||
];
|
||
const subsRes = activeSessionIds.length
|
||
? await soft(api.agents.list({ session_ids: activeSessionIds, limit: 2000 }))
|
||
: { agents: [] };
|
||
if (seq !== monitorSeq.current) return;
|
||
if (subsRes) setAllSubagents(subsRes.agents.filter((a) => a.type === "subagent"));
|
||
}
|
||
setError(failed[0] ?? null);
|
||
}, [scope]);
|
||
|
||
const monitorOn = activeTab === "monitor";
|
||
useEffect(() => {
|
||
if (monitorOn) load();
|
||
}, [load, monitorOn]);
|
||
|
||
useLiveRefresh(load, {
|
||
enabled: monitorOn,
|
||
intervalMs: 10000,
|
||
match: (msg) =>
|
||
msg.type === "agent_created" ||
|
||
msg.type === "agent_updated" ||
|
||
msg.type === "session_created" ||
|
||
msg.type === "session_updated" ||
|
||
isRemoteDataRefreshMessage(msg),
|
||
});
|
||
|
||
// Auto-expand agents with active subagents (walk up the full parent chain)
|
||
useEffect(() => {
|
||
const parentsWithActive = new Set<string>();
|
||
for (const a of allSubagents) {
|
||
if (a.parent_agent_id && a.status === "working") {
|
||
parentsWithActive.add(a.parent_agent_id);
|
||
}
|
||
}
|
||
if (parentsWithActive.size === 0) return; // No-op: skip state update entirely
|
||
|
||
const subMap = new Map(allSubagents.map((a) => [a.id, a]));
|
||
const toExpand = new Set<string>();
|
||
for (const pid of parentsWithActive) {
|
||
let cur = pid;
|
||
while (cur) {
|
||
toExpand.add(cur);
|
||
const parent = subMap.get(cur);
|
||
cur = parent?.parent_agent_id ?? "";
|
||
}
|
||
}
|
||
setExpandedAgents((prev) => {
|
||
// Only update if there are genuinely new IDs to add
|
||
const newIds = [...toExpand].filter((id) => !prev.has(id));
|
||
if (newIds.length === 0) return prev; // Stable reference - no re-render
|
||
return new Set([...prev, ...newIds]);
|
||
});
|
||
}, [allSubagents]);
|
||
|
||
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
||
|
||
// Memoize agent tree structure to avoid recalculating on every render
|
||
const agentTree = useMemo(() => {
|
||
const childrenByParent = new Map<string, Agent[]>();
|
||
for (const a of allSubagents) {
|
||
if (a.parent_agent_id) {
|
||
const list = childrenByParent.get(a.parent_agent_id) || [];
|
||
list.push(a);
|
||
childrenByParent.set(a.parent_agent_id, list);
|
||
}
|
||
}
|
||
|
||
// Pre-compute descendant counts with memoization (avoids exponential recursion)
|
||
const descendantCache = new Map<string, { total: number; active: number }>();
|
||
function getDescendants(id: string): { total: number; active: number } {
|
||
if (descendantCache.has(id)) return descendantCache.get(id)!;
|
||
// Seed a zero sentinel before recursing so a cyclic parent_agent_id
|
||
// (corrupt data) resolves to the cached value instead of looping forever.
|
||
descendantCache.set(id, { total: 0, active: 0 });
|
||
const kids = childrenByParent.get(id) || [];
|
||
const result = kids.reduce(
|
||
(acc, k) => {
|
||
const child = getDescendants(k.id);
|
||
return {
|
||
total: acc.total + 1 + child.total,
|
||
active: acc.active + (k.status === "working" ? 1 : 0) + child.active,
|
||
};
|
||
},
|
||
{ total: 0, active: 0 }
|
||
);
|
||
descendantCache.set(id, result);
|
||
return result;
|
||
}
|
||
// Pre-warm cache for all nodes
|
||
for (const a of allSubagents) getDescendants(a.id);
|
||
|
||
return { childrenByParent, getDescendants };
|
||
}, [allSubagents]);
|
||
|
||
const tabs: Array<{ key: DashboardTab; label: string; icon: typeof BarChart3 }> = [
|
||
{ key: "analytics", label: t("tabs.analytics"), icon: BarChart3 },
|
||
{ key: "monitor", label: t("tabs.monitor"), icon: Activity },
|
||
{ key: "health", label: t("tabs.health"), icon: Server },
|
||
];
|
||
|
||
return (
|
||
<div className="flex flex-col gap-8 animate-fade-in min-h-[calc(100vh-4rem)]">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||
<LayoutDashboard className="w-4.5 h-4.5 text-accent" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||
{wsConnected ? (
|
||
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
|
||
{t("common:live")}
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
|
||
{t("common:offline")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-fg-muted">{t("subtitle")}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{/* Monitor's Refresh sits LEFT of the tabs: on their right it pushed
|
||
Analytics/Monitor/Health aside every time Monitor opened.
|
||
Analytics and Health carry their own Refresh; one per view. */}
|
||
{monitorOn && (
|
||
<button onClick={load} className="btn-ghost flex-shrink-0">
|
||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
||
</button>
|
||
)}
|
||
{/* Tabs */}
|
||
<div
|
||
role="tablist"
|
||
aria-label={t("tabs.label")}
|
||
className="flex bg-surface-2 rounded-lg p-0.5 border border-border"
|
||
>
|
||
{tabs.map(({ key, label, icon: Icon }) => (
|
||
<button
|
||
key={key}
|
||
role="tab"
|
||
aria-selected={activeTab === key}
|
||
onClick={() => setActiveTab(key)}
|
||
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
|
||
activeTab === key
|
||
? "bg-accent/15 text-accent shadow-sm"
|
||
: "text-fg-muted hover:text-fg-secondary"
|
||
}`}
|
||
>
|
||
<Icon className="w-3.5 h-3.5" /> {label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{activeTab !== "health" && <NeedsYouStrip compact={activeTab === "analytics"} />}
|
||
|
||
{activeTab === "monitor" ? (
|
||
<div className="flex-1 flex flex-col gap-8 min-h-0">
|
||
{error && (
|
||
<div
|
||
role="alert"
|
||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||
>
|
||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||
<span className="flex-1 min-w-0">
|
||
{t("failedLoad")}: {error}
|
||
</span>
|
||
<button onClick={load} className="btn-ghost text-xs">
|
||
{t("common:retry")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{/* Stats grid - 2 rows of 3 avoids the 6-column squeeze */}
|
||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||
<StatCard
|
||
label={t("totalSessions")}
|
||
to="/sessions"
|
||
value={stats ? fmt(stats.total_sessions) : ""}
|
||
raw={stats ? stats.total_sessions.toLocaleString() : undefined}
|
||
icon={FolderOpen}
|
||
trend={stats ? `${stats.active_sessions}${t("activeTrend")}` : undefined}
|
||
loading={!stats}
|
||
/>
|
||
<StatCard
|
||
label={t("activeAgents")}
|
||
value={stats?.active_agents ?? ""}
|
||
icon={Bot}
|
||
accentColor="text-status-success"
|
||
loading={!stats}
|
||
/>
|
||
<StatCard
|
||
label={t("activeSubagents")}
|
||
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
|
||
icon={GitBranch}
|
||
accentColor="text-syntax-keyword"
|
||
trend={stats ? `${allSubagents.length}${t("totalTrend")}` : undefined}
|
||
loading={!stats}
|
||
/>
|
||
<StatCard
|
||
label={t("eventsToday")}
|
||
to="/sessions?view=events"
|
||
value={stats ? fmt(stats.events_today) : ""}
|
||
raw={stats ? stats.events_today.toLocaleString() : undefined}
|
||
icon={Zap}
|
||
accentColor="text-status-warning"
|
||
loading={!stats}
|
||
/>
|
||
<StatCard
|
||
label={t("totalEvents")}
|
||
to="/sessions?view=events"
|
||
value={stats ? fmt(stats.total_events) : ""}
|
||
raw={stats ? stats.total_events.toLocaleString() : undefined}
|
||
icon={Activity}
|
||
accentColor="text-syntax-keyword"
|
||
loading={!stats}
|
||
/>
|
||
<StatCard
|
||
label={t("totalCost")}
|
||
value={totalCost !== null ? fmtCost(totalCost) : ""}
|
||
raw={
|
||
totalCost !== null
|
||
? `$${totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||
: undefined
|
||
}
|
||
icon={DollarSign}
|
||
accentColor="text-status-success"
|
||
loading={totalCost === null}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-0 min-w-0 flex-1 min-h-0">
|
||
{/* Active agents */}
|
||
<div ref={agentsContainerRef} className="min-w-0 overflow-y-auto pr-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-sm font-medium text-fg-secondary">
|
||
{t("activeAgentsSection")}
|
||
</h3>
|
||
</div>
|
||
{activeAgents.length === 0 ? (
|
||
<EmptyState icon={Bot} title={t("noAgents")} description={t("noAgentsDesc")} />
|
||
) : (
|
||
<div className="space-y-2">
|
||
{(() => {
|
||
const { childrenByParent, getDescendants } = agentTree;
|
||
|
||
function renderAgentNode(
|
||
agent: Agent,
|
||
depth: number,
|
||
ancestors: Set<string> = new Set()
|
||
): ReactNode {
|
||
// Guard against a cyclic parent_agent_id (corrupt data) so
|
||
// the recursive render can't stack-overflow the page.
|
||
if (ancestors.has(agent.id)) return null;
|
||
const childAncestors = new Set(ancestors).add(agent.id);
|
||
const children = childrenByParent.get(agent.id) || [];
|
||
const isExpanded = expandedAgents.has(agent.id);
|
||
const hasChildren = children.length > 0;
|
||
const isSubagent = depth > 0;
|
||
const { total: totalDesc, active: activeDesc } = hasChildren
|
||
? getDescendants(agent.id)
|
||
: { total: 0, active: 0 };
|
||
const toggleExpanded = () =>
|
||
setExpandedAgents((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(agent.id)) next.delete(agent.id);
|
||
else next.add(agent.id);
|
||
return next;
|
||
});
|
||
|
||
return (
|
||
<div key={agent.id}>
|
||
<div className="flex items-center gap-1 min-w-0">
|
||
{hasChildren && (
|
||
<button
|
||
onClick={toggleExpanded}
|
||
className="p-1 text-fg-muted hover:text-fg-secondary transition-colors flex-shrink-0"
|
||
aria-label={
|
||
isExpanded ? t("collapseSubagents") : t("expandSubagents")
|
||
}
|
||
aria-expanded={isExpanded}
|
||
>
|
||
{isExpanded ? (
|
||
<ChevronDown className="w-4 h-4" />
|
||
) : (
|
||
<ChevronRight className="w-4 h-4" />
|
||
)}
|
||
</button>
|
||
)}
|
||
{/* Reserve the chevron column even when this row
|
||
has no chevron - without this, peer top-level
|
||
mains would line up at different x positions
|
||
depending on whether they have subagents,
|
||
making chevron-having mains look indented
|
||
like a subagent of the chevron-less main
|
||
above them. A muted leaf-marker icon fills
|
||
the slot so the column reads as deliberately
|
||
empty rather than as a misalignment. */}
|
||
{!hasChildren && !isSubagent && (
|
||
<span
|
||
className="w-6 h-6 flex-shrink-0 flex items-center justify-center text-syntax-keyword/70"
|
||
aria-hidden="true"
|
||
title={t("common:noSubagents", "No subagents")}
|
||
>
|
||
<CircleDot className="w-4 h-4" strokeWidth={2} />
|
||
</span>
|
||
)}
|
||
{isSubagent && (
|
||
<GitBranch className="w-3 h-3 text-syntax-keyword flex-shrink-0" />
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<AgentCard
|
||
agent={agent}
|
||
session={sessionsById.get(agent.session_id)}
|
||
// Card click always navigates (AgentCard's
|
||
// default → session details), whether or not it
|
||
// has children. Expand/collapse is handled solely
|
||
// by the chevron button, so clicking a parent
|
||
// (incl. the main agent) no longer toggles.
|
||
onClick={undefined}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{hasChildren && isExpanded && (
|
||
<div className="ml-6 mt-1 space-y-1 border-l-2 border-syntax-keyword/20 pl-3">
|
||
{children.map((child) =>
|
||
renderAgentNode(child, depth + 1, childAncestors)
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{hasChildren && !isExpanded && (
|
||
<button
|
||
onClick={() =>
|
||
setExpandedAgents((prev) => new Set([...prev, agent.id]))
|
||
}
|
||
className="ml-7 mt-1 text-[11px] text-syntax-keyword hover:text-fg-primary transition-colors"
|
||
>
|
||
{t("common:subagent_label", { count: totalDesc })}
|
||
{activeDesc > 0 && (
|
||
<span className="text-status-success ml-1">
|
||
({activeDesc} {t("common:active")})
|
||
</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Build the set of agent ids that will be rendered as
|
||
// descendants under the visible main-agent trees, so the
|
||
// orphan-subagent block below doesn't render them a
|
||
// second time at the root. Previously the orphan filter
|
||
// was `a.type === "subagent"` with no parentage check,
|
||
// which surfaced every nested subagent twice: once
|
||
// indented under its main, and once flush at root level.
|
||
// Every main agent: the panel scrolls, nothing is dropped.
|
||
const visibleMains = activeAgents.filter((a) => a.type === "main");
|
||
const renderedInTree = new Set<string>();
|
||
for (const m of visibleMains) {
|
||
const stack: string[] = [m.id];
|
||
while (stack.length) {
|
||
const id = stack.pop()!;
|
||
if (renderedInTree.has(id)) continue;
|
||
renderedInTree.add(id);
|
||
for (const child of childrenByParent.get(id) || []) {
|
||
stack.push(child.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{visibleMains.map((main) => renderAgentNode(main, 0))}
|
||
{/* Only true orphans: subagents whose ancestor chain
|
||
isn't already shown in a tree above. */}
|
||
{activeAgents
|
||
.filter((a) => a.type === "subagent" && !renderedInTree.has(a.id))
|
||
.map((agent) => (
|
||
<div key={agent.id}>
|
||
<AgentCard
|
||
agent={agent}
|
||
session={sessionsById.get(agent.session_id)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Vertical Divider */}
|
||
<div className="hidden lg:block w-px bg-border self-stretch" />
|
||
|
||
{/* Recent activity */}
|
||
<RecentActivityPanel sessionsById={sessionsById} />
|
||
</div>
|
||
</div>
|
||
) : activeTab === "analytics" ? (
|
||
<AnalyticsTab />
|
||
) : (
|
||
<SystemHealthTab />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|