8ce20e8163
Closes issue #5 (Dashboard | Update menu & page): - Analytics content (heatmap, sparkline, token/cost/workflow/productivity charts) moves into a new "Analytics" tab on the Dashboard page, alongside the existing Monitor/Health tabs. The standalone Analytics page, its /analytics route, and its sidebar menu entry are removed. - The Kanban Board page, its /kanban route, and its sidebar menu entry are removed entirely (page content deleted, not just hidden). - Updated ARCHITECTURE.md, README.md, client/README.md, docs/API.md, and the affected client tests/snapshots to match.
2897 lines
115 KiB
TypeScript
2897 lines
115 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.
|
||
* @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.
|
||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||
*
|
||
* ## 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,
|
||
type ReactNode,
|
||
} from "react";
|
||
import { useNavigate } 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,
|
||
} from "lucide-react";
|
||
import { api } from "../lib/api";
|
||
import { eventBus } from "../lib/eventBus";
|
||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||
import { useDataScope } from "../lib/dataScope";
|
||
import { StatCard } from "../components/StatCard";
|
||
import { AgentCard } from "../components/AgentCard";
|
||
import { AgentStatusBadge } from "../components/StatusBadge";
|
||
import { EmptyState } from "../components/EmptyState";
|
||
import { Tip } from "../components/Tip";
|
||
import { Skeleton, StatValueSkeleton, TextSkeleton } from "../components/Skeleton";
|
||
import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format";
|
||
import type {
|
||
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 };
|
||
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-[#12121f] border border-[#2a2a4a] 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 };
|
||
}
|
||
|
||
function cellColor(count: number, max: number) {
|
||
if (count === 0) return "#161625";
|
||
// Log scale + RGB interpolation across a wide color ramp for maximum perceptual range
|
||
const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1);
|
||
// Ramp: near-black indigo → deep indigo → bright indigo → lavender
|
||
type RGB = [number, number, number];
|
||
const stops: RGB[] = [
|
||
[22, 20, 60], // near-black indigo
|
||
[55, 48, 163], // deep indigo
|
||
[99, 102, 241], // bright indigo
|
||
[199, 210, 254], // lavender
|
||
];
|
||
const scaled = t * (stops.length - 1);
|
||
const lo = Math.min(Math.floor(scaled), stops.length - 2);
|
||
const frac = scaled - lo;
|
||
const [r1, g1, b1]: RGB = stops[lo] as RGB;
|
||
const [r2, g2, b2]: RGB = stops[lo + 1] as RGB;
|
||
const r = Math.round(r1 + (r2 - r1) * frac);
|
||
const g = Math.round(g1 + (g2 - g1) * frac);
|
||
const b = Math.round(b1 + (b2 - b1) * frac);
|
||
return `rgb(${r},${g},${b})`;
|
||
}
|
||
|
||
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 rgba(255,255,255,0.04)",
|
||
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 rgba(255,255,255,0.06)",
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
<span>{t("more")}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AnalyticsSparkline({
|
||
data,
|
||
color = "#6366f1",
|
||
}: {
|
||
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 = "#10b981",
|
||
}: {
|
||
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="#1e1e2e" 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>
|
||
);
|
||
}
|
||
|
||
function StatPill({
|
||
label,
|
||
value,
|
||
raw,
|
||
sub,
|
||
icon: Icon,
|
||
color = "text-accent",
|
||
loading = false,
|
||
}: {
|
||
label: string;
|
||
value: string | number;
|
||
raw?: string;
|
||
sub?: string;
|
||
icon: React.ElementType;
|
||
color?: string;
|
||
loading?: boolean;
|
||
}) {
|
||
return (
|
||
<div className="card p-5 flex flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">{label}</span>
|
||
<Icon className={`w-4 h-4 ${color}`} />
|
||
</div>
|
||
{loading ? (
|
||
<StatValueSkeleton />
|
||
) : (
|
||
<p className={`text-2xl font-bold ${color}`}>
|
||
{raw ? <Tip raw={raw}>{value}</Tip> : value}
|
||
</p>
|
||
)}
|
||
{loading ? (
|
||
<TextSkeleton width="w-20" />
|
||
) : (
|
||
sub && <p className="text-[11px] text-fg-muted">{sub}</p>
|
||
)}
|
||
</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" aria-label="Loading">
|
||
<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" aria-label="Loading analytics charts">
|
||
<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 [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||
const [loading, setLoading] = useState(true);
|
||
const [analyticsSubTab, setAnalyticsSubTab] = useState<
|
||
"tokens" | "cost" | "workflow" | "productivity"
|
||
>("cost");
|
||
// Global data scope; a change re-runs `load` (api injects the `sources` param).
|
||
const [scope] = useDataScope();
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const [result, cost] = await Promise.all([
|
||
api.analytics.get(),
|
||
api.pricing.totalCost().catch(() => null),
|
||
]);
|
||
setData(result);
|
||
setCostData(cost);
|
||
setLastUpdate(new Date());
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [scope]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
const interval = setInterval(load, 15000);
|
||
return () => clearInterval(interval);
|
||
}, [load]);
|
||
|
||
useEffect(() => {
|
||
return eventBus.subscribe((msg) => {
|
||
if (
|
||
msg.type === "session_created" ||
|
||
msg.type === "session_updated" ||
|
||
msg.type === "new_event" ||
|
||
msg.type === "agent_created" ||
|
||
isRemoteDataRefreshMessage(msg)
|
||
) {
|
||
load();
|
||
}
|
||
});
|
||
}, [load]);
|
||
|
||
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));
|
||
|
||
// 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 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];
|
||
const weekdayCosts = weekdayOrder.map((dow) => {
|
||
const label = new Intl.DateTimeFormat(locale, { weekday: "short" }).format(
|
||
new Date(Date.UTC(2026, 0, 4 + dow))
|
||
);
|
||
const cost = dailyCostLast30
|
||
.filter((day) => new Date(day.date + "T12:00:00").getDay() === dow)
|
||
.reduce((sum, day) => sum + day.cost, 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: "#60a5fa" },
|
||
{ label: t("common:token.output"), value: data?.tokens.total_output ?? 0, color: "#34d399" },
|
||
{
|
||
label: t("common:token.cacheRead"),
|
||
value: data?.tokens.total_cache_read ?? 0,
|
||
color: "#a78bfa",
|
||
},
|
||
{
|
||
label: t("common:token.cacheWrite"),
|
||
value: data?.tokens.total_cache_write ?? 0,
|
||
color: "#facc15",
|
||
},
|
||
].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;
|
||
|
||
const cacheHitPct =
|
||
totalTokens > 0 ? Math.round(((data?.tokens.total_cache_read ?? 0) / totalTokens) * 100) : 0;
|
||
|
||
const sessionOutcomeSegments = [
|
||
{
|
||
label: t("common:status.completed"),
|
||
value: data?.sessions_by_status?.completed ?? 0,
|
||
color: "#8b5cf6",
|
||
},
|
||
{
|
||
label: t("common:status.active"),
|
||
value: data?.sessions_by_status?.active ?? 0,
|
||
color: "#10b981",
|
||
},
|
||
{
|
||
label: t("common:status.error"),
|
||
value: data?.sessions_by_status?.error ?? 0,
|
||
color: "#ef4444",
|
||
},
|
||
{
|
||
label: t("common:status.abandoned"),
|
||
value: data?.sessions_by_status?.abandoned ?? 0,
|
||
color: "#f59e0b",
|
||
},
|
||
].filter((s) => s.value > 0);
|
||
|
||
const agentStatusSegments = [
|
||
{
|
||
label: t("common:status.completed"),
|
||
value: data?.agents_by_status?.completed ?? 0,
|
||
color: "#8b5cf6",
|
||
},
|
||
{
|
||
label: t("common:status.working"),
|
||
value: data?.agents_by_status?.working ?? 0,
|
||
color: "#10b981",
|
||
},
|
||
{
|
||
label: t("common:status.waiting"),
|
||
value: data?.agents_by_status?.waiting ?? 0,
|
||
color: "#eab308",
|
||
},
|
||
{
|
||
label: t("common:status.error"),
|
||
value: data?.agents_by_status?.error ?? 0,
|
||
color: "#ef4444",
|
||
},
|
||
].filter((s) => s.value > 0);
|
||
|
||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||
PreToolUse: "bg-status-success",
|
||
PostToolUse: "bg-blue-500",
|
||
Stop: "bg-violet-400",
|
||
SubagentStop: "bg-yellow-400",
|
||
Notification: "bg-orange-400",
|
||
};
|
||
|
||
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">
|
||
<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>
|
||
|
||
{/* Key stats */}
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||
<StatPill
|
||
label={t("totalSessions")}
|
||
value={data ? fmt(data.overview.total_sessions) : ""}
|
||
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
|
||
sub={data ? `${data.overview.active_sessions} ${t("common:active")}` : undefined}
|
||
icon={FolderOpen}
|
||
color="text-blue-500"
|
||
loading={!data}
|
||
/>
|
||
<StatPill
|
||
label={t("totalAgents")}
|
||
value={data ? fmt(data.overview.total_agents) : ""}
|
||
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
|
||
sub={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
|
||
icon={Bot}
|
||
color="text-status-success"
|
||
loading={!data}
|
||
/>
|
||
<StatPill
|
||
label={t("totalTokens")}
|
||
value={data ? fmt(totalTokens) : ""}
|
||
raw={data ? totalTokens.toLocaleString() : undefined}
|
||
sub={data ? `${cacheHitPct}${t("cacheHitRate")}` : undefined}
|
||
icon={Cpu}
|
||
color="text-violet-400"
|
||
loading={!data}
|
||
/>
|
||
<StatPill
|
||
label={t("totalCost")}
|
||
value={costData ? fmtCost(costData.total_cost) : ""}
|
||
raw={costData ? fmtCostFull(costData.total_cost) : undefined}
|
||
sub={
|
||
costData
|
||
? `${costData.breakdown.length} ${t("common:cost.model", { count: costData.breakdown.length })}`
|
||
: undefined
|
||
}
|
||
icon={DollarSign}
|
||
color="text-status-success"
|
||
loading={!costData}
|
||
/>
|
||
<StatPill
|
||
label={t("totalEvents")}
|
||
value={data ? fmt(data.overview.total_events) : ""}
|
||
raw={data ? data.overview.total_events.toLocaleString() : undefined}
|
||
sub={data ? `~${data.avg_events_per_session}${t("perSession")}` : undefined}
|
||
icon={Zap}
|
||
color="text-yellow-400"
|
||
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">
|
||
<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)}
|
||
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-blue-500",
|
||
},
|
||
{
|
||
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-violet-400",
|
||
},
|
||
{
|
||
label: t("common:token.cacheWrite"),
|
||
value: data?.tokens.total_cache_write ?? 0,
|
||
color: "bg-yellow-400",
|
||
},
|
||
].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-violet-400 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-blue-500",
|
||
},
|
||
{
|
||
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-violet-400",
|
||
},
|
||
{
|
||
label: t("common:token.cacheWrite"),
|
||
value: data?.tokens.total_cache_write ?? 0,
|
||
color: "text-yellow-400",
|
||
},
|
||
{ 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:
|
||
["#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#ec4899"][
|
||
i % 6
|
||
] ?? "#6b7280",
|
||
}))}
|
||
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>
|
||
|
||
{/* 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("last30Days")}</p>
|
||
<div className="space-y-3">
|
||
{weekdayCosts.map(({ label, cost }) => (
|
||
<CostBarRow
|
||
key={label}
|
||
label={label}
|
||
cost={cost}
|
||
max={maxWeekdayCost}
|
||
color="bg-cyan-400"
|
||
/>
|
||
))}
|
||
</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-cyan-400 font-mono">
|
||
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{analyticsSubTab === "workflow" && (
|
||
<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-violet-400"
|
||
/>
|
||
))}
|
||
</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>
|
||
)}
|
||
|
||
{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-yellow-400"
|
||
/>
|
||
))}
|
||
</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={dailySessionsLocal.slice(-30)} color="#6366f1" />
|
||
<div className="mt-4 space-y-2">
|
||
{dailySessionsLocal
|
||
.slice(-7)
|
||
.reverse()
|
||
.map(({ date, count }) => {
|
||
const maxD = Math.max(
|
||
...(dailySessionsLocal.length > 0
|
||
? dailySessionsLocal
|
||
: [{ count: 1 }]
|
||
).map((d) => d.count)
|
||
);
|
||
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>
|
||
);
|
||
}
|
||
|
||
function SystemHealthTab() {
|
||
const [info, setInfo] = useState<SystemInfo | null>(null);
|
||
const [workflow, setWorkflow] = useState<WorkflowData | null>(null);
|
||
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
const [infoRes, workflowRes] = await Promise.all([api.settings.info(), api.workflows.get()]);
|
||
setInfo(infoRes as any);
|
||
setWorkflow(workflowRes);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
const int = setInterval(loadData, 30000); // 30s is sufficient for health metrics
|
||
return () => clearInterval(int);
|
||
}, [loadData]);
|
||
|
||
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;
|
||
const heapUsedPct =
|
||
info.server.memory.heapTotal > 0
|
||
? (info.server.memory.heapUsed / info.server.memory.heapTotal) * 100
|
||
: 0;
|
||
|
||
return {
|
||
totalEntries,
|
||
sessPct,
|
||
agentPct,
|
||
eventPct,
|
||
modelStats,
|
||
totalTokens,
|
||
memUsedPct,
|
||
heapUsedPct,
|
||
};
|
||
}, [info, workflow]);
|
||
|
||
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">Runtime</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-fg-muted">
|
||
{info.server.cpus} cores · {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">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">CPU (1/5/15m)</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">Node RSS</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={`Host Memory: ${memUsedPct.toFixed(1)}% used\n${formatBytes(info.server.total_mem - info.server.free_mem)} / ${formatBytes(info.server.total_mem)}`}
|
||
>
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between text-[10px]">
|
||
<span className="text-fg-muted">Host Memory</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={`V8 Heap: ${heapUsedPct.toFixed(1)}% used\n${formatBytes(info.server.memory.heapUsed)} / ${formatBytes(info.server.memory.heapTotal)}\nExternal: ${formatBytes(info.server.memory.external)}`}
|
||
>
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between text-[10px]">
|
||
<span className="text-fg-muted">V8 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-blue-600"}`}
|
||
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-blue-500" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">Storage</span>
|
||
</div>
|
||
<Tip
|
||
raw={`Write velocity (events):\n5 min: ${info.db.load_stats?.m5 ?? 0}\n15 min: ${info.db.load_stats?.m15 ?? 0}\n1 hr: ${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">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={`Record Distribution\n\nSessions: ${(info.db.counts?.sessions ?? 0).toLocaleString()} (${sessPct.toFixed(1)}%)\nAgents: ${(info.db.counts?.agents ?? 0).toLocaleString()} (${agentPct.toFixed(1)}%)\nEvents: ${(info.db.counts?.events ?? 0).toLocaleString()} (${eventPct.toFixed(1)}%)\n\nTotal: ${totalEntries.toLocaleString()} records`}
|
||
>
|
||
<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="#1e1e2e" strokeWidth="14" />
|
||
{(() => {
|
||
const r = 38,
|
||
cx = 48,
|
||
cy = 48,
|
||
circumference = 2 * Math.PI * r;
|
||
const segments = [
|
||
{ pct: sessPct, color: "#60a5fa" },
|
||
{ pct: agentPct, color: "#8b5cf6" },
|
||
{ pct: eventPct, color: "#34d399" },
|
||
];
|
||
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"
|
||
>
|
||
{totalEntries > 999 ? `${(totalEntries / 1000).toFixed(1)}K` : totalEntries}
|
||
</text>
|
||
<text
|
||
x="48"
|
||
y="60"
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
className="fill-fg-muted"
|
||
fontSize="8"
|
||
>
|
||
total
|
||
</text>
|
||
</svg>
|
||
</Tip>
|
||
<div className="space-y-2.5">
|
||
{[
|
||
{
|
||
label: "Sessions",
|
||
value: info.db.counts?.sessions ?? 0,
|
||
color: "#60a5fa",
|
||
pct: sessPct,
|
||
},
|
||
{
|
||
label: "Agents",
|
||
value: info.db.counts?.agents ?? 0,
|
||
color: "#8b5cf6",
|
||
pct: agentPct,
|
||
},
|
||
{
|
||
label: "Events",
|
||
value: info.db.counts?.events ?? 0,
|
||
color: "#34d399",
|
||
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">Health Score</span>
|
||
</div>
|
||
<Tip
|
||
raw={`Composite Index formula:\n0.4 × Success Rate (${successRate.toFixed(1)}%)\n+ 0.25 × Cache Hit (${cacheHitRate.toFixed(1)}%)\n+ 0.25 × (100 − Error Rate) (${(100 - errorRate).toFixed(1)}%)\n+ 0.10 × (100 − Heap%) (${(100 - heapUsedPct).toFixed(1)}%)\n= ${healthScore.toFixed(1)}`}
|
||
>
|
||
<span className="text-[10px] text-fg-muted cursor-help border-b border-dashed border-border-light">
|
||
ⓘ Formula
|
||
</span>
|
||
</Tip>
|
||
</div>
|
||
|
||
{/* Ring gauge - centered */}
|
||
<div className="flex items-center justify-center flex-1">
|
||
<Tip
|
||
raw={`Score: ${healthScore.toFixed(1)} / 100\n\n• Success Rate (40%): ${successRate.toFixed(1)}%\n• Cache Hit (25%): ${cacheHitRate.toFixed(1)}%\n• Error Avoidance (25%): ${(100 - errorRate).toFixed(1)}%\n• Memory Health (10%): ${(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="#1e1e2e" strokeWidth="10" />
|
||
<circle
|
||
cx="60"
|
||
cy="60"
|
||
r="48"
|
||
fill="none"
|
||
stroke={healthScore >= 90 ? "#34d399" : healthScore >= 70 ? "#fbbf24" : "#f87171"}
|
||
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={`Cache Hit Rate: ${cacheHitRate.toFixed(1)}%\nHits: ${info.transcript_cache?.hits ?? 0}\nMisses: ${info.transcript_cache?.misses ?? 0}`}
|
||
>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">Cache</p>
|
||
<p className="text-xs font-mono font-bold text-blue-500">
|
||
{cacheHitRate.toFixed(0)}%
|
||
</p>
|
||
</div>
|
||
</Tip>
|
||
<Tip
|
||
block
|
||
raw={`Error Rate: ${errorRate.toFixed(2)}%\n<5% = healthy, 5-15% = warning, >15% = critical`}
|
||
>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">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={`Transcript compactions: ${workflow.compaction?.totalCompactions ?? 0}\nReduces context window by summarizing turns.`}
|
||
>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">Compact</p>
|
||
<p className="text-xs font-mono font-bold text-violet-400">
|
||
{workflow.compaction?.totalCompactions ?? 0}
|
||
</p>
|
||
</div>
|
||
</Tip>
|
||
<Tip
|
||
block
|
||
raw={`Tokens recovered: ${(workflow.compaction?.tokensRecovered ?? 0).toLocaleString()}\nFreed by compaction.`}
|
||
>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">Saved</p>
|
||
<p className="text-xs font-mono font-bold text-status-success">
|
||
{((workflow.compaction?.tokensRecovered ?? 0) / 1000).toFixed(1)}K
|
||
</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-blue-500" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">Token Usage</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-fg-muted">
|
||
{(totalTokens / 1000).toFixed(1)}K total
|
||
</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-blue-500",
|
||
"bg-violet-400",
|
||
"bg-status-success",
|
||
"bg-status-warning",
|
||
"bg-pink-400",
|
||
"bg-cyan-400",
|
||
];
|
||
return (
|
||
<Tip
|
||
block
|
||
key={i}
|
||
raw={`${formatModelName(m.model) ?? m.model}\nInput: ${m.input_tokens.toLocaleString()}\nOutput: ${m.output_tokens.toLocaleString()}\nCache Read: ${m.cache_read_tokens.toLocaleString()}\nShare: ${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">No model data</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-violet-400" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">Concurrency</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-fg-muted">{lanes.length} intervals</span>
|
||
</div>
|
||
|
||
{/* Sparkline-style bar chart matching Analytics sparkline */}
|
||
<Tip
|
||
block
|
||
raw={`Peak: ${maxLaneCount} parallel sessions\nActive intervals: ${lanes.filter((l) => l.count > 0).length}\nAvg: ${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
|
||
? "#f87171"
|
||
: lane.count > 2
|
||
? "#fbbf24"
|
||
: lane.count > 0
|
||
? "#34d399"
|
||
: "#1e1e2e";
|
||
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">
|
||
No concurrency data
|
||
</p>
|
||
)}
|
||
</div>
|
||
</Tip>
|
||
|
||
<div className="grid grid-cols-3 gap-3 border-t border-border/40 pt-3">
|
||
<Tip block raw={`Peak: ${maxLaneCount} sessions running simultaneously.`}>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">Peak</p>
|
||
<p className="text-sm font-mono font-bold text-fg-secondary">{maxLaneCount}</p>
|
||
</div>
|
||
</Tip>
|
||
<Tip
|
||
block
|
||
raw={`${lanes.filter((l) => l.count > 0).length} of ${lanes.length} intervals have active sessions.`}
|
||
>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">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={`Average concurrency across all intervals.`}>
|
||
<div className="text-center cursor-default">
|
||
<p className="text-[9px] text-fg-muted uppercase">Avg</p>
|
||
<p className="text-sm font-mono font-bold text-blue-500">
|
||
{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">Tool Usage</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-fg-muted">top {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-blue-500",
|
||
"bg-status-success",
|
||
"bg-violet-400",
|
||
"bg-pink-400",
|
||
"bg-cyan-400",
|
||
"bg-status-danger",
|
||
"bg-indigo-400",
|
||
];
|
||
return (
|
||
<Tip
|
||
block
|
||
key={i}
|
||
raw={`${tool.tool_name}: ${tool.count.toLocaleString()} invocations`}
|
||
>
|
||
<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">
|
||
{tool.count > 999 ? `${(tool.count / 1000).toFixed(1)}K` : tool.count}
|
||
</span>
|
||
</div>
|
||
</Tip>
|
||
);
|
||
})}
|
||
{topTools.length === 0 && (
|
||
<p className="text-xs text-fg-muted text-center py-6">No tool data yet</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-violet-400" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">
|
||
Subagent Effectiveness
|
||
</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={`${item.subagent_type || "default"}\nSuccess: ${item.successRate.toFixed(1)}%\nTotal: ${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 || "default"}
|
||
</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">No subagent data yet</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">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 ? "Active" : "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={`Path: ${cwd}\nStatus: ${active ? "Connected" : "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">No project hooks registered</p>
|
||
</div>
|
||
)}
|
||
|
||
<Tip
|
||
block
|
||
raw={`WebSocket connections: ${info.server.ws_connections}\nProtocol: RFC 6455`}
|
||
>
|
||
<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">WebSocket Active</p>
|
||
<p className="text-[10px] text-fg-muted">
|
||
{info.server.ws_connections} connection
|
||
{info.server.ws_connections !== 1 ? "s" : ""}
|
||
</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-cyan-400" />
|
||
<span className="text-xs text-fg-muted uppercase tracking-wider">Platform</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-fg-muted">{info.server.node_version}</span>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{[
|
||
{
|
||
label: "Journal Mode",
|
||
value: info.db.pragmas?.journal_mode?.toUpperCase() || "WAL",
|
||
},
|
||
{
|
||
label: "Synchronous",
|
||
value:
|
||
info.db.pragmas?.synchronous === 2
|
||
? "FULL"
|
||
: info.db.pragmas?.synchronous === 1
|
||
? "NORMAL"
|
||
: "OFF",
|
||
},
|
||
{ label: "Auto-Vacuum", value: info.db.pragmas?.auto_vacuum > 0 ? "FULL" : "OFF" },
|
||
{ label: "Foreign Keys", value: info.db.pragmas?.foreign_keys ? "ON" : "OFF" },
|
||
{ label: "Busy Timeout", value: `${info.db.pragmas?.busy_timeout || 5000}ms` },
|
||
{ label: "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>
|
||
);
|
||
}
|
||
|
||
export function Dashboard() {
|
||
const navigate = useNavigate();
|
||
const { t } = useTranslation("dashboard");
|
||
|
||
// Persistent Tab State
|
||
const [activeTab, setActiveTab] = useState<"monitor" | "analytics" | "health">(() => {
|
||
return (
|
||
(localStorage.getItem("dashboard_tab") as "monitor" | "analytics" | "health") || "monitor"
|
||
);
|
||
});
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem("dashboard_tab", activeTab);
|
||
}, [activeTab]);
|
||
|
||
const [stats, setStats] = useState<Stats | null>(null);
|
||
const [activeAgents, setActiveAgents] = useState<Agent[]>([]);
|
||
const [recentEvents, setRecentEvents] = useState<DashboardEvent[]>([]);
|
||
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());
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Dynamic item counts based on available container height
|
||
const agentsContainerRef = useRef<HTMLDivElement>(null);
|
||
const activityContainerRef = useRef<HTMLDivElement>(null);
|
||
const [visibleAgentCount, setVisibleAgentCount] = useState(5);
|
||
const [visibleActivityCount, setVisibleActivityCount] = useState(8);
|
||
|
||
useEffect(() => {
|
||
const AGENT_ROW_H = 56; // ~px per agent card row
|
||
const ACTIVITY_ROW_H = 44; // ~px per activity row
|
||
const HEADER_H = 40; // section header height
|
||
|
||
function recalc() {
|
||
if (agentsContainerRef.current) {
|
||
const h = agentsContainerRef.current.clientHeight;
|
||
setVisibleAgentCount(Math.max(3, Math.floor((h - HEADER_H) / AGENT_ROW_H)));
|
||
}
|
||
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 (agentsContainerRef.current) ro.observe(agentsContainerRef.current);
|
||
if (activityContainerRef.current) ro.observe(activityContainerRef.current);
|
||
recalc();
|
||
|
||
return () => ro.disconnect();
|
||
}, [activeTab]);
|
||
|
||
// Global data scope; a change re-runs `load` (the api layer injects the
|
||
// matching `sources` param into the calls below).
|
||
const [scope] = useDataScope();
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const [statsRes, workingRes, waitingRes, eventsRes, costRes, sessionsRes] = await Promise.all(
|
||
[
|
||
api.stats.get(),
|
||
api.agents.list({ status: "working", limit: 20 }),
|
||
api.agents.list({ status: "waiting", limit: 20 }),
|
||
api.events.list({ limit: 30 }),
|
||
api.pricing.totalCost(),
|
||
api.sessions.list({ status: "active", limit: 100 }),
|
||
]
|
||
);
|
||
setStats(statsRes);
|
||
const active = [...workingRes.agents, ...waitingRes.agents];
|
||
setActiveAgents(active);
|
||
setRecentEvents(eventsRes.events);
|
||
setTotalCost(costRes.total_cost);
|
||
setSessionsById(new Map(sessionsRes.sessions.map((s) => [s.id, s])));
|
||
setError(null);
|
||
|
||
// Fetch all subagents for each active main agent's session
|
||
const activeSessionIds = [
|
||
...new Set(active.filter((a) => a.type === "main").map((a) => a.session_id)),
|
||
];
|
||
const subagentResults = await Promise.all(
|
||
activeSessionIds.map((sid) => api.agents.list({ session_id: sid, limit: 100 }))
|
||
);
|
||
const subs = subagentResults.flatMap((r) => r.agents).filter((a) => a.type === "subagent");
|
||
setAllSubagents(subs);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : t("failedLoad"));
|
||
}
|
||
}, [t, scope]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
const interval = setInterval(load, 10000);
|
||
return () => clearInterval(interval);
|
||
}, [load]);
|
||
|
||
// 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]);
|
||
|
||
useEffect(() => {
|
||
const debounceRef = { timer: null as ReturnType<typeof setTimeout> | null };
|
||
return eventBus.subscribe((msg: WSMessage) => {
|
||
if (
|
||
msg.type === "agent_created" ||
|
||
msg.type === "agent_updated" ||
|
||
msg.type === "session_created" ||
|
||
msg.type === "session_updated" ||
|
||
isRemoteDataRefreshMessage(msg)
|
||
) {
|
||
// Debounce rapid-fire updates (e.g., 5 agents created in 100ms)
|
||
if (debounceRef.timer) clearTimeout(debounceRef.timer);
|
||
debounceRef.timer = setTimeout(load, 300);
|
||
}
|
||
if (msg.type === "new_event") {
|
||
setRecentEvents((prev) => {
|
||
const newEvent = msg.data as DashboardEvent;
|
||
// 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, 14)];
|
||
});
|
||
}
|
||
});
|
||
}, [load]);
|
||
|
||
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]);
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="text-center py-20">
|
||
<p className="text-status-danger mb-2">{t("failedConnect")}</p>
|
||
<p className="text-sm text-fg-muted">{error}</p>
|
||
<button onClick={load} className="btn-primary mt-4">
|
||
{t("common:retry")}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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">
|
||
{/* Tabs */}
|
||
<div className="flex bg-surface-2 rounded-lg p-0.5 border border-border">
|
||
<button
|
||
onClick={() => setActiveTab("monitor")}
|
||
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
|
||
activeTab === "monitor"
|
||
? "bg-accent/15 text-accent shadow-sm"
|
||
: "text-fg-muted hover:text-fg-secondary"
|
||
}`}
|
||
>
|
||
<Activity className="w-3.5 h-3.5" /> Monitor
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab("analytics")}
|
||
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
|
||
activeTab === "analytics"
|
||
? "bg-accent/15 text-accent shadow-sm"
|
||
: "text-fg-muted hover:text-fg-secondary"
|
||
}`}
|
||
>
|
||
<BarChart3 className="w-3.5 h-3.5" /> Analytics
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab("health")}
|
||
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2 ${
|
||
activeTab === "health"
|
||
? "bg-accent/15 text-accent shadow-sm"
|
||
: "text-fg-muted hover:text-fg-secondary"
|
||
}`}
|
||
>
|
||
<Server className="w-3.5 h-3.5" /> Health
|
||
</button>
|
||
</div>
|
||
<button onClick={load} className="btn-ghost flex-shrink-0">
|
||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{activeTab === "monitor" ? (
|
||
<div className="flex-1 flex flex-col gap-8 min-h-0">
|
||
{/* 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")}
|
||
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-violet-400"
|
||
trend={stats ? `${allSubagents.length}${t("totalTrend")}` : undefined}
|
||
loading={!stats}
|
||
/>
|
||
<StatCard
|
||
label={t("eventsToday")}
|
||
value={stats ? fmt(stats.events_today) : ""}
|
||
raw={stats ? stats.events_today.toLocaleString() : undefined}
|
||
icon={Zap}
|
||
accentColor="text-yellow-400"
|
||
loading={!stats}
|
||
/>
|
||
<StatCard
|
||
label={t("totalEvents")}
|
||
value={stats ? fmt(stats.total_events) : ""}
|
||
raw={stats ? stats.total_events.toLocaleString() : undefined}
|
||
icon={Activity}
|
||
accentColor="text-violet-400"
|
||
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 ? "Collapse subagents" : "Expand subagents"}
|
||
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-violet-400/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-violet-400 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-violet-500/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-violet-400 hover:text-violet-300 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.
|
||
const visibleMains = activeAgents
|
||
.filter((a) => a.type === "main")
|
||
.slice(0, visibleAgentCount);
|
||
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 */}
|
||
<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>
|
||
<button onClick={() => navigate("/activity")} className="btn-ghost text-xs">
|
||
{t("viewAll")} <ArrowRight className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
{recentEvents.length === 0 ? (
|
||
<EmptyState
|
||
icon={Activity}
|
||
title={t("noActivity")}
|
||
description={t("noActivityDesc")}
|
||
/>
|
||
) : (
|
||
<div className="card divide-y divide-border">
|
||
{recentEvents.slice(0, visibleActivityCount).map((event, i) => (
|
||
<div
|
||
key={event.id ?? i}
|
||
className="px-4 py-3 flex items-center gap-3 hover:bg-surface-4 transition-colors cursor-pointer"
|
||
onClick={() => navigate(`/sessions/${event.session_id}`)}
|
||
>
|
||
<AgentStatusBadge
|
||
status={
|
||
event.event_type === "Stop"
|
||
? event.summary?.toLowerCase().includes("error")
|
||
? "error"
|
||
: "completed"
|
||
: event.event_type === "APIError" ||
|
||
event.summary?.toLowerCase().includes("error")
|
||
? "error"
|
||
: event.event_type === "PreToolUse"
|
||
? "working"
|
||
: "waiting"
|
||
}
|
||
/>
|
||
<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 = 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>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : activeTab === "analytics" ? (
|
||
<AnalyticsTab />
|
||
) : (
|
||
<SystemHealthTab />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|