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.
This commit is contained in:
2026-08-25 11:45:39 +07:00
parent 8c026d4cc7
commit 8ce20e8163
13 changed files with 1445 additions and 4564 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-525
View File
@@ -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ĩ <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`
* - `../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<BoardView>(loadView);
const [agents, setAgents] = useState<Agent[]>([]);
const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<Record<string, number>>({});
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<typeof setTimeout> | 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<string, Session>();
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<EffectiveAgentStatus, Agent[]>
);
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<EffectiveSessionStatus, Session[]>
);
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 = (
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Columns3 className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary truncate">{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 truncate">{subtitle}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<ViewToggle view={view} onChange={setView} />
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
</div>
</div>
);
if (!loading && total === 0) {
return (
<div className="animate-fade-in flex flex-col min-h-[60vh]">
{Header}
<div className="flex-1 flex items-center justify-center">
<EmptyState
icon={Columns3}
title={view === "agents" ? t("noAgents") : t("noSessions")}
description={view === "agents" ? t("noAgentsDesc") : t("noSessionsDesc")}
action={
<button onClick={load} className="btn-primary">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
}
/>
</div>
</div>
);
}
return (
<div className="animate-fade-in">
{Header}
<div className="flex gap-4 min-h-[600px] overflow-x-auto pb-4 -mx-8 px-8">
{view === "agents"
? AGENT_COLUMNS.map((status) => {
const config = STATUS_CONFIG[status];
const items = groupedAgents[status];
const limit = expanded[status] || COLUMN_PAGE_SIZE;
return (
<Column
key={status}
labelKey={config.labelKey}
color={config.color}
dotClass={config.dot}
pulse={status === "working" || status === "waiting"}
count={items?.length ?? 0}
emptyLabel={t("noAgentsInColumn")}
tooltip={t(`tooltip.agent.${status}`)}
remaining={Math.max(0, (items?.length ?? 0) - limit)}
onShowMore={() =>
setExpanded((prev) => ({
...prev,
[status]: limit + COLUMN_PAGE_SIZE,
}))
}
>
{loading && (items?.length ?? 0) === 0
? Array.from({ length: 3 }).map((_, i) => (
<CardSkeleton key={`sk-${status}-${i}`} />
))
: items
?.slice(0, limit)
.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
session={sessionsById.get(agent.session_id)}
/>
))}
</Column>
);
})
: SESSION_COLUMNS.map((status) => {
const config = SESSION_STATUS_CONFIG[status];
const items = groupedSessions[status];
const limit = expanded[status] || COLUMN_PAGE_SIZE;
return (
<Column
key={status}
labelKey={config.labelKey}
color={config.color}
dotClass={config.dot}
pulse={status === "active" || status === "waiting"}
count={items?.length ?? 0}
emptyLabel={t("noSessionsInColumn")}
tooltip={t(`tooltip.session.${status}`)}
remaining={Math.max(0, (items?.length ?? 0) - limit)}
onShowMore={() =>
setExpanded((prev) => ({
...prev,
[status]: limit + COLUMN_PAGE_SIZE,
}))
}
>
{loading && (items?.length ?? 0) === 0
? Array.from({ length: 3 }).map((_, i) => (
<CardSkeleton key={`sk-${status}-${i}`} />
))
: items
?.slice(0, limit)
.map((session) => <SessionCard key={session.id} session={session} />)}
</Column>
);
})}
</div>
</div>
);
}
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 (
<div
role="tablist"
aria-label={t("viewToggle.agents") + " / " + t("viewToggle.sessions")}
className="inline-flex border border-border rounded-lg overflow-hidden bg-surface-2"
>
<button
type="button"
role="tab"
aria-selected={view === "agents"}
onClick={() => onChange("agents")}
className={`${baseClass} ${view === "agents" ? activeClass : inactiveClass}`}
>
{t("viewToggle.agents")}
</button>
<button
type="button"
role="tab"
aria-selected={view === "sessions"}
onClick={() => onChange("sessions")}
className={`${baseClass} border-l border-border ${
view === "sessions" ? activeClass : inactiveClass
}`}
>
{t("viewToggle.sessions")}
</button>
</div>
);
}
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 (
<div className="bg-surface-1 rounded-xl border border-border p-3 flex flex-col flex-shrink-0 w-72">
<div className="flex items-center gap-2 mb-4 px-1">
<span className={`w-2 h-2 rounded-full ${dotClass} ${pulse ? "animate-pulse-dot" : ""}`} />
<span className={`text-xs font-semibold uppercase tracking-wider ${color}`}>
{t(labelKey)}
</span>
{tooltip && <ColumnHelp text={tooltip} />}
<span className="ml-auto text-[11px] text-fg-muted bg-surface-3 px-2 py-0.5 rounded-full">
{count}
</span>
</div>
<div className="flex-1 space-y-2.5 overflow-y-auto">
{hasChildren ? (
<>
{children}
{remaining > 0 && (
<button
onClick={onShowMore}
className="w-full py-2 text-[11px] text-fg-muted hover:text-fg-secondary flex items-center justify-center gap-1 transition-colors"
>
<ChevronDown className="w-3 h-3" />
{t("common:showMore", { count: remaining })}
</button>
)}
</>
) : (
<div className="flex items-center justify-center h-24 text-xs text-fg-muted">
{emptyLabel}
</div>
)}
</div>
</div>
);
}
/**
* 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<HTMLSpanElement>(null);
return (
<span
ref={triggerRef}
className="relative inline-flex items-center cursor-help"
tabIndex={0}
role="img"
aria-label={text}
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onFocus={() => setShow(true)}
onBlur={() => setShow(false)}
>
<HelpCircle className="w-3 h-3 text-fg-muted hover:text-fg-secondary transition-colors" />
{show && (
<span
role="tooltip"
className="absolute left-0 top-full mt-1.5 w-64 px-3 py-2 text-[11px] leading-relaxed text-fg-secondary bg-surface-3 border border-border rounded-md shadow-xl z-50 pointer-events-none whitespace-pre-line"
>
{text}
</span>
)}
</span>
);
}
File diff suppressed because it is too large Load Diff
@@ -429,11 +429,9 @@ vi.mock("../../lib/push", () => ({
// 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(<Dashboard />, "/");
});
it("Kanban board", async () => {
await snapshot(<KanbanBoard />, "/kanban");
});
it("Sessions", async () => {
await snapshot(<Sessions />, "/sessions");
});
@@ -528,9 +523,6 @@ describe("screen snapshots", () => {
it("Activity feed", async () => {
await snapshot(<ActivityFeed />, "/activity");
});
it("Analytics", async () => {
await snapshot(<Analytics />, "/analytics");
});
it("Workflows", async () => {
await snapshot(<Workflows />, "/workflows");
});