/** * @file Sidebar.tsx * @description Defines the Sidebar component that provides navigation links to different sections of the application, displays the connection status, and includes a toggle button for collapsing or expanding the sidebar. The component uses React Router's NavLink for navigation and Lucide icons for visual representation. The collapsed state of the sidebar is stored in localStorage to persist user preferences across sessions. * @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. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../lib/types` * * ## Public surface * - `Sidebar` — 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. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **Sidebar** * 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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { NavLink } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { LayoutDashboard, Columns3, FolderOpen, Activity, BarChart3, Workflow, Boxes, Play, Settings, Wifi, WifiOff, PanelLeftClose, PanelLeftOpen, Languages, RefreshCw, X, Plug, Clock, Gauge, ChevronUp, ChevronDown, Sun, Moon, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { useTheme } from "../hooks/useTheme"; import type { UpdateStatusPayload, WSMessage } from "../lib/types"; function isUpdatePayload(x: unknown): x is UpdateStatusPayload { return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x; } 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" }, { to: "/settings", icon: Settings, key: "nav:settings" }, ] as const; const STORAGE_KEY = "sidebar-collapsed"; const STATS_STORAGE_KEY = "sidebar-connection-stats"; const RECENT_EVENTS_CAP = 8; const SUPPORTED_LANGUAGES = ["en", "vi"] as const; type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; interface PersistedStats { eventCount: number; peakPerSec: number; lastEvent: { type: string; at: number } | null; typeCount: [string, number][]; recentEvents: { type: string; at: number }[]; } function loadCollapsed(): boolean { try { return localStorage.getItem(STORAGE_KEY) === "true"; } catch { return false; } } function loadStats(): PersistedStats { const empty: PersistedStats = { eventCount: 0, peakPerSec: 0, lastEvent: null, typeCount: [], recentEvents: [], }; try { const raw = localStorage.getItem(STATS_STORAGE_KEY); if (!raw) return empty; const parsed = JSON.parse(raw) as Partial; return { eventCount: typeof parsed.eventCount === "number" ? parsed.eventCount : 0, peakPerSec: typeof parsed.peakPerSec === "number" ? parsed.peakPerSec : 0, lastEvent: parsed.lastEvent && typeof parsed.lastEvent.type === "string" && typeof parsed.lastEvent.at === "number" ? parsed.lastEvent : null, typeCount: Array.isArray(parsed.typeCount) ? parsed.typeCount.filter( (e): e is [string, number] => Array.isArray(e) && typeof e[0] === "string" && typeof e[1] === "number" ) : [], recentEvents: Array.isArray(parsed.recentEvents) ? parsed.recentEvents .filter( (e): e is { type: string; at: number } => !!e && typeof e.type === "string" && typeof e.at === "number" ) .slice(0, RECENT_EVENTS_CAP) : [], }; } catch { return empty; } } function normalizeLanguage(language: string): SupportedLanguage { const base = language.toLowerCase().split("-")[0]; if (base === "vi" || base === "en") { return base; } return "en"; } interface SidebarProps { wsConnected: boolean; collapsed: boolean; onToggle: () => void; } export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) { const { t, i18n } = useTranslation(); const { theme, setTheme, toggleTheme } = useTheme(); // Track whether nav items are clipped by overflow so we can render // chevron affordances pointing toward the hidden items. Recomputed on // scroll, resize, and any structural change (e.g. collapse toggle). const navRef = useRef(null); const [navOverflow, setNavOverflow] = useState({ up: false, down: false }); const [updateStatus, setUpdateStatus] = useState(null); const [checking, setChecking] = useState(false); const [checkError, setCheckError] = useState(false); const [statusModalOpen, setStatusModalOpen] = useState(false); const [connectedSince, setConnectedSince] = useState( wsConnected ? Date.now() : null ); // Buffers live in refs so the sidebar isn't re-rendered on every WS event - // the modal samples them on its own tick while it's open. Cumulative buffers // (count, type breakdown, recent list) are hydrated from localStorage so they // survive page reloads; the rolling 60s sparkline buffer is intentionally // ephemeral since it's only meaningful relative to "now". const eventCountRef = useRef(0); const peakPerSecRef = useRef(0); const lastEventRef = useRef<{ type: string; at: number } | null>(null); const eventTimestampsRef = useRef([]); const typeCountRef = useRef>(new Map()); const recentEventsRef = useRef>([]); const persistTimerRef = useRef | null>(null); const recomputeNavOverflow = useCallback(() => { const el = navRef.current; if (!el) return; const up = el.scrollTop > 1; const down = el.scrollTop + el.clientHeight < el.scrollHeight - 1; setNavOverflow((prev) => (prev.up === up && prev.down === down ? prev : { up, down })); }, []); useEffect(() => { recomputeNavOverflow(); const el = navRef.current; if (!el) return; const onScroll = () => recomputeNavOverflow(); el.addEventListener("scroll", onScroll, { passive: true }); const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(recomputeNavOverflow) : null; ro?.observe(el); window.addEventListener("resize", recomputeNavOverflow); return () => { el.removeEventListener("scroll", onScroll); ro?.disconnect(); window.removeEventListener("resize", recomputeNavOverflow); }; }, [recomputeNavOverflow, collapsed]); const scrollNavBy = useCallback((delta: number) => { navRef.current?.scrollBy({ top: delta, behavior: "smooth" }); }, []); // Hydrate from localStorage on mount. useEffect(() => { const stats = loadStats(); eventCountRef.current = stats.eventCount; peakPerSecRef.current = stats.peakPerSec; lastEventRef.current = stats.lastEvent; typeCountRef.current = new Map(stats.typeCount); recentEventsRef.current = stats.recentEvents; }, []); const persistStats = useCallback(() => { try { const payload: PersistedStats = { eventCount: eventCountRef.current, peakPerSec: peakPerSecRef.current, lastEvent: lastEventRef.current, typeCount: Array.from(typeCountRef.current.entries()), recentEvents: recentEventsRef.current, }; localStorage.setItem(STATS_STORAGE_KEY, JSON.stringify(payload)); } catch { /* ignore quota / disabled storage */ } }, []); const schedulePersist = useCallback(() => { if (persistTimerRef.current) return; persistTimerRef.current = setTimeout(() => { persistTimerRef.current = null; persistStats(); }, 2000); }, [persistStats]); // Flush pending writes when the page is being hidden / unloaded so the very // latest events aren't lost to the throttle window. useEffect(() => { const flush = () => { if (persistTimerRef.current) { clearTimeout(persistTimerRef.current); persistTimerRef.current = null; } persistStats(); }; window.addEventListener("pagehide", flush); document.addEventListener("visibilitychange", flush); return () => { window.removeEventListener("pagehide", flush); document.removeEventListener("visibilitychange", flush); if (persistTimerRef.current) clearTimeout(persistTimerRef.current); }; }, [persistStats]); useEffect(() => { return eventBus.subscribe((msg: WSMessage) => { if (msg.type === "update_status") { if (isUpdatePayload(msg.data)) { setUpdateStatus(msg.data); setCheckError(Boolean(msg.data.fetch_error)); } return; } const now = Date.now(); eventCountRef.current += 1; lastEventRef.current = { type: msg.type, at: now }; eventTimestampsRef.current.push(now); // Keep only the last 60 seconds worth of timestamps for the sparkline. const cutoff = now - 60_000; const stamps = eventTimestampsRef.current; while (stamps.length > 0 && (stamps[0] as number) < cutoff) { stamps.shift(); } typeCountRef.current.set(msg.type, (typeCountRef.current.get(msg.type) ?? 0) + 1); recentEventsRef.current.unshift({ type: msg.type, at: now }); if (recentEventsRef.current.length > RECENT_EVENTS_CAP) { recentEventsRef.current.length = RECENT_EVENTS_CAP; } // All-time peak events/sec: count events landing in the trailing 1s // window ending right now. Walk from the tail (newest) backwards and // stop as soon as we cross the threshold - O(k) where k is the size of // the burst, so this stays cheap even under sustained traffic. const oneSecAgo = now - 1000; let inLastSec = 0; for (let i = stamps.length - 1; i >= 0; i--) { if ((stamps[i] as number) >= oneSecAgo) inLastSec += 1; else break; } if (inLastSec > peakPerSecRef.current) { peakPerSecRef.current = inLastSec; } schedulePersist(); }); }, [schedulePersist]); // Track when the live connection most recently came up so the modal can // show an honest "connected since" timestamp instead of stale state. useEffect(() => { if (wsConnected) { setConnectedSince((prev) => prev ?? Date.now()); } else { setConnectedSince(null); } }, [wsConnected]); const onCheckUpdates = async () => { if (checking) return; setChecking(true); setCheckError(false); // Explicit user intent - clear any prior dismissal so the modal can // re-open if this check still reports an update. try { localStorage.removeItem("agent-monitor-update-dismissed-sha"); } catch { /* ignore */ } window.dispatchEvent(new Event("dashboard:reset-update-dismissal")); try { const fresh = await api.updates.check(); setUpdateStatus(fresh); setCheckError(Boolean(fresh.fetch_error)); } catch { setCheckError(true); } finally { setChecking(false); } }; const updateAvailable = Boolean(updateStatus?.update_available); const checkTitle = checking ? t("nav:checkingForUpdates") : checkError ? t("nav:checkFailed") : updateAvailable ? t("nav:updateAvailable") : updateStatus ? t("nav:upToDate") : t("nav:checkForUpdates"); const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language); const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage); const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length]; const switchLanguageTitle = t("nav:switchLanguage", { language: t(`nav:languageNames.${nextLanguage}`), }); const nextTheme = theme === "dark" ? "light" : "dark"; const switchThemeTitle = t("nav:switchTheme", { theme: t(`nav:themeNames.${nextTheme}`) }); const toggleLang = () => { i18n.changeLanguage(nextLanguage); }; const changeLanguage = (language: SupportedLanguage) => { if (language !== currentLanguage) { i18n.changeLanguage(language); } }; return ( ); } interface ConnectionStatusModalProps { open: boolean; onClose: () => void; wsConnected: boolean; connectedSince: number | null; eventCountRef: React.MutableRefObject; peakPerSecRef: React.MutableRefObject; lastEventRef: React.MutableRefObject<{ type: string; at: number } | null>; eventTimestampsRef: React.MutableRefObject; typeCountRef: React.MutableRefObject>; recentEventsRef: React.MutableRefObject>; onResetStats: () => void; } function ConnectionStatusModal({ open, onClose, wsConnected, connectedSince, eventCountRef, peakPerSecRef, lastEventRef, eventTimestampsRef, typeCountRef, recentEventsRef, onResetStats, }: ConnectionStatusModalProps) { const { t } = useTranslation(); const [, forceTick] = useState(0); // Re-render once a second so the sparkline / relative timestamps / counts // stay honest while the modal is open. Cleared on close so we don't burn // cycles in the background. useEffect(() => { if (!open) return; const id = window.setInterval(() => forceTick((n) => n + 1), 1000); return () => window.clearInterval(id); }, [open]); const close = useCallback(() => onClose(), [onClose]); useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [open, close]); const wsUrl = useMemo(() => { if (typeof window === "undefined") return ""; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${window.location.host}/ws`; }, []); if (!open || typeof document === "undefined") return null; const eventCount = eventCountRef.current; const lastEvent = lastEventRef.current; const recentEvents = recentEventsRef.current; const buckets = bucketEventsPerSecond(eventTimestampsRef.current, 60); const eventsLastMinute = buckets.reduce((sum, n) => sum + n, 0); // All-time peak - kept persistently across the session and across reloads, // so a one-off burst doesn't disappear once it rolls off the 60s window. const peakPerSec = peakPerSecRef.current; const avgPerSec = eventsLastMinute / 60; const totalCounted = Array.from(typeCountRef.current.values()).reduce((sum, n) => sum + n, 0); const topTypes = Array.from(typeCountRef.current.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 5); const topMax = topTypes.length > 0 ? (topTypes[0] as [string, number])[1] : 0; return createPortal(
{ if (e.target === e.currentTarget) close(); }} >
{wsConnected ? ( ) : ( )}

{t("nav:connectionDetails")}

{wsConnected && ( )} {wsConnected ? t("nav:live") : t("nav:disconnected")}

{/* KPI row */}
{/* Throughput sparkline */}
{/* Connection facts */}
{/* Top event types */}
{topTypes.length === 0 ? (

{t("nav:noEventsYet")}

) : (
{topTypes.map(([type, count]) => ( ))}
)}
{/* Recent activity */}
{recentEvents.length === 0 ? (

{t("nav:noEventsYet")}

) : (
    {recentEvents.map((evt, i) => (
  • {evt.type} {formatRelative(evt.at, t)}
  • ))}
)}
{t("nav:statsPersisted")}
, document.body ); } function Section({ title, icon: Icon, children, }: { title: string; icon: LucideIcon; children: React.ReactNode; }) { return (

{title}

{children}
); } function KpiTile({ label, value, unit }: { label: string; value: string; unit: string }) { return (
{label}
{value} {unit}
); } function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
{label} {value}
); } function TypeBar({ type, count, max, total, }: { type: string; count: number; max: number; total: number; }) { const widthPct = max > 0 ? Math.max(2, (count / max) * 100) : 0; const sharePct = total > 0 ? Math.round((count / total) * 100) : 0; return (
{type} {count} · {sharePct}%
); } function Sparkline({ buckets, connected, avgLabel, }: { buckets: number[]; connected: boolean; avgLabel: string; }) { const W = 320; const H = 56; const max = Math.max(1, ...buckets); const stepX = W / Math.max(1, buckets.length - 1); const points = buckets.map((v, i) => { const x = i * stepX; const y = H - (v / max) * (H - 4) - 2; return `${x.toFixed(1)},${y.toFixed(1)}`; }); const linePath = points.length > 0 ? `M ${points.join(" L ")}` : ""; const areaPath = points.length > 0 ? `M 0,${H} L ${points.join(" L ")} L ${W},${H} Z` : ""; const stroke = connected ? "#34d399" : "#6b7280"; return (
{areaPath && } {linePath && ( )}
−60s {avgLabel} {"now"}
); } function bucketEventsPerSecond(timestamps: number[], windowSec: number): number[] { const now = Date.now(); const buckets = new Array(windowSec).fill(0); for (const ts of timestamps) { const ageSec = Math.floor((now - ts) / 1000); if (ageSec < 0 || ageSec >= windowSec) continue; // Index 0 = 60s ago, last index = now. const idx = windowSec - 1 - ageSec; buckets[idx] = (buckets[idx] ?? 0) + 1; } return buckets; } function formatRelative( timestamp: number, t: (key: string, opts?: Record) => string ): string { const diffSec = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); if (diffSec < 5) return t("nav:justNow"); if (diffSec < 60) return t("nav:secondsAgo", { count: diffSec }); const diffMin = Math.floor(diffSec / 60); if (diffMin < 60) return t("nav:minutesAgo", { count: diffMin }); const diffHr = Math.floor(diffMin / 60); if (diffHr < 24) return t("nav:hoursAgo", { count: diffHr }); const diffDay = Math.floor(diffHr / 24); return t("nav:daysAgo", { count: diffDay }); } export { STORAGE_KEY as SIDEBAR_STORAGE_KEY, loadCollapsed };