From 8ce20e8163bfe115591b3cebeaf22d43f9bc2da5 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 11:45:39 +0700 Subject: [PATCH] feat(dashboard): merge Analytics into Dashboard, remove Kanban Board 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. --- ARCHITECTURE.md | 39 +- README.md | 2 +- client/README.md | 24 +- client/src/App.tsx | 6 - client/src/components/DocumentTitle.tsx | 4 - client/src/components/Sidebar.tsx | 3 - .../src/components/__tests__/Sidebar.test.tsx | 3 - client/src/pages/Analytics.tsx | 1477 ---------- client/src/pages/Dashboard.tsx | 1400 ++++++++- client/src/pages/KanbanBoard.tsx | 525 ---- .../screens.snapshot.test.tsx.snap | 2516 +---------------- .../pages/__tests__/screens.snapshot.test.tsx | 8 - docs/API.md | 2 +- 13 files changed, 1445 insertions(+), 4564 deletions(-) delete mode 100644 client/src/pages/Analytics.tsx delete mode 100644 client/src/pages/KanbanBoard.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bfd8d0d7..a8c7bc92 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -428,20 +428,18 @@ graph TD APP["App.tsx
Router + WebSocket"] LAYOUT["Layout.tsx
Sidebar + Outlet"] SIDEBAR["Sidebar.tsx
Nav (scroll-bounded with overflow
chevrons) + Connection Status"] - DASH["Dashboard.tsx"] - KANBAN["KanbanBoard.tsx"] + DASH["Dashboard.tsx
(Monitor / Analytics / Health tabs)"] SESS["Sessions.tsx"] DETAIL["SessionDetail.tsx"] ACTIVITY["ActivityFeed.tsx"] SETTINGS_P["Settings.tsx"] - ANALYTICS_P["Analytics.tsx"] WORKFLOWS_P["Workflows.tsx"] NOTFOUND["NotFound.tsx"] APP --> LAYOUT LAYOUT --> SIDEBAR - LAYOUT --> DASH & KANBAN & SESS & DETAIL & ACTIVITY & ANALYTICS_P & WORKFLOWS_P & SETTINGS_P & NOTFOUND + LAYOUT --> DASH & SESS & DETAIL & ACTIVITY & WORKFLOWS_P & SETTINGS_P & NOTFOUND DASH --> SC1["StatCard x6
(sessions/agents/subagents/
events today/total events/cost)
3-column grid"] DASH --> AC1["AgentCard[]
with collapsible subagent hierarchy"] @@ -547,25 +545,23 @@ graph TD EB --> TYPES subgraph Pages - D[Dashboard] - K[KanbanBoard] + D["Dashboard
(Monitor / Analytics / Health)"] S[Sessions] SD[SessionDetail] AF[ActivityFeed] - AN[Analytics] WF[Workflows] SET[Settings] NF[NotFound] end - APP --> D & K & S & SD & AF & AN & WF - D & K & S & SD & AF & AN & WF --> API - D & K & S & SD & AF & AN & WF --> EB - D & K & S & SD & AF & AN & WF --> FMT + APP --> D & S & SD & AF & WF + D & S & SD & AF & WF --> API + D & S & SD & AF & WF --> EB + D & S & SD & AF & WF --> FMT SET --> API SET --> EB SET --> FMT - D & K & S & AF & AN & WF --> DS + D & S & AF & WF --> DS DS --> API API --> TYPES @@ -580,7 +576,6 @@ graph TD end D --> STC & AGC & STB - K --> AGC S --> STB & ES SD --> AGC & STB AF --> STB & ES @@ -598,12 +593,10 @@ graph TD ```mermaid graph LR - ROOT["/ (index)"] --> DASH[Dashboard] - KANBAN_R["/kanban"] --> KANBAN[KanbanBoard] + ROOT["/ (index)"] --> DASH["Dashboard
(Monitor / Analytics / Health)"] SESS_R["/sessions"] --> SESS[Sessions] DETAIL_R["/sessions/:id"] --> DETAIL[SessionDetail] ACT_R["/activity"] --> ACT[ActivityFeed] - AN_R["/analytics"] --> AN[Analytics] WF_R["/workflows"] --> WF[Workflows] CC_R["/cc-config"] --> CC[CcConfig] LANES_R["/lanes"] -->|redirect| RUN_R["/run"] @@ -616,12 +609,10 @@ graph LR | Route | Page | Data Sources | | --------------- | ------------- | ------------------------------------------------------ | -| `/` | Dashboard | Two tabs (Monitor / Health). Monitor: `GET /api/stats`, `GET /api/agents`, `GET /api/events`, `GET /api/agents?session_id={sid}` (subagent hierarchy), dynamic item counts via `ResizeObserver`. Health: `GET /api/settings/info` + `GET /api/workflows` (5 s auto-refresh) — composite health score, storage donut, cache/error/success gauges, tool invocation bars, subagent effectiveness, model token distribution, compaction stats | -| `/kanban` | KanbanBoard | View toggle persisted in `localStorage`. Agents view: `GET /api/agents?status={each}` per-status (default 10000 cap). Sessions view: `GET /api/sessions?status={each}&limit=10000` per-status. Each column then paginates client-side at `COLUMN_PAGE_SIZE=10`; the WS subscription scopes to the active view. | +| `/` | Dashboard | Three tabs (Monitor / Analytics / Health). Monitor: `GET /api/stats`, `GET /api/agents`, `GET /api/events`, `GET /api/agents?session_id={sid}` (subagent hierarchy), dynamic item counts via `ResizeObserver`. Analytics (merged from the former standalone `/analytics` page): `GET /api/analytics` + `GET /api/pricing/cost` — event heatmap, cost/token/workflow/productivity sub-tabs. Health: `GET /api/settings/info` + `GET /api/workflows` (5 s auto-refresh) — composite health score, storage donut, cache/error/success gauges, tool invocation bars, subagent effectiveness, model token distribution, compaction stats | | `/sessions` | Sessions | `GET /api/sessions?status=&q=&limit=PAGE_SIZE&offset=page*PAGE_SIZE` — true server-side pagination. The search box passes `q` to the server (300 ms debounced). Response carries `total` for the paginator UI. Cost computation runs server-side over the visible page only. Polls `/api/run` (and listens for `run_status`) to badge any row whose session is currently being driven from `/run` with a clickable green **▶ Run** pill | -| `/sessions/:id` | SessionDetail | `GET /api/sessions/:id` (agents + events), `GET /api/sessions/:id/stats` (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on `new_event`/`agent_*`/`session_updated`), `GET /api/sessions/:id/transcripts` (Conversation tab transcript list), `GET /api/sessions/:id/transcript` (cursor-paginated message stream, including inline `session_event` rename markers; optional `run_id` resolves a workflow inner agent's nested transcript). Probes `/api/run` (and listens for `run_status`) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the `awaiting_reason` (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting `StatusBadge` across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Kanban/Dashboard) stay tooltip-only | +| `/sessions/:id` | SessionDetail | `GET /api/sessions/:id` (agents + events), `GET /api/sessions/:id/stats` (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on `new_event`/`agent_*`/`session_updated`), `GET /api/sessions/:id/transcripts` (Conversation tab transcript list), `GET /api/sessions/:id/transcript` (cursor-paginated message stream, including inline `session_event` rename markers; optional `run_id` resolves a workflow inner agent's nested transcript). Probes `/api/run` (and listens for `run_status`) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the `awaiting_reason` (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting `StatusBadge` across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Dashboard) stay tooltip-only | | `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` | -| `/analytics` | Analytics | `GET /api/analytics` | | `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) | | `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title | | `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title | @@ -1852,19 +1843,17 @@ graph TD end subgraph "Page State" - US1["useState
Dashboard"] - US2["useState
KanbanBoard"] + US1["useState
Dashboard
(Monitor/Analytics/Health)"] US3["useState
Sessions"] US4["useState
SessionDetail"] US5["useState
ActivityFeed"] - US6["useState
Analytics"] US8["useState
Workflows"] US7["useState
Settings"] end - REST --> US1 & US2 & US3 & US4 & US5 & US6 & US8 & US7 + REST --> US1 & US3 & US4 & US5 & US8 & US7 WSM --> EB - EB --> US1 & US2 & US3 & US4 & US5 & US6 & US8 & US7 + EB --> US1 & US3 & US4 & US5 & US8 & US7 EB --> NOTIF_H EB --> SAGI_H LS --> NOTIF_H diff --git a/README.md b/README.md index 17318724..51edc81c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Internal build — all rights reserved. rendered with xterm.js) attached to a lane's directory — the exact TUI you'd see locally, fully interactive, resumable, and attachable from a real terminal too via `ccam lanes shell`. -- **Analytics, alerts, Kanban and a workflow view**, plus an MCP server and a CLI. +- **Analytics (in-dashboard tab), alerts, and a workflow view**, plus an MCP server and a CLI. ## Requirements diff --git a/client/README.md b/client/README.md index a16e7972..93c6d930 100644 --- a/client/README.md +++ b/client/README.md @@ -67,12 +67,10 @@ graph TB Layout[Layout Component] subgraph "Pages" - Home[Dashboard] - Kanban[KanbanBoard] + Home[Dashboard
Monitor / Analytics / Health tabs] Sessions[Sessions] Detail[SessionDetail] Feed[ActivityFeed] - Analytics[Analytics] Workflows[Workflows] Settings[Settings] end @@ -100,7 +98,7 @@ graph TB end Router --> Layout - Layout --> Home & Kanban & Sessions & Detail & Feed & Analytics & Workflows & Settings + Layout --> Home & Sessions & Detail & Feed & Workflows & Settings Home & Detail --> AgentCard & StatCard & StatusBadge Feed --> EventDetail API --> REST @@ -183,12 +181,10 @@ client/ │ │ └── workflows/ # D3.js workflow visualization components (12 files) │ │ │ ├── pages/ # Route pages -│ │ ├── Dashboard.tsx -│ │ ├── KanbanBoard.tsx +│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs │ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID │ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) │ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates -│ │ ├── Analytics.tsx │ │ ├── Workflows.tsx │ │ ├── Settings.tsx │ │ └── NotFound.tsx @@ -230,17 +226,15 @@ graph TB App[App.tsx
Router + WS + Notifications] Layout[Layout.tsx
Sidebar + Outlet] - Dashboard[Dashboard
Monitor tab: stats + agents + events
Health tab: SystemHealthTab] - Kanban[KanbanBoard
4-column agent board] + Dashboard[Dashboard
Monitor tab: stats + agents + events
Analytics tab: tokens + heatmap + trends
Health tab: SystemHealthTab] Sessions[Sessions
filterable table] Detail[SessionDetail
agent hierarchy + timeline] Feed[ActivityFeed
streaming event log] - Analytics[Analytics
tokens + heatmap + trends] Workflows[Workflows
D3.js visualizations] Settings[Settings
pricing + notifications + hooks] App --> Layout - Layout --> Dashboard & Kanban & Sessions & Detail & Feed & Analytics & Workflows & Settings + Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings Dashboard --> StatCard[StatCard × 6] Dashboard --> AgentCard[AgentCard × N] @@ -450,18 +444,16 @@ useEffect(() => { graph TB Root["/"] Dashboard["/ (Dashboard)"] - Kanban["/kanban"] Sessions["/sessions"] Detail["/sessions/:id"] Feed["/activity"] - Analytics["/analytics"] Workflows["/workflows"] CcConfig["/cc-config"] Run["/run"] Settings["/settings"] NF["/* (NotFound)"] - Root --> Dashboard & Kanban & Sessions & Detail & Feed & Analytics & Workflows & CcConfig & Run & Settings & NF + Root --> Dashboard & Sessions & Detail & Feed & Workflows & CcConfig & Run & Settings & NF style Dashboard fill:#3B82F6 style Detail fill:#3B82F6 @@ -480,11 +472,9 @@ function App() { }> } /> - } /> } /> } /> } /> - } /> } /> } /> } /> @@ -883,7 +873,7 @@ test('renders session title and cost', () => { ### Snapshot Testing `pages/__tests__/screens.snapshot.test.tsx` renders **every routed screen** -(Dashboard, Kanban, Sessions, Session detail, Activity feed, Analytics, +(Dashboard, Sessions, Session detail, Activity feed, Workflows, Claude Config, Run, Settings, Not found) and asserts each against a committed snapshot in `pages/__tests__/__snapshots__/`. These are structural regression guards — they catch unintended changes to layout, markup, or diff --git a/client/src/App.tsx b/client/src/App.tsx index fc4012d5..d867c8e3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -39,11 +39,9 @@ * - `./components/Layout` * - `./components/SplashScreen` * - `./pages/Dashboard` - * - `./pages/KanbanBoard` * - `./pages/Sessions` * - `./pages/SessionDetail` * - `./pages/ActivityFeed` - * - `./pages/Analytics` * - `./pages/Workflows` * - `./pages/Settings` * - `./pages/CcConfig` @@ -66,11 +64,9 @@ import { Layout } from "./components/Layout"; import { DocumentTitle } from "./components/DocumentTitle"; import { SplashScreen } from "./components/SplashScreen"; import { Dashboard } from "./pages/Dashboard"; -import { KanbanBoard } from "./pages/KanbanBoard"; import { Sessions } from "./pages/Sessions"; import { SessionDetail } from "./pages/SessionDetail"; import { ActivityFeed } from "./pages/ActivityFeed"; -import { Analytics } from "./pages/Analytics"; import { Workflows } from "./pages/Workflows"; import { Settings } from "./pages/Settings"; import { CcConfig } from "./pages/CcConfig"; @@ -101,11 +97,9 @@ export default function App() { }> } /> - } /> } /> } /> } /> - } /> } /> } /> } /> diff --git a/client/src/components/DocumentTitle.tsx b/client/src/components/DocumentTitle.tsx index 927daff1..841dafc9 100644 --- a/client/src/components/DocumentTitle.tsx +++ b/client/src/components/DocumentTitle.tsx @@ -24,12 +24,8 @@ export function DocumentTitle() { title = `${t("sessions")} · ${sessionId.slice(0, 8)}`; } else if (path.startsWith("/sessions")) { title = t("sessions"); - } else if (path.startsWith("/kanban")) { - title = t("agentBoard"); } else if (path.startsWith("/activity")) { title = t("activityFeed"); - } else if (path.startsWith("/analytics")) { - title = t("analytics"); } else if (path.startsWith("/workflows")) { title = t("workflows"); } else if (path.startsWith("/cc-config")) { diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 9fe487fa..121a7c9d 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -54,7 +54,6 @@ import { NavLink } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { LayoutDashboard, - Columns3, FolderOpen, Activity, BarChart3, @@ -89,10 +88,8 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload { const NAV_KEYS = [ { to: "/", icon: LayoutDashboard, key: "nav:dashboard" }, - { to: "/kanban", icon: Columns3, key: "nav:agentBoard" }, { to: "/sessions", icon: FolderOpen, key: "nav:sessions" }, { to: "/activity", icon: Activity, key: "nav:activityFeed" }, - { to: "/analytics", icon: BarChart3, key: "nav:analytics" }, { to: "/workflows", icon: Workflow, key: "nav:workflows" }, { to: "/cc-config", icon: Boxes, key: "nav:ccConfig" }, { to: "/run", icon: Play, key: "nav:workspace" }, diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 9ac0a6a8..9e21f067 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -32,7 +32,6 @@ describe("Sidebar", () => { it("should render all navigation links", () => { renderSidebar(true); expect(screen.getByText("Dashboard")).toBeInTheDocument(); - expect(screen.getByText("Kanban Board")).toBeInTheDocument(); expect(screen.getByText("Sessions")).toBeInTheDocument(); expect(screen.getByText("Activity Feed")).toBeInTheDocument(); }); @@ -60,7 +59,6 @@ describe("Sidebar", () => { const links = screen.getAllByRole("link"); const hrefs = links.map((link) => link.getAttribute("href")); expect(hrefs).toContain("/"); - expect(hrefs).toContain("/kanban"); expect(hrefs).toContain("/sessions"); expect(hrefs).toContain("/activity"); }); @@ -82,7 +80,6 @@ describe("Sidebar", () => { await waitFor(() => { expect(screen.getByText("Tổng quan")).toBeInTheDocument(); - expect(screen.getByText("Bảng Kanban")).toBeInTheDocument(); }); }); diff --git a/client/src/pages/Analytics.tsx b/client/src/pages/Analytics.tsx deleted file mode 100644 index 427456c1..00000000 --- a/client/src/pages/Analytics.tsx +++ /dev/null @@ -1,1477 +0,0 @@ -/** - * @file Analytics.tsx - * @description Provides a comprehensive analytics dashboard for monitoring Claude Code sessions, agents, token usage, and events in real-time. Features include an activity heatmap, token distribution charts, session outcome breakdowns, and more, all with interactive tooltips and live updates via WebSocket. - * @author Nguyễn Ngọc Trí Vĩ - */ -/* ============================================================================= - * 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` - * - `../lib/format` - * - `../components/Tip` - * - `../components/Skeleton` - * - `../lib/types` - * - * ## Public surface - * - `Analytics` — 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). - * ----------------------------------------------------------------------------- - * **Analytics** - * 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, useMemo, useSyncExternalStore } from "react"; -import { useTranslation } from "react-i18next"; -import { - RefreshCw, - Download, - Zap, - Bot, - FolderOpen, - Cpu, - DollarSign, - Clock, - BarChart3, -} from "lucide-react"; -import { api } from "../lib/api"; -import { eventBus } from "../lib/eventBus"; -import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; -import { useDataScope } from "../lib/dataScope"; -import { fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format"; -import { Tip } from "../components/Tip"; -import { Skeleton, StatValueSkeleton, TextSkeleton } from "../components/Skeleton"; -import type { Analytics as AnalyticsData, CostResult } from "../lib/types"; - -// ── Tooltip ─────────────────────────────────────────────────────────────────── - -function ChartTooltip({ x, y, children }: { x: number; y: number; children: React.ReactNode }) { - const nearRight = x > window.innerWidth - 200; - return ( -
- {children} -
- ); -} - -function useTooltip() { - const [tooltip, setTooltip] = useState<{ - x: number; - y: number; - content: React.ReactNode; - } | null>(null); - - const show = (e: React.MouseEvent, content: React.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 ? ( - - {tooltip.content} - - ) : null; - - return { show, move, hide, node }; -} - -// ── Heatmap ────────────────────────────────────────────────────────────────── - -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> }) { - 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 ( -
- {node} - {/* Month labels */} -
- {monthPositions.map((mp, i) => ( -
- {mp.label} -
- ))} -
-
- {/* Day labels */} -
- {dayLabels.map((d, i) => ( -
- {d} -
- ))} -
- {/* Cells */} - {weeks.map((week, wi) => ( -
- {week.map((cell) => ( -
{ - 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, - <> - - {dayNames[dow] ?? ""}, {cell.date} - - - {t("eventCountLabel", { count: cell.count })} - - - ); - }} - 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", - }} - /> - ))} -
- ))} -
- {/* Legend */} -
- {t("less")} - {[0, 0.25, 0.5, 0.75, 1].map((f) => { - const v = Math.round(f * maxCount); - return ( -
- ); - })} - {t("more")} -
-
- ); -} - -// ── Sparkline bar chart ─────────────────────────────────────────────────────── - -function Sparkline({ - 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 ( -
- {node} - {data.map(({ date, count }) => ( -
- show( - e, - <> - {date} - {t("eventCountLabel", { count })} - - ) - } - onMouseMove={move} - onMouseLeave={hide} - /> - ))} -
- ); -} - -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 ( -
- {node} - - - - - - - - - - {points.map((point) => ( - - - - show( - e, - <> - {point.date} - {fmtCostFull(point.cost)} - - ) - } - onMouseMove={move} - onMouseLeave={hide} - /> - - ))} - -
- ); -} - -// ── Bar row ─────────────────────────────────────────────────────────────────── - -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 ( -
- - {label} - -
-
-
- - {fmt(count)} - -
- ); -} - -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 ( -
- - {label} - -
-
-
- - {fmtCost(cost)} - -
- ); -} - -// ── Donut segment via SVG ───────────────────────────────────────────────────── - -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
{t("common:noData")}
; - - 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 ( -
- {node} - - - {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 ( - - show( - e, - <> - {label} - {pct}% - - ) - } - onMouseMove={move} - onMouseLeave={hide} - /> - ); - })} - - {(formatTotal ?? fmt)(total)} - - - {t("common:total_lower")} - - -
- {segments.map(({ label, value, color }) => ( -
- - {label} - {Math.round((value / total) * 100)}% -
- ))} -
-
- ); -} - -// ── StatPill ────────────────────────────────────────────────────────────────── - -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 ( -
-
- {label} - -
- {loading ? ( - - ) : ( -

- {raw ? {value} : value} -

- )} - {loading ? ( - - ) : ( - sub &&

{sub}

- )} -
- ); -} - -// ── Loading skeleton ─────────────────────────────────────────────────────────── - -/** A single chart-card placeholder: title bar, chart body, two footer lines. - * Uses the shared pulsing 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 ( -
- - -
- - -
-
- ); -} - -/** 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 ( -
-
- - -
- -
- - - -
-
- ); -} - -// ── Main page ───────────────────────────────────────────────────────────────── - -export function Analytics() { - const { t, i18n } = useTranslation("analytics"); - const [data, setData] = useState(null); - const [costData, setCostData] = useState(null); - const [lastUpdate, setLastUpdate] = useState(new Date()); - const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState<"tokens" | "cost" | "workflow" | "productivity">( - "cost" - ); - const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); - // 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 = {}; - 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> = []; - 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 = {}; - 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 = {}; - 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 = { - PreToolUse: "bg-status-success", - PostToolUse: "bg-blue-500", - Stop: "bg-violet-400", - SubagentStop: "bg-yellow-400", - Notification: "bg-orange-400", - }; - - return ( -
- {/* Header */} -
-
-
-
- -
-
-
-

{t("title")}

- {wsConnected ? ( - - - {t("common:live")} - - ) : ( - - - {t("common:offline")} - - )} -
-

- {t("subtitle")} - - - {lastUpdate.toLocaleTimeString()} - -

-
-
-
-
- - -
-
- - {/* Key stats */} -
- - - - - -
- - {!data ? ( - - ) : ( - <> - {/* Activity heatmap + 30-day sparkline */} -
-
-

{t("eventActivity")}

-
-
- -
-
-
-
-

{t("last30Days")}

-

{t("dailyEventCount")}

- -
- {last30[0]?.date?.slice(5)} - {last30[last30.length - 1]?.date?.slice(5)} -
-
-
- {t("peakDay")} - - d.count)).toLocaleString()}> - {fmt(Math.max(...last30.map((d) => d.count)))} - {" "} - {t("common:events")} - -
-
- {t("total30d")} - - s + d.count, 0).toLocaleString()}> - {fmt(last30.reduce((s, d) => s + d.count, 0))} - {" "} - {t("common:events")} - -
-
-
-
- - {/* Tabs */} -
-
- {( - [ - { 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 }) => ( - - ))} -
- - {activeTab === "tokens" && ( -
- {/* Token bars */} -
-

- {t("tokenDistribution")} -

-
- {[ - { - 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 }) => ( - - ))} -
-
-
- {t("common:token.totalTokens")} - - {fmt(totalTokens)} - -
-
- {t("cacheEfficiency")} - {cacheHitPct}% -
-
-
- - {/* Token summary */} -
-

- {t("tokenBreakdown")} -

-
- {[ - { - 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 }) => ( -
- {label} - - {value.toLocaleString()} - -
- ))} -
- {totalTokens === 0 && ( -

{t("tokenInfo")}

- )} -
- - {/* Token mix donut */} -
-

{t("tokenMix")}

- {tokenMixSegments.length === 0 ? ( -

{t("common:noData")}

- ) : ( - <> - fmt(total)} /> -
- {tokenMixSegments.map((segment) => ( -
- {segment.label} - - {fmt(segment.value)} - -
- ))} -
- - )} -
-
- )} - - {activeTab === "cost" && ( -
- {/* Daily cost trends */} -
-

- {t("dailyCostTrends")} -

- {dailyCostsLocal.length === 0 ? ( -

{t("noDailyCostData")}

- ) : ( - <> -

{t("costPerDay")}

- -
- {dailyCostLast30[0]?.date?.slice(5)} - {dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)} -
-
-
- {t("peakCostDay")} - - - {fmtCost(peakCostDay.cost)} - - -
-
- {t("totalCost30d")} - - {fmtCost(totalCost30d)} - -
-
- - )} -
- - {/* Cost by model */} -
-

{t("costByModel")}

- {costBreakdown.length > 0 ? ( - <> - ({ - 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)} - /> -
- {costBreakdown.map((b) => ( -
- - {formatModelName(b.model)} - - - {fmtCost(b.cost)} - -
- ))} -
- {t("common:total")} - - - {fmtCost(costData?.total_cost ?? 0)} - - -
-
- - ) : ( -

{t("noCostData")}

- )} -
- - {/* Cost by weekday */} -
-

- {t("costByWeekday")} -

- {dailyCostsLocal.length === 0 ? ( -

{t("noDailyCostData")}

- ) : ( - <> -

{t("last30Days")}

-
- {weekdayCosts.map(({ label, cost }) => ( - - ))} -
-
- {t("common:total")} - - {fmtCost(totalCost30d)} - -
- - )} -
-
- )} - - {activeTab === "workflow" && ( -
- {/* Agent type distribution */} -
-

- {t("subagentTypes")} -

- {(data?.agent_types ?? []).length === 0 ? ( -

{t("noSubagentData")}

- ) : ( -
- {(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => ( - - ))} -
- )} -
- - {/* Agent status donut */} -
-

{t("agentStatus")}

- -
-
- {t("totalAgentsLabel")} - - - {fmt(data?.overview.total_agents ?? 0)} - - -
- {agentStatusSegments.map((s) => ( -
- - - {s.label} - - - {fmt(s.value)} - -
- ))} -
-
- - {/* Event type breakdown */} -
-

{t("eventTypes")}

- {(data?.event_types ?? []).length === 0 ? ( -

{t("noEventData")}

- ) : ( -
- {(data?.event_types ?? []).map(({ event_type, count }) => ( - - ))} -
- )} -
-
- )} - - {activeTab === "productivity" && ( -
- {/* Top tools */} -
-

{t("toolUsage")}

- {(data?.tool_usage ?? []).length === 0 ? ( -

{t("noToolData")}

- ) : ( -
- {(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => ( - - ))} -
- )} -
- - {/* Session outcomes donut */} -
-

- {t("sessionOutcomes")} -

- -
-
- {t("totalSessionsLabel")} - - - {fmt(data?.overview.total_sessions ?? 0)} - - -
- {sessionOutcomeSegments.map((s) => ( -
- - - {s.label} - - - {fmt(s.value)} - -
- ))} -
-
- - {/* Daily session trends */} -
-

- {t("dailySessionTrends")} -

- {dailySessionsLocal.length === 0 ? ( -

{t("noSessionTrendData")}

- ) : ( - <> - -
- {dailySessionsLocal - .slice(-7) - .reverse() - .map(({ date, count }) => { - const maxD = Math.max( - ...(dailySessionsLocal.length > 0 - ? dailySessionsLocal - : [{ count: 1 }] - ).map((d) => d.count) - ); - return ( -
- - {date.slice(5)} - -
-
-
- - {count} - -
- ); - })} -
-

{t("last7Days")}

- - )} -
-
- )} -
- - )} -
- ); -} diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 94bfefe9..eae23680 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ /** * @file Dashboard.tsx - * @description Main dashboard page showing real-time stats, active agents, and recent activity feed for Claude Code sessions. + * @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ĩ */ /* ============================================================================= @@ -87,6 +87,8 @@ import { ShieldCheck, Database, Search, + Download, + Clock, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; @@ -97,8 +99,18 @@ import { AgentCard } from "../components/AgentCard"; import { AgentStatusBadge } from "../components/StatusBadge"; import { EmptyState } from "../components/EmptyState"; import { Tip } from "../components/Tip"; -import { timeAgo, fmt, fmtCost, formatModelName } from "../lib/format"; -import type { Stats, Agent, DashboardEvent, WSMessage, WorkflowData, Session } from "../lib/types"; +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: { @@ -152,6 +164,1367 @@ function formatUptime(seconds: number): string { 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 ( +
+ {children} +
+ ); +} + +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 ? ( + + {tooltip.content} + + ) : 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> }) { + 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 ( +
+ {node} + {/* Month labels */} +
+ {monthPositions.map((mp, i) => ( +
+ {mp.label} +
+ ))} +
+
+ {/* Day labels */} +
+ {dayLabels.map((d, i) => ( +
+ {d} +
+ ))} +
+ {/* Cells */} + {weeks.map((week, wi) => ( +
+ {week.map((cell) => ( +
{ + 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, + <> + + {dayNames[dow] ?? ""}, {cell.date} + + + {t("eventCountLabel", { count: cell.count })} + + + ); + }} + 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", + }} + /> + ))} +
+ ))} +
+ {/* Legend */} +
+ {t("less")} + {[0, 0.25, 0.5, 0.75, 1].map((f) => { + const v = Math.round(f * maxCount); + return ( +
+ ); + })} + {t("more")} +
+
+ ); +} + +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 ( +
+ {node} + {data.map(({ date, count }) => ( +
+ show( + e, + <> + {date} + {t("eventCountLabel", { count })} + + ) + } + onMouseMove={move} + onMouseLeave={hide} + /> + ))} +
+ ); +} + +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 ( +
+ {node} + + + + + + + + + + {points.map((point) => ( + + + + show( + e, + <> + {point.date} + {fmtCostFull(point.cost)} + + ) + } + onMouseMove={move} + onMouseLeave={hide} + /> + + ))} + +
+ ); +} + +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 ( +
+ + {label} + +
+
+
+ + {fmt(count)} + +
+ ); +} + +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 ( +
+ + {label} + +
+
+
+ + {fmtCost(cost)} + +
+ ); +} + +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
{t("common:noData")}
; + + 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 ( +
+ {node} + + + {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 ( + + show( + e, + <> + {label} + {pct}% + + ) + } + onMouseMove={move} + onMouseLeave={hide} + /> + ); + })} + + {(formatTotal ?? fmt)(total)} + + + {t("common:total_lower")} + + +
+ {segments.map(({ label, value, color }) => ( +
+ + {label} + {Math.round((value / total) * 100)}% +
+ ))} +
+
+ ); +} + +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 ( +
+
+ {label} + +
+ {loading ? ( + + ) : ( +

+ {raw ? {value} : value} +

+ )} + {loading ? ( + + ) : ( + sub &&

{sub}

+ )} +
+ ); +} + +/** A single chart-card placeholder: title bar, chart body, two footer lines. + * Uses the shared pulsing 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 ( +
+ + +
+ + +
+
+ ); +} + +/** 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 ( +
+
+ + +
+ +
+ + + +
+
+ ); +} + +function AnalyticsTab() { + const { t, i18n } = useTranslation("analytics"); + const [data, setData] = useState(null); + const [costData, setCostData] = useState(null); + const [lastUpdate, setLastUpdate] = useState(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 = {}; + 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> = []; + 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 = {}; + 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 = {}; + 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 = { + PreToolUse: "bg-status-success", + PostToolUse: "bg-blue-500", + Stop: "bg-violet-400", + SubagentStop: "bg-yellow-400", + Notification: "bg-orange-400", + }; + + return ( +
+ {/* Toolbar */} +
+ + + {lastUpdate.toLocaleTimeString()} + +
+ + +
+
+ + {/* Key stats */} +
+ + + + + +
+ + {!data ? ( + + ) : ( + <> + {/* Activity heatmap + 30-day sparkline */} +
+
+

{t("eventActivity")}

+
+
+ +
+
+
+
+

{t("last30Days")}

+

{t("dailyEventCount")}

+ +
+ {last30[0]?.date?.slice(5)} + {last30[last30.length - 1]?.date?.slice(5)} +
+
+
+ {t("peakDay")} + + d.count)).toLocaleString()}> + {fmt(Math.max(...last30.map((d) => d.count)))} + {" "} + {t("common:events")} + +
+
+ {t("total30d")} + + s + d.count, 0).toLocaleString()}> + {fmt(last30.reduce((s, d) => s + d.count, 0))} + {" "} + {t("common:events")} + +
+
+
+
+ + {/* Tabs */} +
+
+ {( + [ + { 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 }) => ( + + ))} +
+ + {analyticsSubTab === "tokens" && ( +
+ {/* Token bars */} +
+

+ {t("tokenDistribution")} +

+
+ {[ + { + 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 }) => ( + + ))} +
+
+
+ {t("common:token.totalTokens")} + + {fmt(totalTokens)} + +
+
+ {t("cacheEfficiency")} + {cacheHitPct}% +
+
+
+ + {/* Token summary */} +
+

+ {t("tokenBreakdown")} +

+
+ {[ + { + 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 }) => ( +
+ {label} + + {value.toLocaleString()} + +
+ ))} +
+ {totalTokens === 0 && ( +

{t("tokenInfo")}

+ )} +
+ + {/* Token mix donut */} +
+

{t("tokenMix")}

+ {tokenMixSegments.length === 0 ? ( +

{t("common:noData")}

+ ) : ( + <> + fmt(total)} /> +
+ {tokenMixSegments.map((segment) => ( +
+ {segment.label} + + {fmt(segment.value)} + +
+ ))} +
+ + )} +
+
+ )} + + {analyticsSubTab === "cost" && ( +
+ {/* Daily cost trends */} +
+

+ {t("dailyCostTrends")} +

+ {dailyCostsLocal.length === 0 ? ( +

{t("noDailyCostData")}

+ ) : ( + <> +

{t("costPerDay")}

+ +
+ {dailyCostLast30[0]?.date?.slice(5)} + {dailyCostLast30[dailyCostLast30.length - 1]?.date?.slice(5)} +
+
+
+ {t("peakCostDay")} + + + {fmtCost(peakCostDay.cost)} + + +
+
+ {t("totalCost30d")} + + {fmtCost(totalCost30d)} + +
+
+ + )} +
+ + {/* Cost by model */} +
+

{t("costByModel")}

+ {costBreakdown.length > 0 ? ( + <> + ({ + 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)} + /> +
+ {costBreakdown.map((b) => ( +
+ + {formatModelName(b.model)} + + + {fmtCost(b.cost)} + +
+ ))} +
+ {t("common:total")} + + + {fmtCost(costData?.total_cost ?? 0)} + + +
+
+ + ) : ( +

{t("noCostData")}

+ )} +
+ + {/* Cost by weekday */} +
+

+ {t("costByWeekday")} +

+ {dailyCostsLocal.length === 0 ? ( +

{t("noDailyCostData")}

+ ) : ( + <> +

{t("last30Days")}

+
+ {weekdayCosts.map(({ label, cost }) => ( + + ))} +
+
+ {t("common:total")} + + {fmtCost(totalCost30d)} + +
+ + )} +
+
+ )} + + {analyticsSubTab === "workflow" && ( +
+ {/* Agent type distribution */} +
+

+ {t("subagentTypes")} +

+ {(data?.agent_types ?? []).length === 0 ? ( +

{t("noSubagentData")}

+ ) : ( +
+ {(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => ( + + ))} +
+ )} +
+ + {/* Agent status donut */} +
+

{t("agentStatus")}

+ +
+
+ {t("totalAgentsLabel")} + + + {fmt(data?.overview.total_agents ?? 0)} + + +
+ {agentStatusSegments.map((s) => ( +
+ + + {s.label} + + + {fmt(s.value)} + +
+ ))} +
+
+ + {/* Event type breakdown */} +
+

{t("eventTypes")}

+ {(data?.event_types ?? []).length === 0 ? ( +

{t("noEventData")}

+ ) : ( +
+ {(data?.event_types ?? []).map(({ event_type, count }) => ( + + ))} +
+ )} +
+
+ )} + + {analyticsSubTab === "productivity" && ( +
+ {/* Top tools */} +
+

{t("toolUsage")}

+ {(data?.tool_usage ?? []).length === 0 ? ( +

{t("noToolData")}

+ ) : ( +
+ {(data?.tool_usage ?? []).slice(0, 12).map(({ tool_name, count }) => ( + + ))} +
+ )} +
+ + {/* Session outcomes donut */} +
+

+ {t("sessionOutcomes")} +

+ +
+
+ {t("totalSessionsLabel")} + + + {fmt(data?.overview.total_sessions ?? 0)} + + +
+ {sessionOutcomeSegments.map((s) => ( +
+ + + {s.label} + + + {fmt(s.value)} + +
+ ))} +
+
+ + {/* Daily session trends */} +
+

+ {t("dailySessionTrends")} +

+ {dailySessionsLocal.length === 0 ? ( +

{t("noSessionTrendData")}

+ ) : ( + <> + +
+ {dailySessionsLocal + .slice(-7) + .reverse() + .map(({ date, count }) => { + const maxD = Math.max( + ...(dailySessionsLocal.length > 0 + ? dailySessionsLocal + : [{ count: 1 }] + ).map((d) => d.count) + ); + return ( +
+ + {date.slice(5)} + +
+
+
+ + {count} + +
+ ); + })} +
+

{t("last7Days")}

+ + )} +
+
+ )} +
+ + )} +
+ ); +} + function SystemHealthTab() { const [info, setInfo] = useState(null); const [workflow, setWorkflow] = useState(null); @@ -955,8 +2328,10 @@ export function Dashboard() { const { t } = useTranslation("dashboard"); // Persistent Tab State - const [activeTab, setActiveTab] = useState<"monitor" | "health">(() => { - return (localStorage.getItem("dashboard_tab") as "monitor" | "health") || "monitor"; + const [activeTab, setActiveTab] = useState<"monitor" | "analytics" | "health">(() => { + return ( + (localStorage.getItem("dashboard_tab") as "monitor" | "analytics" | "health") || "monitor" + ); }); useEffect(() => { @@ -1189,6 +2564,16 @@ export function Dashboard() { > Monitor +
{activeAgents.length === 0 ? ( @@ -1504,6 +2886,8 @@ export function Dashboard() {
+ ) : activeTab === "analytics" ? ( + ) : ( )} diff --git a/client/src/pages/KanbanBoard.tsx b/client/src/pages/KanbanBoard.tsx deleted file mode 100644 index 8f13b0eb..00000000 --- a/client/src/pages/KanbanBoard.tsx +++ /dev/null @@ -1,525 +0,0 @@ -/** - * @file KanbanBoard.tsx - * @description Kanban-style board with two views: agents grouped by their - * AgentStatus (working/waiting/completed/error) or sessions grouped - * by their SessionStatus (active/completed/error/abandoned). The view toggle - * is persisted in localStorage so the user's choice survives reloads. Each - * column paginates client-side at COLUMN_PAGE_SIZE. - * @author Nguyễn Ngọc Trí Vĩ - */ -/* ============================================================================= - * 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` - * - `../components/AgentCard` - * - `../components/SessionCard` - * - `../components/EmptyState` - * - `../components/Skeleton` - * - `../lib/types` - * - * ## Public surface - * - `KanbanBoard` — 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). - * ----------------------------------------------------------------------------- - * **KanbanBoard** - * 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, useMemo, useRef, useSyncExternalStore } from "react"; -import { useTranslation } from "react-i18next"; -import { RefreshCw, Columns3, ChevronDown, HelpCircle } from "lucide-react"; -import { api } from "../lib/api"; -import { eventBus } from "../lib/eventBus"; -import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; -import { AgentCard } from "../components/AgentCard"; -import { SessionCard } from "../components/SessionCard"; -import { EmptyState } from "../components/EmptyState"; -import { CardSkeleton } from "../components/Skeleton"; -import { - STATUS_CONFIG, - SESSION_STATUS_CONFIG, - isAgentAwaitingInput, - isSessionAwaitingInput, -} from "../lib/types"; -import type { - Agent, - AgentStatus, - EffectiveAgentStatus, - EffectiveSessionStatus, - Session, - WSMessage, -} from "../lib/types"; - -type BoardView = "agents" | "sessions"; - -// Persisted statuses we fetch from the API. -const AGENT_FETCH_STATUSES: AgentStatus[] = ["working", "waiting", "completed", "error"]; - -// Columns rendered on the Agents board. -const AGENT_COLUMNS: EffectiveAgentStatus[] = ["working", "waiting", "completed", "error"]; -const SESSION_COLUMNS: EffectiveSessionStatus[] = [ - "active", - "waiting", - "completed", - "error", - "abandoned", -]; -const COLUMN_PAGE_SIZE = 10; -const VIEW_STORAGE_KEY = "kanban-board-view"; - -function loadView(): BoardView { - try { - const stored = localStorage.getItem(VIEW_STORAGE_KEY); - if (stored === "agents" || stored === "sessions") return stored; - } catch { - /* ignore */ - } - return "agents"; -} - -function persistView(view: BoardView): void { - try { - localStorage.setItem(VIEW_STORAGE_KEY, view); - } catch { - /* ignore */ - } -} - -export function KanbanBoard() { - const { t } = useTranslation("kanban"); - const [view, setViewState] = useState(loadView); - const [agents, setAgents] = useState([]); - const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(true); - const [expanded, setExpanded] = useState>({}); - - const setView = useCallback((next: BoardView) => { - setViewState(next); - persistView(next); - setExpanded({}); // reset per-column pagination when switching views - }, []); - - const loadAgents = useCallback(async () => { - // Fetch every persisted agent status. Bucketing happens below in - // `groupedAgents`. - // - // Also fetch sessions so AgentCard can surface model / cwd / cost on - // main-agent cards (they have no task and a generic name on their - // own - the session metadata is what makes the card useful). - const [agentResults, sessionsRes] = await Promise.all([ - Promise.all(AGENT_FETCH_STATUSES.map((status) => api.agents.list({ status }))), - api.sessions.list({ limit: 10000 }), - ]); - setAgents(agentResults.flatMap((r) => r.agents)); - setSessions(sessionsRes.sessions); - }, []); - - const loadSessions = useCallback(async () => { - // Each column needs the full set for its status - column-level - // pagination ("show more") is handled client-side at COLUMN_PAGE_SIZE. - // Wire-limit raised to the server's safety cap (10000); cost - // computation on the server scales with returned rows, so each - // column's request stays bounded by how many sessions actually have - // that status. The "waiting" column is derived client-side from the - // active set (see grouping below). - const persistedStatuses = SESSION_COLUMNS.filter((s) => s !== "waiting"); - const results = await Promise.all( - persistedStatuses.map((status) => api.sessions.list({ status, limit: 10000 })) - ); - setSessions(results.flatMap((r) => r.sessions)); - }, []); - - const load = useCallback(async () => { - try { - if (view === "agents") await loadAgents(); - else await loadSessions(); - } finally { - setLoading(false); - } - }, [view, loadAgents, loadSessions]); - - useEffect(() => { - setLoading(true); - load(); - }, [load]); - - useEffect(() => { - let debounceTimer: ReturnType | null = null; - return eventBus.subscribe((msg: WSMessage) => { - if (isRemoteDataRefreshMessage(msg)) { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(load, 300); - return; - } - if (view === "agents") { - if ( - msg.type === "agent_created" || - msg.type === "agent_updated" || - msg.type === "session_updated" || - msg.type === "session_created" - ) { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(loadAgents, 300); - } - } else { - if (msg.type === "session_created" || msg.type === "session_updated") { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(loadSessions, 300); - } - } - }); - }, [view, loadAgents, loadSessions]); - - // Lookup map for AgentCard's session prop - memoized to avoid rebuilding on every render - const sessionsById = useMemo(() => { - const map = new Map(); - for (const s of sessions) map.set(s.id, s); - return map; - }, [sessions]); - - // Bucket by effective status: agents with status "waiting" OR those with - // awaiting_input_since set go into the "waiting" column. Other columns - // exclude agents that belong in "waiting". - const isEffectivelyWaiting = (a: Agent) => a.status === "waiting" || isAgentAwaitingInput(a); - - const groupedAgents = AGENT_COLUMNS.reduce( - (acc, status) => { - acc[status] = - status === "waiting" - ? agents.filter(isEffectivelyWaiting) - : agents.filter((a) => a.status === status && !isEffectivelyWaiting(a)); - return acc; - }, - {} as Record - ); - - const groupedSessions = SESSION_COLUMNS.reduce( - (acc, status) => { - acc[status] = - status === "waiting" - ? sessions.filter(isSessionAwaitingInput) - : sessions.filter((s) => s.status === status && !isSessionAwaitingInput(s)); - return acc; - }, - {} as Record - ); - - const total = view === "agents" ? agents.length : sessions.length; - const subtitle = - view === "agents" - ? t("agentCount", { count: agents.length }) - : t("sessionCount", { count: sessions.length }); - - const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); - - const Header = ( -
-
-
- -
-
-
-

{t("title")}

- {wsConnected ? ( - - - {t("common:live")} - - ) : ( - - - {t("common:offline")} - - )} -
-

{subtitle}

-
-
-
- - -
-
- ); - - if (!loading && total === 0) { - return ( -
- {Header} -
- - {t("common:refresh")} - - } - /> -
-
- ); - } - - return ( -
- {Header} - -
- {view === "agents" - ? AGENT_COLUMNS.map((status) => { - const config = STATUS_CONFIG[status]; - const items = groupedAgents[status]; - const limit = expanded[status] || COLUMN_PAGE_SIZE; - return ( - - setExpanded((prev) => ({ - ...prev, - [status]: limit + COLUMN_PAGE_SIZE, - })) - } - > - {loading && (items?.length ?? 0) === 0 - ? Array.from({ length: 3 }).map((_, i) => ( - - )) - : items - ?.slice(0, limit) - .map((agent) => ( - - ))} - - ); - }) - : SESSION_COLUMNS.map((status) => { - const config = SESSION_STATUS_CONFIG[status]; - const items = groupedSessions[status]; - const limit = expanded[status] || COLUMN_PAGE_SIZE; - return ( - - setExpanded((prev) => ({ - ...prev, - [status]: limit + COLUMN_PAGE_SIZE, - })) - } - > - {loading && (items?.length ?? 0) === 0 - ? Array.from({ length: 3 }).map((_, i) => ( - - )) - : items - ?.slice(0, limit) - .map((session) => )} - - ); - })} -
-
- ); -} - -interface ViewToggleProps { - view: BoardView; - onChange: (next: BoardView) => void; -} - -function ViewToggle({ view, onChange }: ViewToggleProps) { - const { t } = useTranslation("kanban"); - const baseClass = - "px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-lg last:rounded-r-lg"; - const activeClass = "bg-accent/15 text-accent"; - const inactiveClass = "text-fg-secondary hover:text-fg-primary hover:bg-surface-3"; - - return ( -
- - -
- ); -} - -interface ColumnProps { - labelKey: string; - color: string; - dotClass: string; - pulse: boolean; - count: number; - emptyLabel: string; - /** Multi-line description rendered in a tooltip when the user hovers - * the column's help icon. Pass an empty string to suppress the icon. */ - tooltip?: string; - remaining: number; - onShowMore: () => void; - children: React.ReactNode; -} - -function Column({ - labelKey, - color, - dotClass, - pulse, - count, - emptyLabel, - tooltip, - remaining, - onShowMore, - children, -}: ColumnProps) { - const { t } = useTranslation("kanban"); - const childrenArray = Array.isArray(children) ? children : children ? [children] : []; - const hasChildren = childrenArray.length > 0; - - return ( -
-
- - - {t(labelKey)} - - {tooltip && } - - {count} - -
- -
- {hasChildren ? ( - <> - {children} - {remaining > 0 && ( - - )} - - ) : ( -
- {emptyLabel} -
- )} -
-
- ); -} - -/** - * Help icon + tooltip for a Kanban column header. Hover or focus shows a - * multi-line description explaining what the column lists and what the - * status means in lifecycle terms. Keyboard-focusable for accessibility. - */ -function ColumnHelp({ text }: { text: string }) { - const [show, setShow] = useState(false); - // Anchor positioning to the column header so the tooltip stays in-page on - // the leftmost columns (where a centered tooltip would clip on narrow - // viewports). We always anchor left-aligned to the trigger. - const triggerRef = useRef(null); - - return ( - setShow(true)} - onMouseLeave={() => setShow(false)} - onFocus={() => setShow(true)} - onBlur={() => setShow(false)} - > - - {show && ( - - {text} - - )} - - ); -} diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index 012ab441..346b05e5 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -679,2258 +679,6 @@ exports[`screen snapshots > Activity feed 1`] = `
`; -exports[`screen snapshots > Analytics 1`] = ` -
-
-
-
-
-
- - - - - - -
-
-
-

- Analytics -

- - - Live - -
-

- Real-time monitoring and analytics for Claude Code sessions - - - - - - 1:00:00 PM - -

-
-
-
-
- - -
-
-
-
-
- - Total Sessions - - - - -
-

- - 0 - -

-

- 0 active -

-
-
-
- - Total Agents - - - - - - - - - -
-

- - 0 - -

-

- 0 active -

-
-
-
- - Total Tokens - - - - - - - - - - - - - -
-

- - 0 - -

-

- 0% cache hit rate -

-
-
-
- - Total Cost - - - - - -
-

- - $0.00 - -

-

- 0 Model -

-
-
-
- - Total Events - - - - -
-

- - 0 - -

-

- ~0 per session -

-
-
-
-
-

- Event Activity - Last 52 Weeks -

-
-
-
-
-
- Jun -
-
- Jul -
-
- Aug -
-
- Sep -
-
- Oct -
-
- Nov -
-
- Dec -
-
- Jan -
-
- Feb -
-
- Mar -
-
- Apr -
-
- May -
-
- Jun -
-
-
-
-
- Sun -
-
-
- Tue -
-
-
- Thu -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - Less - -
-
-
-
-
- - More - -
-
-
-
-
-
-

- Last 30 Days -

-

- Daily event count -

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - 05-12 - - - 06-10 - -
-
-
- - Peak day - - - - 0 - - - events - -
-
- - Total (30d) - - - - 0 - - - events - -
-
-
-
-
-
- - - - -
-
-
-

- Daily Cost Trends -

-

- No daily cost data yet. -

-
-
-

- Cost by Model -

-

- No cost data yet. -

-
-
-

- Cost by Weekday -

-

- No daily cost data yet. -

-
-
-
-
-
-`; - exports[`screen snapshots > Claude Config 1`] = `
Dashboard 1`] = ` Monitor +
Dashboard 1`] = `
`; -exports[`screen snapshots > Kanban board 1`] = ` -
-
-
-
-
- - - - - -
-
-
-

- Kanban Board -

- - - Live - -
-

- 0 agent tracked -

-
-
-
-
- - -
- -
-
-
-
-
- - - - - -
-

- No agents tracked yet -

-

- Start a Claude Code session with hooks installed to see agents appear here. -

- -
-
-
-
-`; - exports[`screen snapshots > Not found 1`] = `
({ // Page components (imported after the mocks above; vi.mock is hoisted). import { Dashboard } from "../Dashboard"; -import { KanbanBoard } from "../KanbanBoard"; import { Sessions } from "../Sessions"; import { SessionDetail } from "../SessionDetail"; import { ActivityFeed } from "../ActivityFeed"; -import { Analytics } from "../Analytics"; import { Workflows } from "../Workflows"; import { CcConfig } from "../CcConfig"; import { Workspace } from "../Workspace"; @@ -511,9 +509,6 @@ describe("screen snapshots", () => { it("Dashboard", async () => { await snapshot(, "/"); }); - it("Kanban board", async () => { - await snapshot(, "/kanban"); - }); it("Sessions", async () => { await snapshot(, "/sessions"); }); @@ -528,9 +523,6 @@ describe("screen snapshots", () => { it("Activity feed", async () => { await snapshot(, "/activity"); }); - it("Analytics", async () => { - await snapshot(, "/analytics"); - }); it("Workflows", async () => { await snapshot(, "/workflows"); }); diff --git a/docs/API.md b/docs/API.md index 893071e3..390fa73e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1496,7 +1496,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s) **PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `ccam lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view. -Every history row carries `lane_id`: the lane whose run it belongs to. `GET /api/run/history?laneId=` returns only that lane's runs. Spawned `claude` processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. +Every history row carries `lane_id`: the lane whose run it belongs to. `GET /api/run/history?laneId=` returns only that lane's runs. Spawned `claude` processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the Dashboard's Analytics tab, and the Workflows page automatically. ---