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 */}
+
+
+
+
+
+ | PID |
+ User |
+ CPU% |
+ MEM% |
+ Command |
+
+
+
+ {stats.processes.map((p) => (
+
+ | {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);