From 67a23e02d979b7c7a923a53f58cee3db99893bdf Mon Sep 17 00:00:00 2001 From: dontcallmejames Date: Sat, 2 May 2026 18:12:01 -0400 Subject: [PATCH 1/3] feat: restore system metrics footer --- src/components/system-metrics-footer.tsx | 155 +++++++++++++++++++++++ src/components/workspace-shell.tsx | 6 +- src/hooks/use-settings.ts | 5 + src/routeTree.gen.ts | 21 +++ src/routes/api/system-metrics.ts | 117 +++++++++++++++++ 5 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 src/components/system-metrics-footer.tsx create mode 100644 src/routes/api/system-metrics.ts diff --git a/src/components/system-metrics-footer.tsx b/src/components/system-metrics-footer.tsx new file mode 100644 index 000000000..207aedfc2 --- /dev/null +++ b/src/components/system-metrics-footer.tsx @@ -0,0 +1,155 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { HugeiconsIcon } from '@hugeicons/react' +import { + CheckmarkCircle02Icon, + CpuIcon, + DatabaseIcon, + HardDriveIcon, + WifiDisconnected02Icon, +} from '@hugeicons/core-free-icons' +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): 'good' | 'warn' | 'hot' { + if (percent >= 90) return 'hot' + if (percent >= 75) return 'warn' + return 'good' +} + +function MetricPill({ + icon, + label, + value, + tone = 'good', +}: { + icon: typeof CpuIcon + label: string + value: string + tone?: 'good' | 'warn' | 'hot' | 'muted' +}) { + return ( +
+ + {label} + {value} +
+ ) +} + +export function SystemMetricsFooter() { + const { data, isError, isFetching } = useQuery({ + queryKey: ['system-metrics-footer'], + queryFn: fetchSystemMetrics, + refetchInterval: 5_000, + staleTime: 4_000, + }) + + const hermesHealthy = data?.hermes.status === 'connected' || data?.hermes.status === 'enhanced' + + return ( +
+
+ {data ? ( + <> + + + + + + ) : ( + + )} + {isFetching && data ? ( + + refreshing + + ) : null} +
+
+ ) +} diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index 998bf9b61..64a78a8b6 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) @@ -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) + }, + }, + }, +}) From 7e3009d95a20d729af3da4f0e875d579b18d3988 Mon Sep 17 00:00:00 2001 From: dontcallmejames Date: Sat, 2 May 2026 18:20:38 -0400 Subject: [PATCH 2/3] fix: stabilize system metrics footer layout --- src/components/system-metrics-footer.tsx | 18 +++++++----------- src/components/workspace-shell.tsx | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/components/system-metrics-footer.tsx b/src/components/system-metrics-footer.tsx index 207aedfc2..6345a675f 100644 --- a/src/components/system-metrics-footer.tsx +++ b/src/components/system-metrics-footer.tsx @@ -92,23 +92,24 @@ function MetricPill({ ) } -export function SystemMetricsFooter() { - const { data, isError, isFetching } = useQuery({ +export function SystemMetricsFooter({ leftOffsetPx = 0 }: { leftOffsetPx?: number }) { + const { data, isError } = useQuery({ queryKey: ['system-metrics-footer'], queryFn: fetchSystemMetrics, - refetchInterval: 5_000, - staleTime: 4_000, + refetchInterval: 15_000, + staleTime: 14_000, }) const hermesHealthy = data?.hermes.status === 'connected' || data?.hermes.status === 'enhanced' return (
-
+
{data ? ( <> )} - {isFetching && data ? ( - - refreshing - - ) : null}
) diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index 64a78a8b6..d8a6bb825 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -394,7 +394,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) { {!isMobile && !isOnChatRoute && settings.showSystemMetricsFooter ? ( - + ) : null} From edd001a6d9744f41bdcc5383f0b646eb6ab00f92 Mon Sep 17 00:00:00 2001 From: dontcallmejames Date: Sat, 2 May 2026 19:19:50 -0400 Subject: [PATCH 3/3] style: refine system metrics footer chrome --- src/components/system-metrics-footer.tsx | 125 ++++++++++++++--------- src/components/workspace-shell.tsx | 2 +- 2 files changed, 77 insertions(+), 50 deletions(-) diff --git a/src/components/system-metrics-footer.tsx b/src/components/system-metrics-footer.tsx index 6345a675f..92648bd68 100644 --- a/src/components/system-metrics-footer.tsx +++ b/src/components/system-metrics-footer.tsx @@ -1,14 +1,6 @@ 'use client' import { useQuery } from '@tanstack/react-query' -import { HugeiconsIcon } from '@hugeicons/react' -import { - CheckmarkCircle02Icon, - CpuIcon, - DatabaseIcon, - HardDriveIcon, - WifiDisconnected02Icon, -} from '@hugeicons/core-free-icons' import { cn } from '@/lib/utils' type SystemMetrics = { @@ -57,38 +49,65 @@ function formatBytes(bytes: number): string { return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}` } -function metricTone(percent: number): 'good' | 'warn' | 'hot' { - if (percent >= 90) return 'hot' +function metricTone(percent: number): 'normal' | 'warn' | 'critical' { + if (percent >= 90) return 'critical' if (percent >= 75) return 'warn' - return 'good' + return 'normal' } -function MetricPill({ - icon, +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 = 'good', + tone = 'normal', }: { - icon: typeof CpuIcon label: string value: string - tone?: 'good' | 'warn' | 'hot' | 'muted' + tone?: 'normal' | 'warn' | 'critical' | 'muted' | 'accent' }) { return ( -
- - {label} - {value} -
+ + {label} + + {value} + + ) +} + +function Separator() { + return +} + +function StatusDot({ tone }: { tone: 'ok' | 'warn' | 'critical' | 'muted' }) { + return ( + ) } @@ -101,49 +120,57 @@ export function SystemMetricsFooter({ leftOffsetPx = 0 }: { leftOffsetPx?: numbe }) 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 ( diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index d8a6bb825..888e76dd5 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -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"