Skip to content

Commit 0ce4dc5

Browse files
feat: restore system metrics footer (#246)
* feat: restore system metrics footer * fix: stabilize system metrics footer layout * style: refine system metrics footer chrome --------- Co-authored-by: dontcallmejames <dontcallmejames@users.noreply.github.com>
1 parent b5f4dc9 commit 0ce4dc5

5 files changed

Lines changed: 326 additions & 3 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
'use client'
2+
3+
import { useQuery } from '@tanstack/react-query'
4+
import { cn } from '@/lib/utils'
5+
6+
type SystemMetrics = {
7+
checkedAt: number
8+
cpu: {
9+
loadPercent: number
10+
loadAverage1m: number
11+
cores: number
12+
}
13+
memory: {
14+
usedBytes: number
15+
totalBytes: number
16+
usedPercent: number
17+
}
18+
disk: {
19+
path: string
20+
usedBytes: number
21+
totalBytes: number
22+
usedPercent: number
23+
}
24+
hermes: {
25+
status: 'connected' | 'enhanced' | 'partial' | 'disconnected'
26+
health: boolean
27+
dashboard: boolean
28+
}
29+
}
30+
31+
async function fetchSystemMetrics(): Promise<SystemMetrics> {
32+
const response = await fetch('/api/system-metrics', { cache: 'no-store' })
33+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
34+
return response.json() as Promise<SystemMetrics>
35+
}
36+
37+
function formatBytes(bytes: number): string {
38+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
39+
40+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
41+
let value = bytes
42+
let unit = 0
43+
44+
while (value >= 1024 && unit < units.length - 1) {
45+
value /= 1024
46+
unit += 1
47+
}
48+
49+
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
50+
}
51+
52+
function metricTone(percent: number): 'normal' | 'warn' | 'critical' {
53+
if (percent >= 90) return 'critical'
54+
if (percent >= 75) return 'warn'
55+
return 'normal'
56+
}
57+
58+
function formatCheckedAt(checkedAt: number): string {
59+
const ageSeconds = Math.max(0, Math.round((Date.now() - checkedAt) / 1000))
60+
if (ageSeconds < 5) return 'now'
61+
if (ageSeconds < 60) return `${ageSeconds}s ago`
62+
63+
const ageMinutes = Math.round(ageSeconds / 60)
64+
return `${ageMinutes}m ago`
65+
}
66+
67+
function MetricItem({
68+
label,
69+
value,
70+
tone = 'normal',
71+
}: {
72+
label: string
73+
value: string
74+
tone?: 'normal' | 'warn' | 'critical' | 'muted' | 'accent'
75+
}) {
76+
return (
77+
<span
78+
className={cn(
79+
'inline-flex min-w-0 items-baseline gap-1.5 whitespace-nowrap',
80+
tone === 'normal' && 'text-[var(--theme-text)]/80',
81+
tone === 'warn' && 'text-amber-300/90',
82+
tone === 'critical' && 'text-red-300/95',
83+
tone === 'muted' && 'text-[var(--theme-muted)]',
84+
tone === 'accent' && 'text-[var(--theme-accent)]',
85+
)}
86+
>
87+
<span className="text-[9px] font-medium uppercase tracking-[0.16em] text-[var(--theme-muted)]">
88+
{label}
89+
</span>
90+
<span className="truncate font-medium tabular-nums">{value}</span>
91+
</span>
92+
)
93+
}
94+
95+
function Separator() {
96+
return <span className="h-3 w-px shrink-0 bg-[var(--theme-border)]" aria-hidden />
97+
}
98+
99+
function StatusDot({ tone }: { tone: 'ok' | 'warn' | 'critical' | 'muted' }) {
100+
return (
101+
<span
102+
className={cn(
103+
'inline-block size-1.5 rounded-full',
104+
tone === 'ok' && 'bg-[var(--theme-accent)]',
105+
tone === 'warn' && 'bg-amber-300/90',
106+
tone === 'critical' && 'bg-red-300/95',
107+
tone === 'muted' && 'bg-[var(--theme-muted)]',
108+
)}
109+
aria-hidden
110+
/>
111+
)
112+
}
113+
114+
export function SystemMetricsFooter({ leftOffsetPx = 0 }: { leftOffsetPx?: number }) {
115+
const { data, isError } = useQuery({
116+
queryKey: ['system-metrics-footer'],
117+
queryFn: fetchSystemMetrics,
118+
refetchInterval: 15_000,
119+
staleTime: 14_000,
120+
})
121+
122+
const hermesHealthy = data?.hermes.status === 'connected' || data?.hermes.status === 'enhanced'
123+
const hermesTone = hermesHealthy ? 'accent' : data?.hermes.status === 'disconnected' ? 'critical' : 'warn'
124+
const hermesDotTone = hermesHealthy ? 'ok' : data?.hermes.status === 'disconnected' ? 'critical' : 'warn'
125+
126+
return (
127+
<footer
128+
className="fixed bottom-0 right-0 z-40 hidden h-7 items-center border-t border-[var(--theme-border)] bg-[var(--theme-card)] px-4 text-[11px] leading-none text-[var(--theme-text)] shadow-[inset_0_1px_0_rgba(255,255,255,0.025)] md:flex"
129+
data-testid="system-metrics-footer"
130+
aria-label="System metrics footer"
131+
style={{ left: leftOffsetPx }}
132+
>
133+
<div className="flex max-w-full items-center justify-center gap-3 overflow-hidden opacity-85">
134+
{data ? (
135+
<>
136+
<MetricItem
137+
label="CPU"
138+
value={`${data.cpu.loadPercent}%`}
139+
tone={metricTone(data.cpu.loadPercent)}
140+
/>
141+
<Separator />
142+
<MetricItem
143+
label="RAM"
144+
value={`${formatBytes(data.memory.usedBytes)} / ${formatBytes(data.memory.totalBytes)}`}
145+
tone={metricTone(data.memory.usedPercent)}
146+
/>
147+
<Separator />
148+
<MetricItem
149+
label="Disk"
150+
value={`${data.disk.usedPercent}%`}
151+
tone={metricTone(data.disk.usedPercent)}
152+
/>
153+
<Separator />
154+
<span className="inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap">
155+
<StatusDot tone={hermesDotTone} />
156+
<MetricItem label="Hermes" value={data.hermes.status} tone={hermesTone} />
157+
</span>
158+
<Separator />
159+
<MetricItem
160+
label="Updated"
161+
value={formatCheckedAt(data.checkedAt)}
162+
tone="muted"
163+
/>
164+
</>
165+
) : (
166+
<span className="inline-flex items-center gap-2 whitespace-nowrap text-[var(--theme-muted)]">
167+
<StatusDot tone={isError ? 'warn' : 'muted'} />
168+
<MetricItem
169+
label="Metrics"
170+
value={isError ? 'unavailable' : 'loading'}
171+
tone={isError ? 'warn' : 'muted'}
172+
/>
173+
</span>
174+
)}
175+
</div>
176+
</footer>
177+
)
178+
}

src/components/workspace-shell.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { MobilePageHeader } from '@/components/mobile-page-header'
3939
import { MobileTerminalInput } from '@/components/terminal/mobile-terminal-input'
4040
import { ClaudeReconnectBanner } from '@/components/claude-reconnect-banner'
4141
import { useMobileKeyboard } from '@/hooks/use-mobile-keyboard'
42-
// System metrics footer removed — not used in Hermes Workspace
42+
import { SystemMetricsFooter } from '@/components/system-metrics-footer'
4343
import { CommandPalette } from '@/components/command-palette'
4444
import { useSettings } from '@/hooks/use-settings'
4545
// ActivityTicker moved to dashboard-only (too noisy for global header)
@@ -321,7 +321,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
321321
: !isMobile &&
322322
!isOnChatRoute &&
323323
settings.showSystemMetricsFooter
324-
? 'pb-[calc(1.5rem+1.75rem)]'
324+
? 'pb-7'
325325
: '',
326326
].join(' ')}
327327
data-tour="chat-area"
@@ -393,7 +393,9 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
393393
</div>
394394

395395
<MobileHamburgerMenu />
396-
{/* System metrics footer removed */}
396+
{!isMobile && !isOnChatRoute && settings.showSystemMetricsFooter ? (
397+
<SystemMetricsFooter leftOffsetPx={sidebarCollapsed ? 48 : 300} />
398+
) : null}
397399
<CommandPalette pathname={pathname} sessions={sessions} />
398400
</>
399401
)

src/hooks/use-settings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect } from 'react'
12
import { create } from 'zustand'
23
import { persist } from 'zustand/middleware'
34
import { getTheme, setTheme } from '@/lib/theme'
@@ -72,6 +73,10 @@ export const useSettingsStore = create<SettingsState>()(
7273
)
7374

7475
export function useSettings() {
76+
useEffect(() => {
77+
void useSettingsStore.persist.rehydrate()
78+
}, [])
79+
7580
const settings = useSettingsStore(function selectSettings(state) {
7681
return state.settings
7782
})

src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { Route as ApiTerminalStreamRouteImport } from './routes/api/terminal-str
3434
import { Route as ApiTerminalResizeRouteImport } from './routes/api/terminal-resize'
3535
import { Route as ApiTerminalInputRouteImport } from './routes/api/terminal-input'
3636
import { Route as ApiTerminalCloseRouteImport } from './routes/api/terminal-close'
37+
import { Route as ApiSystemMetricsRouteImport } from './routes/api/system-metrics'
3738
import { Route as ApiSwarmTmuxStopRouteImport } from './routes/api/swarm-tmux-stop'
3839
import { Route as ApiSwarmTmuxStartRouteImport } from './routes/api/swarm-tmux-start'
3940
import { Route as ApiSwarmTmuxScrollRouteImport } from './routes/api/swarm-tmux-scroll'
@@ -252,6 +253,11 @@ const ApiTerminalCloseRoute = ApiTerminalCloseRouteImport.update({
252253
path: '/api/terminal-close',
253254
getParentRoute: () => rootRouteImport,
254255
} as any)
256+
const ApiSystemMetricsRoute = ApiSystemMetricsRouteImport.update({
257+
id: '/api/system-metrics',
258+
path: '/api/system-metrics',
259+
getParentRoute: () => rootRouteImport,
260+
} as any)
255261
const ApiSwarmTmuxStopRoute = ApiSwarmTmuxStopRouteImport.update({
256262
id: '/api/swarm-tmux-stop',
257263
path: '/api/swarm-tmux-stop',
@@ -787,6 +793,7 @@ export interface FileRoutesByFullPath {
787793
'/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute
788794
'/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute
789795
'/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute
796+
'/api/system-metrics': typeof ApiSystemMetricsRoute
790797
'/api/terminal-close': typeof ApiTerminalCloseRoute
791798
'/api/terminal-input': typeof ApiTerminalInputRoute
792799
'/api/terminal-resize': typeof ApiTerminalResizeRoute
@@ -905,6 +912,7 @@ export interface FileRoutesByTo {
905912
'/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute
906913
'/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute
907914
'/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute
915+
'/api/system-metrics': typeof ApiSystemMetricsRoute
908916
'/api/terminal-close': typeof ApiTerminalCloseRoute
909917
'/api/terminal-input': typeof ApiTerminalInputRoute
910918
'/api/terminal-resize': typeof ApiTerminalResizeRoute
@@ -1025,6 +1033,7 @@ export interface FileRoutesById {
10251033
'/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute
10261034
'/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute
10271035
'/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute
1036+
'/api/system-metrics': typeof ApiSystemMetricsRoute
10281037
'/api/terminal-close': typeof ApiTerminalCloseRoute
10291038
'/api/terminal-input': typeof ApiTerminalInputRoute
10301039
'/api/terminal-resize': typeof ApiTerminalResizeRoute
@@ -1146,6 +1155,7 @@ export interface FileRouteTypes {
11461155
| '/api/swarm-tmux-scroll'
11471156
| '/api/swarm-tmux-start'
11481157
| '/api/swarm-tmux-stop'
1158+
| '/api/system-metrics'
11491159
| '/api/terminal-close'
11501160
| '/api/terminal-input'
11511161
| '/api/terminal-resize'
@@ -1264,6 +1274,7 @@ export interface FileRouteTypes {
12641274
| '/api/swarm-tmux-scroll'
12651275
| '/api/swarm-tmux-start'
12661276
| '/api/swarm-tmux-stop'
1277+
| '/api/system-metrics'
12671278
| '/api/terminal-close'
12681279
| '/api/terminal-input'
12691280
| '/api/terminal-resize'
@@ -1383,6 +1394,7 @@ export interface FileRouteTypes {
13831394
| '/api/swarm-tmux-scroll'
13841395
| '/api/swarm-tmux-start'
13851396
| '/api/swarm-tmux-stop'
1397+
| '/api/system-metrics'
13861398
| '/api/terminal-close'
13871399
| '/api/terminal-input'
13881400
| '/api/terminal-resize'
@@ -1503,6 +1515,7 @@ export interface RootRouteChildren {
15031515
ApiSwarmTmuxScrollRoute: typeof ApiSwarmTmuxScrollRoute
15041516
ApiSwarmTmuxStartRoute: typeof ApiSwarmTmuxStartRoute
15051517
ApiSwarmTmuxStopRoute: typeof ApiSwarmTmuxStopRoute
1518+
ApiSystemMetricsRoute: typeof ApiSystemMetricsRoute
15061519
ApiTerminalCloseRoute: typeof ApiTerminalCloseRoute
15071520
ApiTerminalInputRoute: typeof ApiTerminalInputRoute
15081521
ApiTerminalResizeRoute: typeof ApiTerminalResizeRoute
@@ -1711,6 +1724,13 @@ declare module '@tanstack/react-router' {
17111724
preLoaderRoute: typeof ApiTerminalCloseRouteImport
17121725
parentRoute: typeof rootRouteImport
17131726
}
1727+
'/api/system-metrics': {
1728+
id: '/api/system-metrics'
1729+
path: '/api/system-metrics'
1730+
fullPath: '/api/system-metrics'
1731+
preLoaderRoute: typeof ApiSystemMetricsRouteImport
1732+
parentRoute: typeof rootRouteImport
1733+
}
17141734
'/api/swarm-tmux-stop': {
17151735
id: '/api/swarm-tmux-stop'
17161736
path: '/api/swarm-tmux-stop'
@@ -2545,6 +2565,7 @@ const rootRouteChildren: RootRouteChildren = {
25452565
ApiSwarmTmuxScrollRoute: ApiSwarmTmuxScrollRoute,
25462566
ApiSwarmTmuxStartRoute: ApiSwarmTmuxStartRoute,
25472567
ApiSwarmTmuxStopRoute: ApiSwarmTmuxStopRoute,
2568+
ApiSystemMetricsRoute: ApiSystemMetricsRoute,
25482569
ApiTerminalCloseRoute: ApiTerminalCloseRoute,
25492570
ApiTerminalInputRoute: ApiTerminalInputRoute,
25502571
ApiTerminalResizeRoute: ApiTerminalResizeRoute,

0 commit comments

Comments
 (0)