diff --git a/src/components/system-metrics-footer.tsx b/src/components/system-metrics-footer.tsx new file mode 100644 index 000000000..92648bd68 --- /dev/null +++ b/src/components/system-metrics-footer.tsx @@ -0,0 +1,178 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { cn } from '@/lib/utils' + +type SystemMetrics = { + checkedAt: number + cpu: { + loadPercent: number + loadAverage1m: number + cores: number + } + memory: { + usedBytes: number + totalBytes: number + usedPercent: number + } + disk: { + path: string + usedBytes: number + totalBytes: number + usedPercent: number + } + hermes: { + status: 'connected' | 'enhanced' | 'partial' | 'disconnected' + health: boolean + dashboard: boolean + } +} + +async function fetchSystemMetrics(): Promise { + const response = await fetch('/api/system-metrics', { cache: 'no-store' }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return response.json() as Promise +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unit = 0 + + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + + return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}` +} + +function metricTone(percent: number): 'normal' | 'warn' | 'critical' { + if (percent >= 90) return 'critical' + if (percent >= 75) return 'warn' + return 'normal' +} + +function formatCheckedAt(checkedAt: number): string { + const ageSeconds = Math.max(0, Math.round((Date.now() - checkedAt) / 1000)) + if (ageSeconds < 5) return 'now' + if (ageSeconds < 60) return `${ageSeconds}s ago` + + const ageMinutes = Math.round(ageSeconds / 60) + return `${ageMinutes}m ago` +} + +function MetricItem({ + label, + value, + tone = 'normal', +}: { + label: string + value: string + tone?: 'normal' | 'warn' | 'critical' | 'muted' | 'accent' +}) { + return ( + + + {label} + + {value} + + ) +} + +function Separator() { + return +} + +function StatusDot({ tone }: { tone: 'ok' | 'warn' | 'critical' | 'muted' }) { + return ( + + ) +} + +export function SystemMetricsFooter({ leftOffsetPx = 0 }: { leftOffsetPx?: number }) { + const { data, isError } = useQuery({ + queryKey: ['system-metrics-footer'], + queryFn: fetchSystemMetrics, + refetchInterval: 15_000, + staleTime: 14_000, + }) + + const hermesHealthy = data?.hermes.status === 'connected' || data?.hermes.status === 'enhanced' + const hermesTone = hermesHealthy ? 'accent' : data?.hermes.status === 'disconnected' ? 'critical' : 'warn' + const hermesDotTone = hermesHealthy ? 'ok' : data?.hermes.status === 'disconnected' ? 'critical' : 'warn' + + return ( +
+
+ {data ? ( + <> + + + + + + + + + + + + + + ) : ( + + + + + )} +
+
+ ) +} diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index 998bf9b61..888e76dd5 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -39,7 +39,7 @@ import { MobilePageHeader } from '@/components/mobile-page-header' import { MobileTerminalInput } from '@/components/terminal/mobile-terminal-input' import { ClaudeReconnectBanner } from '@/components/claude-reconnect-banner' import { useMobileKeyboard } from '@/hooks/use-mobile-keyboard' -// System metrics footer removed — not used in Hermes Workspace +import { SystemMetricsFooter } from '@/components/system-metrics-footer' import { CommandPalette } from '@/components/command-palette' import { useSettings } from '@/hooks/use-settings' // ActivityTicker moved to dashboard-only (too noisy for global header) @@ -321,7 +321,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) { : !isMobile && !isOnChatRoute && settings.showSystemMetricsFooter - ? 'pb-[calc(1.5rem+1.75rem)]' + ? 'pb-7' : '', ].join(' ')} data-tour="chat-area" @@ -393,7 +393,9 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) { - {/* System metrics footer removed */} + {!isMobile && !isOnChatRoute && settings.showSystemMetricsFooter ? ( + + ) : null} ) diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts index a862abc95..821519ed0 100644 --- a/src/hooks/use-settings.ts +++ b/src/hooks/use-settings.ts @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { create } from 'zustand' import { persist } from 'zustand/middleware' import { getTheme, setTheme } from '@/lib/theme' @@ -72,6 +73,10 @@ export const useSettingsStore = create()( ) export function useSettings() { + useEffect(() => { + void useSettingsStore.persist.rehydrate() + }, []) + const settings = useSettingsStore(function selectSettings(state) { return state.settings }) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 8aff5b0c8..de8b6a395 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -34,6 +34,7 @@ import { Route as ApiTerminalStreamRouteImport } from './routes/api/terminal-str import { Route as ApiTerminalResizeRouteImport } from './routes/api/terminal-resize' import { Route as ApiTerminalInputRouteImport } from './routes/api/terminal-input' import { Route as ApiTerminalCloseRouteImport } from './routes/api/terminal-close' +import { Route as ApiSystemMetricsRouteImport } from './routes/api/system-metrics' import { Route as ApiSwarmTmuxStopRouteImport } from './routes/api/swarm-tmux-stop' import { Route as ApiSwarmTmuxStartRouteImport } from './routes/api/swarm-tmux-start' import { Route as ApiSwarmTmuxScrollRouteImport } from './routes/api/swarm-tmux-scroll' @@ -252,6 +253,11 @@ const ApiTerminalCloseRoute = ApiTerminalCloseRouteImport.update({ path: '/api/terminal-close', getParentRoute: () => rootRouteImport, } as any) +const ApiSystemMetricsRoute = ApiSystemMetricsRouteImport.update({ + id: '/api/system-metrics', + path: '/api/system-metrics', + getParentRoute: () => rootRouteImport, +} as any) const ApiSwarmTmuxStopRoute = ApiSwarmTmuxStopRouteImport.update({ id: '/api/swarm-tmux-stop', path: '/api/swarm-tmux-stop', @@ -787,6 +793,7 @@ export interface FileRoutesByFullPath { '/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute '/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute '/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute + '/api/system-metrics': typeof ApiSystemMetricsRoute '/api/terminal-close': typeof ApiTerminalCloseRoute '/api/terminal-input': typeof ApiTerminalInputRoute '/api/terminal-resize': typeof ApiTerminalResizeRoute @@ -905,6 +912,7 @@ export interface FileRoutesByTo { '/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute '/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute '/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute + '/api/system-metrics': typeof ApiSystemMetricsRoute '/api/terminal-close': typeof ApiTerminalCloseRoute '/api/terminal-input': typeof ApiTerminalInputRoute '/api/terminal-resize': typeof ApiTerminalResizeRoute @@ -1025,6 +1033,7 @@ export interface FileRoutesById { '/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute '/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute '/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute + '/api/system-metrics': typeof ApiSystemMetricsRoute '/api/terminal-close': typeof ApiTerminalCloseRoute '/api/terminal-input': typeof ApiTerminalInputRoute '/api/terminal-resize': typeof ApiTerminalResizeRoute @@ -1146,6 +1155,7 @@ export interface FileRouteTypes { | '/api/swarm-tmux-scroll' | '/api/swarm-tmux-start' | '/api/swarm-tmux-stop' + | '/api/system-metrics' | '/api/terminal-close' | '/api/terminal-input' | '/api/terminal-resize' @@ -1264,6 +1274,7 @@ export interface FileRouteTypes { | '/api/swarm-tmux-scroll' | '/api/swarm-tmux-start' | '/api/swarm-tmux-stop' + | '/api/system-metrics' | '/api/terminal-close' | '/api/terminal-input' | '/api/terminal-resize' @@ -1383,6 +1394,7 @@ export interface FileRouteTypes { | '/api/swarm-tmux-scroll' | '/api/swarm-tmux-start' | '/api/swarm-tmux-stop' + | '/api/system-metrics' | '/api/terminal-close' | '/api/terminal-input' | '/api/terminal-resize' @@ -1503,6 +1515,7 @@ export interface RootRouteChildren { ApiSwarmTmuxScrollRoute: typeof ApiSwarmTmuxScrollRoute ApiSwarmTmuxStartRoute: typeof ApiSwarmTmuxStartRoute ApiSwarmTmuxStopRoute: typeof ApiSwarmTmuxStopRoute + ApiSystemMetricsRoute: typeof ApiSystemMetricsRoute ApiTerminalCloseRoute: typeof ApiTerminalCloseRoute ApiTerminalInputRoute: typeof ApiTerminalInputRoute ApiTerminalResizeRoute: typeof ApiTerminalResizeRoute @@ -1711,6 +1724,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiTerminalCloseRouteImport parentRoute: typeof rootRouteImport } + '/api/system-metrics': { + id: '/api/system-metrics' + path: '/api/system-metrics' + fullPath: '/api/system-metrics' + preLoaderRoute: typeof ApiSystemMetricsRouteImport + parentRoute: typeof rootRouteImport + } '/api/swarm-tmux-stop': { id: '/api/swarm-tmux-stop' path: '/api/swarm-tmux-stop' @@ -2545,6 +2565,7 @@ const rootRouteChildren: RootRouteChildren = { ApiSwarmTmuxScrollRoute: ApiSwarmTmuxScrollRoute, ApiSwarmTmuxStartRoute: ApiSwarmTmuxStartRoute, ApiSwarmTmuxStopRoute: ApiSwarmTmuxStopRoute, + ApiSystemMetricsRoute: ApiSystemMetricsRoute, ApiTerminalCloseRoute: ApiTerminalCloseRoute, ApiTerminalInputRoute: ApiTerminalInputRoute, ApiTerminalResizeRoute: ApiTerminalResizeRoute, diff --git a/src/routes/api/system-metrics.ts b/src/routes/api/system-metrics.ts new file mode 100644 index 000000000..1fd114ba1 --- /dev/null +++ b/src/routes/api/system-metrics.ts @@ -0,0 +1,117 @@ +import fs from 'node:fs' +import os from 'node:os' +import { createFileRoute } from '@tanstack/react-router' +import { isAuthenticated } from '../../server/auth-middleware' +import { + ensureGatewayProbed, + getConnectionStatus, +} from '../../server/gateway-capabilities' + +type SystemMetricsResponse = { + checkedAt: number + cpu: { + loadPercent: number + loadAverage1m: number + cores: number + } + memory: { + usedBytes: number + totalBytes: number + usedPercent: number + } + disk: { + path: string + usedBytes: number + totalBytes: number + usedPercent: number + } + hermes: { + status: 'connected' | 'enhanced' | 'partial' | 'disconnected' + health: boolean + dashboard: boolean + } +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.max(0, Math.min(100, Math.round(value))) +} + +function readCpu() { + const cores = Math.max(1, os.cpus().length) + const loadAverage1m = os.loadavg()[0] ?? 0 + const loadPercent = clampPercent((loadAverage1m / cores) * 100) + + return { + loadPercent, + loadAverage1m: Math.round(loadAverage1m * 100) / 100, + cores, + } +} + +function readMemory() { + const totalBytes = os.totalmem() + const freeBytes = os.freemem() + const usedBytes = Math.max(0, totalBytes - freeBytes) + const usedPercent = clampPercent((usedBytes / totalBytes) * 100) + + return { + usedBytes, + totalBytes, + usedPercent, + } +} + +function readDisk() { + const diskPath = process.env.HERMES_WORKSPACE_METRICS_DISK_PATH || os.homedir() + + try { + const stats = fs.statfsSync(diskPath) + const totalBytes = stats.blocks * stats.bsize + const freeBytes = stats.bavail * stats.bsize + const usedBytes = Math.max(0, totalBytes - freeBytes) + const usedPercent = totalBytes > 0 ? clampPercent((usedBytes / totalBytes) * 100) : 0 + + return { + path: diskPath, + usedBytes, + totalBytes, + usedPercent, + } + } catch { + return { + path: diskPath, + usedBytes: 0, + totalBytes: 0, + usedPercent: 0, + } + } +} + +export const Route = createFileRoute('/api/system-metrics')({ + server: { + handlers: { + GET: async ({ request }) => { + const authResult = isAuthenticated(request) + if (authResult !== true) return authResult as unknown as Response + + const caps = await ensureGatewayProbed() + const status = getConnectionStatus() + + const body: SystemMetricsResponse = { + checkedAt: Date.now(), + cpu: readCpu(), + memory: readMemory(), + disk: readDisk(), + hermes: { + status, + health: caps.health, + dashboard: caps.dashboard.available, + }, + } + + return Response.json(body) + }, + }, + }, +})