Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions src/components/system-metrics-footer.tsx
Original file line number Diff line number Diff line change
@@ -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<SystemMetrics> {
const response = await fetch('/api/system-metrics', { cache: 'no-store' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json() as Promise<SystemMetrics>
}

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 (
<span
className={cn(
'inline-flex min-w-0 items-baseline gap-1.5 whitespace-nowrap',
tone === 'normal' && 'text-[var(--theme-text)]/80',
tone === 'warn' && 'text-amber-300/90',
tone === 'critical' && 'text-red-300/95',
tone === 'muted' && 'text-[var(--theme-muted)]',
tone === 'accent' && 'text-[var(--theme-accent)]',
)}
>
<span className="text-[9px] font-medium uppercase tracking-[0.16em] text-[var(--theme-muted)]">
{label}
</span>
<span className="truncate font-medium tabular-nums">{value}</span>
</span>
)
}

function Separator() {
return <span className="h-3 w-px shrink-0 bg-[var(--theme-border)]" aria-hidden />
}

function StatusDot({ tone }: { tone: 'ok' | 'warn' | 'critical' | 'muted' }) {
return (
<span
className={cn(
'inline-block size-1.5 rounded-full',
tone === 'ok' && 'bg-[var(--theme-accent)]',
tone === 'warn' && 'bg-amber-300/90',
tone === 'critical' && 'bg-red-300/95',
tone === 'muted' && 'bg-[var(--theme-muted)]',
)}
aria-hidden
/>
)
}

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 (
<footer
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"
data-testid="system-metrics-footer"
aria-label="System metrics footer"
style={{ left: leftOffsetPx }}
>
<div className="flex max-w-full items-center justify-center gap-3 overflow-hidden opacity-85">
{data ? (
<>
<MetricItem
label="CPU"
value={`${data.cpu.loadPercent}%`}
tone={metricTone(data.cpu.loadPercent)}
/>
<Separator />
<MetricItem
label="RAM"
value={`${formatBytes(data.memory.usedBytes)} / ${formatBytes(data.memory.totalBytes)}`}
tone={metricTone(data.memory.usedPercent)}
/>
<Separator />
<MetricItem
label="Disk"
value={`${data.disk.usedPercent}%`}
tone={metricTone(data.disk.usedPercent)}
/>
<Separator />
<span className="inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap">
<StatusDot tone={hermesDotTone} />
<MetricItem label="Hermes" value={data.hermes.status} tone={hermesTone} />
</span>
<Separator />
<MetricItem
label="Updated"
value={formatCheckedAt(data.checkedAt)}
tone="muted"
/>
</>
) : (
<span className="inline-flex items-center gap-2 whitespace-nowrap text-[var(--theme-muted)]">
<StatusDot tone={isError ? 'warn' : 'muted'} />
<MetricItem
label="Metrics"
value={isError ? 'unavailable' : 'loading'}
tone={isError ? 'warn' : 'muted'}
/>
</span>
)}
</div>
</footer>
)
}
8 changes: 5 additions & 3 deletions src/components/workspace-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -393,7 +393,9 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
</div>

<MobileHamburgerMenu />
{/* System metrics footer removed */}
{!isMobile && !isOnChatRoute && settings.showSystemMetricsFooter ? (
<SystemMetricsFooter leftOffsetPx={sidebarCollapsed ? 48 : 300} />
) : null}
<CommandPalette pathname={pathname} sessions={sessions} />
</>
)
Expand Down
5 changes: 5 additions & 0 deletions src/hooks/use-settings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { getTheme, setTheme } from '@/lib/theme'
Expand Down Expand Up @@ -72,6 +73,10 @@ export const useSettingsStore = create<SettingsState>()(
)

export function useSettings() {
useEffect(() => {
void useSettingsStore.persist.rehydrate()
}, [])

const settings = useSettingsStore(function selectSettings(state) {
return state.settings
})
Expand Down
21 changes: 21 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -2545,6 +2565,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiSwarmTmuxScrollRoute: ApiSwarmTmuxScrollRoute,
ApiSwarmTmuxStartRoute: ApiSwarmTmuxStartRoute,
ApiSwarmTmuxStopRoute: ApiSwarmTmuxStopRoute,
ApiSystemMetricsRoute: ApiSystemMetricsRoute,
ApiTerminalCloseRoute: ApiTerminalCloseRoute,
ApiTerminalInputRoute: ApiTerminalInputRoute,
ApiTerminalResizeRoute: ApiTerminalResizeRoute,
Expand Down
Loading
Loading