Files
AgentHub/client/src/components/Sidebar.tsx
T
nntrivi2001 b6b40b0c1b feat(admin): Administration page — Users tab + Server tab (hardware, per-user CPU/memory/home share)
/users becomes /admin (redirected), a tab shell over the existing Users view
and a new Server tab backed by GET /api/host (server/lib/host-stats.js):
hostname/OS/uptime, CPU and load, memory and swap, every real disk with
HDD/SSD, NVIDIA GPUs, and each user's live share from two /proc samples —
attributed by the member's Linux uid (S3) or, for host processes, by the
inherited AGENTHUB_LANE_ID's workspace owner. Read-only, staff-only (/host
in STAFF_ONLY_PREFIXES), comm names only. Members' tmux card moves to the
Server tab.
2026-10-15 16:33:32 +07:00

1108 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Sidebar.tsx
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot — in server mode with the signed-in account above them (its avatar, name and role, linking to Settings → Account, and Log out). It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
* - `../lib/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.
*
* ## 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).
* -----------------------------------------------------------------------------
* **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, useLocation } from "react-router-dom";
import { settingsHref } from "../lib/settingsLink";
import { useTranslation } from "react-i18next";
import { useDialog } from "../hooks/useDialog";
import {
LayoutDashboard,
FolderOpen,
HardDrive,
Gem,
BarChart3,
Settings,
Wifi,
WifiOff,
PanelLeftClose,
PanelLeftOpen,
RefreshCw,
X,
Plug,
Clock,
Gauge,
ChevronUp,
ChevronDown,
FolderTree,
LogOut,
ShieldCheck,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
import { useAuth, usePermissions } from "./AuthGate";
import { UserAvatar } from "./users/UserAvatar";
import { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree";
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: "/sessions", icon: FolderOpen, key: "nav:sessions" },
// The file manager (FilesPage): browse, upload and download workspace files.
{ to: "/files", icon: HardDrive, key: "nav:files" },
// What agents produced, and (server mode) the Hub others shared (Artifacts.tsx).
{ to: "/artifacts", icon: Gem, key: "nav:artifacts" },
] as const;
const STORAGE_KEY = "sidebar-collapsed";
const STATS_STORAGE_KEY = "sidebar-connection-stats";
const RECENT_EVENTS_CAP = 8;
interface PersistedStats {
eventCount: number;
peakPerSec: number;
lastEvent: { type: string; at: number } | null;
typeCount: [string, number][];
recentEvents: { type: string; at: number }[];
}
/** Below Tailwind's `lg` a full 15rem sidebar eats too much of the page. */
const NARROW_QUERY = "(max-width: 1023.98px)";
/** The stored choice, else collapsed on a narrow viewport. */
function loadCollapsed(): boolean {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) return stored === "true";
} catch {
/* storage off: fall through to the viewport */
}
return window.matchMedia?.(NARROW_QUERY).matches ?? 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<PersistedStats>;
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;
}
}
interface SidebarProps {
wsConnected: boolean;
collapsed: boolean;
onToggle: () => void;
/** The connection details modal, opened from here or from the status bar. */
connectionOpen: boolean;
onConnectionOpenChange: (open: boolean) => void;
/** Told whether a newer release is out, for the status bar's dot. */
onUpdateAvailableChange?: (available: boolean) => void;
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
drawer?: { open: boolean; onClose: () => void };
/** Expanded width in px, chosen by dragging its edge (see Layout). */
width?: number;
/** True while that edge is dragged: no width transition lagging the pointer. */
resizing?: boolean;
}
export function Sidebar({
wsConnected,
collapsed,
onToggle,
connectionOpen,
onConnectionOpenChange,
onUpdateAvailableChange,
drawer,
width = 240,
resizing = false,
}: SidebarProps) {
const { t } = useTranslation();
const auth = useAuth();
// On a workspace Settings opens as its tab (lib/settingsLink.ts), so the
// link's own match (the path) cannot tell when it is the one on screen.
const location = useLocation();
const settingsOn =
location.pathname.startsWith("/settings") ||
new URLSearchParams(location.search).has("settings");
// Checking for (and applying) an update acts on the host: an admin's.
const perms = usePermissions();
const canUpdate = perms.isAdmin;
const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose);
// 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<HTMLElement | null>(null);
const [navOverflow, setNavOverflow] = useState({ up: false, down: false });
const [updateStatus, setUpdateStatus] = useState<UpdateStatusPayload | null>(null);
const [checking, setChecking] = useState(false);
const [checkError, setCheckError] = useState(false);
const [connectedSince, setConnectedSince] = useState<number | null>(
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<number[]>([]);
const typeCountRef = useRef<Map<string, number>>(new Map());
const recentEventsRef = useRef<Array<{ type: string; at: number }>>([]);
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | 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);
// Four resting states, not two: a release is out, this build is ahead of the
// newest release (dev build), it matches the newest release, or no release
// could be read at all. Collapsing the middle two into "Up to date" is what
// made a checkout carrying unreleased commits look shipped.
const checkTitle = checking
? t("nav:checkingForUpdates")
: checkError
? t("nav:checkFailed")
: updateStatus?.installed_not_running
? t("nav:installedNotRunning", {
version: updateStatus.installed_not_running.version ?? "?",
})
: updateAvailable
? updateStatus?.latest_release
? t("nav:releaseAvailable", { version: updateStatus.latest_release })
: t("nav:updateAvailable")
: updateStatus?.dev_build
? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 })
: // A plugin install has no commit count to fall back on: a release
// that could not be read is "unknown", never "up to date".
updateStatus?.install_kind === "plugin" &&
updateStatus.release_check &&
updateStatus.release_check !== "ok"
? t("nav:releaseUnchecked")
: updateStatus
? t("nav:upToDate")
: t("nav:checkForUpdates");
useEffect(() => {
onUpdateAvailableChange?.(updateAvailable);
}, [updateAvailable, onUpdateAvailableChange]);
// Same density and selection as a workspace row, so the whole sidebar
// reads as one list: no border, the active item tinted with an accent icon.
const navClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-2.5 rounded-md text-[13px] transition-colors duration-150 ${
collapsed ? "justify-center px-2 py-2" : "px-2.5 py-1.5"
} ${
isActive
? "bg-accent/10 font-medium text-fg-primary [&>svg]:text-accent"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`;
return (
<aside
ref={asideRef}
aria-label={t("nav:sidebar")}
role={drawerOpen ? "dialog" : undefined}
aria-modal={drawerOpen || undefined}
data-testid="sidebar"
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col overflow-hidden ${
drawer
? // Visibility flips at once on open (a still-hidden panel cannot take
// focus) and only after the slide-out on close.
`z-50 w-60 max-w-[85vw] duration-200 ${
drawer.open
? "visible translate-x-0 shadow-2xl transition-transform"
: "invisible -translate-x-full transition-[transform,visibility]"
}`
: `z-30 duration-200 ${resizing ? "" : "transition-[width]"} ${collapsed ? "w-[4.25rem]" : ""}`
}`}
style={drawer || collapsed ? undefined : { width }}
>
<div className="px-3 py-4 border-b border-border flex-shrink-0">
<div className="flex flex-col items-center justify-center">
{collapsed ? (
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
) : (
<>
<img src={BRAND_LOGO} alt={t("nav:brandSub")} className="w-full h-auto px-2" />
{/* Wordmark under the logo: small caps + wide tracking so it reads
as a caption to the logo rather than competing with it. The
hairlines either side keep it centred on a narrow rail. */}
<div className="mt-3 flex w-full items-center gap-2 px-1">
<span className="h-px flex-1 bg-fg-muted/40" />
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-primary">
{t("nav:brand")}
</span>
<span className="h-px flex-1 bg-fg-muted/40" />
</div>
</>
)}
</div>
</div>
{/* Nav - only this section scrolls when its items overflow; the rest of
the sidebar (brand, and the Settings + collapse footer) stays pinned.
Chevron buttons appear at the edges when content is clipped, so the
user knows there's more to reach without inspecting the scrollbar. */}
<div className="flex-1 min-h-0 relative flex">
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
{[
// The file manager serves a shared server; locally it is the desktop's.
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
// Server mode: Administration (users, server), for an admin or analyst.
...(auth.state.mode === "server" &&
auth.state.user &&
auth.state.user.role !== "developer"
? [{ to: "/admin", icon: ShieldCheck, key: "nav:admin" }]
: []),
].map(({ to, icon: Icon, key }) => {
const label = t(key);
return (
<NavLink
key={to}
to={to}
end={to === "/"}
title={collapsed ? label : undefined}
className={navClass}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
);
})}
{/* The workspace → sessions list scrolls with the nav. In the rail it
is one icon that opens the sidebar instead. */}
{collapsed ? (
<button
type="button"
onClick={onToggle}
data-testid="sidebar-tree-rail"
title={t("lanes:tree.title")}
aria-label={t("lanes:tree.title")}
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<FolderTree className="w-4 h-4" />
</button>
) : (
<WorkspaceTree />
)}
</nav>
{!collapsed && navOverflow.up && (
<button
type="button"
onClick={() => scrollNavBy(-160)}
aria-label={t("nav:scrollUp")}
title={t("nav:scrollUp")}
className="absolute top-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-fg-secondary hover:text-fg-primary hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
>
<ChevronUp className="w-3.5 h-3.5" aria-hidden />
</button>
)}
{!collapsed && navOverflow.down && (
<button
type="button"
onClick={() => scrollNavBy(160)}
aria-label={t("nav:scrollDown")}
title={t("nav:scrollDown")}
className="absolute bottom-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-fg-secondary hover:text-fg-primary hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
>
<ChevronDown className="w-3.5 h-3.5" aria-hidden />
</button>
)}
</div>
{/* Server mode: who is signed in — avatar, name and role, opening
Settings → Account — and Log out. On the rail they stack. */}
{auth.state.mode === "server" &&
auth.state.user &&
(() => {
const user = auth.state.user;
const name = user.displayName || user.username;
const role = t(`users:roles.${user.role}`);
const signedIn = t("common:auth.signedInAs", { name, role });
return (
<div
data-testid="sidebar-account"
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
collapsed ? "flex-col items-center" : "items-center"
}`}
>
<NavLink
to={settingsHref("account", location)}
title={signedIn}
aria-label={signedIn}
className={`flex min-w-0 items-center gap-2 rounded-md transition-colors hover:bg-surface-3 ${
collapsed ? "justify-center p-1" : "flex-1 px-1.5 py-1"
}`}
>
<UserAvatar
name={name}
avatarAt={user.avatarAt}
className={collapsed ? "h-7 w-7 text-[11px]" : "h-8 w-8 text-xs"}
/>
{!collapsed && (
<span className="min-w-0 leading-tight">
<span className="block truncate text-sm font-medium text-fg-primary">
{name}
</span>
<span className="block truncate text-[11px] text-fg-muted">{role}</span>
</span>
)}
</NavLink>
<button
type="button"
onClick={() => void auth.logout()}
title={`${signedIn} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "w-full py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
<LogOut className="h-4 w-4" />
</button>
</div>
);
})()}
{/* Settings and the collapse toggle stick to the foot, out of the
scrolling nav. On the rail they stack. */}
<div
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
collapsed ? "flex-col" : "items-center"
}`}
>
<NavLink
to={settingsHref("", location)}
title={collapsed ? t("nav:settings") : undefined}
className={() => `${navClass({ isActive: settingsOn })} ${collapsed ? "" : "flex-1"}`}
>
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
</NavLink>
<button
type="button"
onClick={onToggle}
data-testid="sidebar-toggle"
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</button>
</div>
<ConnectionStatusModal
open={connectionOpen}
onClose={() => onConnectionOpenChange(false)}
wsConnected={wsConnected}
connectedSince={connectedSince}
eventCountRef={eventCountRef}
peakPerSecRef={peakPerSecRef}
lastEventRef={lastEventRef}
eventTimestampsRef={eventTimestampsRef}
typeCountRef={typeCountRef}
recentEventsRef={recentEventsRef}
update={{
title: checkTitle,
checking,
available: updateAvailable,
failed: checkError,
onCheck: canUpdate ? onCheckUpdates : undefined,
}}
onResetStats={() => {
eventCountRef.current = 0;
peakPerSecRef.current = 0;
lastEventRef.current = null;
eventTimestampsRef.current = [];
typeCountRef.current = new Map();
recentEventsRef.current = [];
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}
try {
localStorage.removeItem(STATS_STORAGE_KEY);
} catch {
/* ignore */
}
}}
/>
</aside>
);
}
interface ConnectionStatusModalProps {
open: boolean;
onClose: () => void;
wsConnected: boolean;
connectedSince: number | null;
eventCountRef: React.MutableRefObject<number>;
peakPerSecRef: React.MutableRefObject<number>;
lastEventRef: React.MutableRefObject<{ type: string; at: number } | null>;
eventTimestampsRef: React.MutableRefObject<number[]>;
typeCountRef: React.MutableRefObject<Map<string, number>>;
recentEventsRef: React.MutableRefObject<Array<{ type: string; at: number }>>;
/** The release check: its one-line state ("Up to date", "Dev build · N
* ahead", a release out…) and the button that re-runs it. */
update: {
title: string;
checking: boolean;
available: boolean;
failed: boolean;
/** Absent for a user who may not update (server mode, not an admin). */
onCheck?: () => void;
};
onResetStats: () => void;
}
function ConnectionStatusModal({
open,
onClose,
wsConnected,
connectedSince,
eventCountRef,
peakPerSecRef,
lastEventRef,
eventTimestampsRef,
typeCountRef,
recentEventsRef,
update,
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]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(open, panelRef, 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(
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="connection-status-title"
className="w-full max-w-md card shadow-2xl animate-slide-up overflow-hidden flex flex-col max-h-[85vh]"
>
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<div
className={`w-9 h-9 rounded-lg border flex items-center justify-center flex-shrink-0 ${
wsConnected
? "bg-status-success/10 border-status-success/30 text-status-success"
: "bg-surface-3 border-border text-fg-secondary"
}`}
>
{wsConnected ? (
<Wifi className="w-4 h-4" aria-hidden />
) : (
<WifiOff className="w-4 h-4" aria-hidden />
)}
</div>
<div className="min-w-0">
<h2
id="connection-status-title"
className="text-base font-semibold text-fg-primary truncate tracking-tight leading-tight"
>
{t("nav:connectionDetails")}
</h2>
<p
className={`text-[11px] font-medium inline-flex items-center gap-1.5 leading-tight ${
wsConnected ? "text-status-success" : "text-fg-muted"
}`}
>
{wsConnected && (
<span className="relative flex w-1.5 h-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-success opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-status-success" />
</span>
)}
{wsConnected ? t("nav:live") : t("nav:disconnected")}
</p>
</div>
</div>
<button
type="button"
onClick={close}
aria-label={t("nav:close")}
className="btn-icon -m-1 flex-shrink-0"
>
<X className="w-4 h-4" aria-hidden />
</button>
</div>
<div className="px-5 py-4 space-y-5 overflow-y-auto">
{/* Version + release check */}
<Section title={t("nav:version")} icon={RefreshCw}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-mono text-sm text-fg-primary">{`v${__APP_VERSION__}`}</p>
<p
data-testid="update-status"
className={`mt-0.5 inline-flex items-center gap-1.5 text-xs ${
update.available
? "text-status-success"
: update.failed
? "text-status-warning"
: "text-fg-muted"
}`}
>
{update.available && (
<span className="h-1.5 w-1.5 rounded-full bg-status-success" />
)}
{update.title}
</p>
</div>
{update.onCheck && (
<button
type="button"
onClick={update.onCheck}
disabled={update.checking}
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
>
<RefreshCw
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
aria-hidden
/>
{t("nav:checkForUpdates")}
</button>
)}
</div>
</Section>
{/* KPI row */}
<div className="grid grid-cols-3 gap-2">
<KpiTile
label={t("nav:eventsTotal")}
value={eventCount.toLocaleString()}
unit={t("nav:unitEvents")}
/>
<KpiTile
label={t("nav:eventsLastMin")}
value={eventsLastMinute.toLocaleString()}
unit={t("nav:unitEvents")}
/>
<KpiTile
label={t("nav:peakRate")}
value={peakPerSec.toLocaleString()}
unit={t("nav:unitPerSec")}
/>
</div>
{/* Throughput sparkline */}
<Section title={t("nav:throughput60s")} icon={Gauge}>
<Sparkline
buckets={buckets}
connected={wsConnected}
avgLabel={`${avgPerSec.toFixed(2)}/s ${t("nav:avg")}`}
/>
</Section>
{/* Connection facts */}
<Section title={t("nav:connection")} icon={Plug}>
<div className="space-y-2">
<DetailRow label={t("nav:wsEndpoint")} value={wsUrl} mono />
<DetailRow
label={t("nav:connectionUptime")}
value={connectedSince ? formatRelative(connectedSince, t) : t("nav:notConnected")}
/>
<DetailRow
label={t("nav:lastEvent")}
value={
lastEvent
? `${lastEvent.type} · ${formatRelative(lastEvent.at, t)}`
: t("nav:noEventsYet")
}
mono={Boolean(lastEvent)}
/>
</div>
</Section>
{/* Top event types */}
<Section title={t("nav:topEventTypes")} icon={BarChart3}>
{topTypes.length === 0 ? (
<p className="text-xs text-fg-muted italic">{t("nav:noEventsYet")}</p>
) : (
<div className="space-y-1.5">
{topTypes.map(([type, count]) => (
<TypeBar key={type} type={type} count={count} max={topMax} total={totalCounted} />
))}
</div>
)}
</Section>
{/* Recent activity */}
<Section title={t("nav:recentActivity")} icon={Clock}>
{recentEvents.length === 0 ? (
<p className="text-xs text-fg-muted italic">{t("nav:noEventsYet")}</p>
) : (
<ul className="space-y-1">
{recentEvents.map((evt, i) => (
<li
key={`${evt.at}-${i}`}
className="flex items-center justify-between gap-3 text-[11px] font-mono px-2 py-1 rounded bg-surface-2/50"
>
<span className="text-fg-secondary truncate">{evt.type}</span>
<span className="text-fg-muted flex-shrink-0">{formatRelative(evt.at, t)}</span>
</li>
))}
</ul>
)}
</Section>
</div>
<div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
<span className="text-[10px] text-fg-muted">{t("nav:statsPersisted")}</span>
<button
type="button"
onClick={onResetStats}
className="text-[11px] font-medium text-fg-secondary hover:text-fg-primary hover:bg-surface-3 px-2 py-1 rounded transition-colors"
>
{t("nav:resetStats")}
</button>
</div>
</div>
</div>,
document.body
);
}
function Section({
title,
icon: Icon,
children,
}: {
title: string;
icon: LucideIcon;
children: React.ReactNode;
}) {
return (
<section>
<div className="flex items-center gap-2 pb-2 mb-3 border-b border-border/60">
<span className="w-5 h-5 rounded-md bg-accent/15 border border-accent/25 flex items-center justify-center flex-shrink-0">
<Icon className="w-3 h-3 text-accent" aria-hidden />
</span>
<h3 className="text-[13px] font-semibold text-fg-primary tracking-tight">{title}</h3>
</div>
{children}
</section>
);
}
function KpiTile({ label, value, unit }: { label: string; value: string; unit: string }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-2.5 py-2">
<div className="text-[9px] font-semibold uppercase tracking-wider text-fg-muted truncate">
{label}
</div>
<div className="mt-0.5 flex items-baseline gap-1 truncate">
<span className="text-base font-semibold text-fg-primary font-mono">{value}</span>
<span className="text-[10px] font-medium text-fg-muted truncate">{unit}</span>
</div>
</div>
);
}
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-start justify-between gap-3 text-xs">
<span className="text-fg-muted font-medium uppercase tracking-wider text-[10px] pt-0.5">
{label}
</span>
<span className={`text-fg-secondary text-right break-all min-w-0 ${mono ? "font-mono" : ""}`}>
{value}
</span>
</div>
);
}
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 (
<div className="text-[11px]">
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="font-mono text-fg-secondary truncate">{type}</span>
<span className="text-fg-muted flex-shrink-0 font-mono">
{count} · {sharePct}%
</span>
</div>
<div className="h-1.5 rounded-full bg-surface-3 overflow-hidden">
<div
className="h-full bg-accent/70 rounded-full transition-[width] duration-300"
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
);
}
function Sparkline({
buckets,
connected,
avgLabel,
}: {
buckets: number[];
connected: boolean;
avgLabel: string;
}) {
const { t } = useTranslation();
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 ? "rgb(var(--status-success))" : "rgb(var(--fg-muted))";
return (
<div className="rounded-lg border border-border bg-surface-2 p-2.5">
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-14" aria-hidden>
<defs>
<linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" style={{ stopColor: stroke, stopOpacity: 0.35 }} />
<stop offset="100%" style={{ stopColor: stroke, stopOpacity: 0 }} />
</linearGradient>
</defs>
{areaPath && <path d={areaPath} fill="url(#spark-fill)" />}
{linePath && (
<path
d={linePath}
fill="none"
style={{ stroke }}
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
<div className="flex items-center justify-between mt-1.5 text-[10px] text-fg-muted font-mono">
<span>−60s</span>
<span>{avgLabel}</span>
<span>{t("nav:now")}</span>
</div>
</div>
);
}
function bucketEventsPerSecond(timestamps: number[], windowSec: number): number[] {
const now = Date.now();
const buckets = new Array<number>(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, unknown>) => 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 };