diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index a2464964..949548eb 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -41,6 +41,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 695afa42..09da47c9 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -129,6 +129,7 @@ import { MonitorSmartphone, Workflow, Music, + Activity, } from 'lucide-react'; export const ALL_DOCK_ITEMS: DockItem[] = [ @@ -145,6 +146,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [ { label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' }, { label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' }, { label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' }, + { label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' }, { label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' }, ]; diff --git a/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx new file mode 100644 index 00000000..b7f5b2b6 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx @@ -0,0 +1,204 @@ +import { useEffect, useRef, useState } from 'react'; +import { useClient } from 'hooks/useClient'; +import { Cpu, MemoryStick, HardDrive, Server, Activity } from 'lucide-react'; + +type Stats = { + hostname: string; + platform: string; + uptimeSec: number; + loadavg: number[]; + cpu: { model: string; cores: number; usagePct: number; perCore: number[] }; + mem: { + totalBytes: number; + usedBytes: number; + freeBytes: number; + usedPct: number; + swapTotalBytes: number; + swapUsedBytes: number; + } | null; + disks: { mount: string; fsType: string; totalBytes: number; usedBytes: number; usedPct: number }[]; + processes: { pid: number; user: string; cpuPct: number; memPct: number; command: string }[]; + timestamp: number; +}; + +const POLL_MS = 2000; + +function fmtBytes(n: number): string { + if (!n) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024))); + return `${(n / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +function fmtUptime(sec: number): string { + const d = Math.floor(sec / 86400); + const h = Math.floor((sec % 86400) / 3600); + const m = Math.floor((sec % 3600) / 60); + return [d && `${d}d`, (d || h) && `${h}h`, `${m}m`].filter(Boolean).join(' '); +} + +const barColor = (pct: number) => + pct >= 85 ? 'bg-red-500' : pct >= 60 ? 'bg-amber-500' : 'bg-emerald-500'; + +const Bar = ({ pct }: { pct: number }) => ( +
+
+
+); + +const Card = ({ title, icon: Icon, children }: { title: string; icon: typeof Cpu; children: React.ReactNode }) => ( +
+
+ + {title} +
+ {children} +
+); + +export const SystemMonitorScreen = () => { + const { get } = useClient(); + const [stats, setStats] = useState(null); + const [error, setError] = useState(null); + const alive = useRef(true); + + useEffect(() => { + alive.current = true; + const tick = () => { + get('/system-monitor/stats') + .then((s) => { + if (alive.current) { + setStats(s); + setError(null); + } + }) + .catch(() => alive.current && setError('Failed to read system stats')); + }; + tick(); + const id = setInterval(tick, POLL_MS); + return () => { + alive.current = false; + clearInterval(id); + }; + }, []); + + return ( +
+
+ {/* Header */} +
+
+ +
+

System Monitor

+

+ {stats ? `${stats.hostname} · ${stats.platform}` : 'Loading…'} +

+
+
+ {stats && ( +
+ up {fmtUptime(stats.uptimeSec)} + load {stats.loadavg.map((l) => l.toFixed(2)).join(' ')} + +
+ )} +
+ + {error &&
{error}
} + + {stats && ( + <> +
+ {/* CPU */} + +
+ {stats.cpu.usagePct.toFixed(0)}% + {stats.cpu.cores} cores +
+ +

{stats.cpu.model}

+
+ {stats.cpu.perCore.map((c, i) => ( +
+
+
+ ))} +
+ + + {/* Memory */} + + {stats.mem ? ( + <> +
+ {stats.mem.usedPct.toFixed(0)}% + + {fmtBytes(stats.mem.usedBytes)} / {fmtBytes(stats.mem.totalBytes)} + +
+ + {stats.mem.swapTotalBytes > 0 && ( +

+ swap {fmtBytes(stats.mem.swapUsedBytes)} / {fmtBytes(stats.mem.swapTotalBytes)} +

+ )} + + ) : ( +

unavailable

+ )} +
+ + {/* Disks */} + + {stats.disks.length ? ( +
+ {stats.disks.map((d) => ( +
+
+ {d.mount} + {fmtBytes(d.usedBytes)} / {fmtBytes(d.totalBytes)} +
+ +
+ ))} +
+ ) : ( +

no disks

+ )} +
+
+ + {/* Processes */} + +
+ + + + + + + + + + + + {stats.processes.map((p) => ( + + + + + + + + ))} + +
PIDUserCPU%MEM%Command
{p.pid}{p.user}{p.cpuPct.toFixed(1)}{p.memPct.toFixed(1)}{p.command}
+
+
+ + )} +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/SystemMonitor/index.tsx b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/index.tsx new file mode 100644 index 00000000..d03f0307 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/index.tsx @@ -0,0 +1 @@ +export * from './SystemMonitorScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index f8591d93..6ff60bf3 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -11,6 +11,7 @@ export * from './Tasks'; export * from './Files'; export * from './Music'; +export * from './SystemMonitor'; export * from './CodeEditor'; export * from './ChatHistory'; export * from './Dashboards'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index b914585f..ed659c3f 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -15,6 +15,8 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/chat'), title: 'Chat' }, { match: (p) => p.startsWith('/email'), title: 'Email' }, { match: (p) => p.startsWith('/files'), title: 'Files' }, + { match: (p) => p.startsWith('/music'), title: 'Music' }, + { match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' }, { match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' }, { match: (p) => p.startsWith('/projects'), title: 'Projects' }, { match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' }, diff --git a/src/servers/api/system-monitor/system-monitor.ts b/src/servers/api/system-monitor/system-monitor.ts new file mode 100644 index 00000000..08583899 --- /dev/null +++ b/src/servers/api/system-monitor/system-monitor.ts @@ -0,0 +1,143 @@ +import os from 'node:os'; +import { readFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { createRouter } from '../../create-router'; + +// First-stab system monitor: a single /stats snapshot the frontend polls. Linux-only (reads /proc + +// shells out to df/ps); each piece degrades to empty/zero rather than failing the whole response. + +const exec = promisify(execFile); + +export const systemMonitorRouter = createRouter(); + +type CpuSample = { total: number; busy: number; perCore: { total: number; busy: number }[] }; + +async function readCpuSample(): Promise { + const data = await readFile('/proc/stat', 'utf8'); + const lines = data.split('\n').filter((l) => l.startsWith('cpu')); + const parse = (line: string) => { + const n = line.trim().split(/\s+/).slice(1).map(Number); + const total = n.reduce((a, b) => a + b, 0); + const idle = (n[3] ?? 0) + (n[4] ?? 0); // idle + iowait + return { total, busy: total - idle }; + }; + const agg = parse(lines[0] ?? 'cpu 0 0 0 0'); + const perCore = lines.slice(1).map(parse); + return { total: agg.total, busy: agg.busy, perCore }; +} + +const pct = (busyDelta: number, totalDelta: number) => + totalDelta > 0 ? Math.round((busyDelta / totalDelta) * 1000) / 10 : 0; + +async function readMem() { + const data = await readFile('/proc/meminfo', 'utf8'); + const map: Record = {}; + for (const line of data.split('\n')) { + const m = line.match(/^(\w+):\s+(\d+)/); + if (m) map[m[1]!] = Number(m[2]) * 1024; // kB → bytes + } + const total = map.MemTotal ?? 0; + const available = map.MemAvailable ?? map.MemFree ?? 0; + const used = Math.max(0, total - available); + const swapTotal = map.SwapTotal ?? 0; + const swapUsed = Math.max(0, swapTotal - (map.SwapFree ?? 0)); + return { + totalBytes: total, + usedBytes: used, + freeBytes: available, + usedPct: total ? Math.round((used / total) * 1000) / 10 : 0, + swapTotalBytes: swapTotal, + swapUsedBytes: swapUsed, + }; +} + +async function readDisks() { + try { + const { stdout } = await exec('df', [ + '-B1', + '--output=target,fstype,size,used,pcent', + '-x', 'tmpfs', '-x', 'devtmpfs', '-x', 'squashfs', '-x', 'overlay', '-x', 'efivarfs', + ]); + return stdout + .trim() + .split('\n') + .slice(1) + .map((l) => { + const [mount, fsType, size, used, pcent] = l.trim().split(/\s+/); + return { + mount: mount ?? '', + fsType: fsType ?? '', + totalBytes: Number(size ?? 0), + usedBytes: Number(used ?? 0), + usedPct: Number((pcent ?? '0').replace('%', '')), + }; + }) + .filter((d) => d.totalBytes > 0); + } catch { + return []; + } +} + +async function readProcesses() { + try { + const { stdout } = await exec('ps', ['-eo', 'pid,user:16,pcpu,pmem,comm', '--sort=-pcpu']); + return stdout + .trim() + .split('\n') + .slice(1, 21) + .map((l) => { + const [pid, user, cpu, mem, ...comm] = l.trim().split(/\s+/); + return { + pid: Number(pid ?? 0), + user: user ?? '', + cpuPct: Number(cpu ?? 0), + memPct: Number(mem ?? 0), + command: comm.join(' '), + }; + }); + } catch { + return []; + } +} + +// GET /stats — one snapshot. CPU% comes from two /proc/stat samples ~120ms apart. +systemMonitorRouter.get('/stats', async (ctx) => { + const first = await readCpuSample().catch(() => null); + await new Promise((r) => setTimeout(r, 120)); + const second = await readCpuSample().catch(() => null); + + const cpuUsage = + first && second ? pct(second.busy - first.busy, second.total - first.total) : 0; + const perCore = + first && second + ? second.perCore.map((c, i) => { + const a = first.perCore[i]; + return a ? pct(c.busy - a.busy, c.total - a.total) : 0; + }) + : []; + + const [mem, disks, processes] = await Promise.all([ + readMem().catch(() => null), + readDisks(), + readProcesses(), + ]); + + const cpus = os.cpus(); + return ctx.json({ + hostname: os.hostname(), + platform: `${os.type()} ${os.release()}`, + uptimeSec: Math.round(os.uptime()), + loadavg: os.loadavg(), + cpu: { + model: cpus[0]?.model?.trim() ?? 'unknown', + cores: cpus.length, + usagePct: cpuUsage, + perCore, + }, + mem, + disks, + processes, + timestamp: Date.now(), + }); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 70a2c8b5..55e2d3fa 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -20,6 +20,7 @@ import { dashboardsRouter } from './api/dashboards'; import { taskLogsRouter } from './api/task-logs/task-logs'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { musicRouter } from './api/music/router'; +import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server port import { devServerRouter, devServerProxyRouter } from './api/dev-server/router'; import { dockRouter } from './api/dock/dock'; @@ -93,6 +94,7 @@ protectedRouter.route('/dashboards', dashboardsRouter); protectedRouter.route('/task-logs', taskLogsRouter); protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/music', musicRouter); +protectedRouter.route('/system-monitor', systemMonitorRouter); protectedRouter.route('/dev-server', devServerRouter); protectedRouter.route('/dock', dockRouter); protectedRouter.route('/integrations', integrationsRouter);