b6b40b0c1b
/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.
1108 lines
42 KiB
TypeScript
1108 lines
42 KiB
TypeScript
/**
|
||
* @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 };
|