system-monitor: /system-monitor route (CPU/mem/disk/processes) + page titles

- Backend GET /api/system-monitor/stats: one snapshot — CPU overall + per-core
  (two /proc/stat samples), memory + swap (/proc/meminfo), disks (df), load,
  uptime, top-20 processes (ps). Owner-only via the account gate.
- Frontend /system-monitor screen: polls every 2s; CPU/Memory/Disks cards with
  bars + per-core mini-bars, top-processes table. Nav dock "Monitor" item.
- Register /music and /system-monitor in usePageTitle RULES so the browser tab
  and editable header title update on those routes (/music was missing too).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 19:23:32 +00:00
co-authored by Claude Opus 4.8
parent 1d441e3aa1
commit d1e19d1473
8 changed files with 356 additions and 0 deletions
+1
View File
@@ -41,6 +41,7 @@ export function App() {
<Route path="/plans" element={<Dashboard.Plans />} />
<Route path="/files" element={<Dashboard.FilesScreen />} />
<Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
<Route path="/code-editor" element={<Dashboard.CodeEditor />} />
<Route path="/skills" element={<Dashboard.Skills />} />
@@ -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' },
];
@@ -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 }) => (
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div className={`h-full rounded-full transition-all ${barColor(pct)}`} style={{ width: `${Math.min(100, pct)}%` }} />
</div>
);
const Card = ({ title, icon: Icon, children }: { title: string; icon: typeof Cpu; children: React.ReactNode }) => (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Icon size={16} className="text-primary" />
{title}
</div>
{children}
</div>
);
export const SystemMonitorScreen = () => {
const { get } = useClient();
const [stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState<string | null>(null);
const alive = useRef(true);
useEffect(() => {
alive.current = true;
const tick = () => {
get<Stats>('/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 (
<div className="h-full w-full overflow-y-auto p-4 md:p-6">
<div className="mx-auto flex max-w-6xl flex-col gap-5">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Activity className="text-primary" />
<div>
<h1 className="text-lg font-semibold text-foreground">System Monitor</h1>
<p className="text-xs text-muted-foreground">
{stats ? `${stats.hostname} · ${stats.platform}` : 'Loading…'}
</p>
</div>
</div>
{stats && (
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>up {fmtUptime(stats.uptimeSec)}</span>
<span>load {stats.loadavg.map((l) => l.toFixed(2)).join(' ')}</span>
<span className="inline-flex h-2 w-2 animate-pulse rounded-full bg-emerald-500" title="live" />
</div>
)}
</div>
{error && <div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-500">{error}</div>}
{stats && (
<>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* CPU */}
<Card title="CPU" icon={Cpu}>
<div className="flex items-end justify-between">
<span className="text-3xl font-semibold text-foreground">{stats.cpu.usagePct.toFixed(0)}%</span>
<span className="text-xs text-muted-foreground">{stats.cpu.cores} cores</span>
</div>
<Bar pct={stats.cpu.usagePct} />
<p className="truncate text-xs text-muted-foreground" title={stats.cpu.model}>{stats.cpu.model}</p>
<div className="grid grid-cols-8 gap-1 pt-1">
{stats.cpu.perCore.map((c, i) => (
<div key={i} className="flex h-10 items-end rounded bg-muted" title={`core ${i}: ${c}%`}>
<div className={`w-full rounded ${barColor(c)}`} style={{ height: `${Math.max(4, c)}%` }} />
</div>
))}
</div>
</Card>
{/* Memory */}
<Card title="Memory" icon={MemoryStick}>
{stats.mem ? (
<>
<div className="flex items-end justify-between">
<span className="text-3xl font-semibold text-foreground">{stats.mem.usedPct.toFixed(0)}%</span>
<span className="text-xs text-muted-foreground">
{fmtBytes(stats.mem.usedBytes)} / {fmtBytes(stats.mem.totalBytes)}
</span>
</div>
<Bar pct={stats.mem.usedPct} />
{stats.mem.swapTotalBytes > 0 && (
<p className="text-xs text-muted-foreground">
swap {fmtBytes(stats.mem.swapUsedBytes)} / {fmtBytes(stats.mem.swapTotalBytes)}
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">unavailable</p>
)}
</Card>
{/* Disks */}
<Card title="Disks" icon={HardDrive}>
{stats.disks.length ? (
<div className="flex flex-col gap-3">
{stats.disks.map((d) => (
<div key={d.mount} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="truncate font-mono text-foreground" title={`${d.mount} (${d.fsType})`}>{d.mount}</span>
<span className="text-muted-foreground">{fmtBytes(d.usedBytes)} / {fmtBytes(d.totalBytes)}</span>
</div>
<Bar pct={d.usedPct} />
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">no disks</p>
)}
</Card>
</div>
{/* Processes */}
<Card title="Top processes" icon={Server}>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="text-xs uppercase tracking-wide text-muted-foreground">
<th className="pb-2 pr-4 font-medium">PID</th>
<th className="pb-2 pr-4 font-medium">User</th>
<th className="pb-2 pr-4 text-right font-medium">CPU%</th>
<th className="pb-2 pr-4 text-right font-medium">MEM%</th>
<th className="pb-2 font-medium">Command</th>
</tr>
</thead>
<tbody>
{stats.processes.map((p) => (
<tr key={p.pid} className="border-t border-border/60">
<td className="py-1.5 pr-4 font-mono text-muted-foreground">{p.pid}</td>
<td className="py-1.5 pr-4 text-muted-foreground">{p.user}</td>
<td className="py-1.5 pr-4 text-right font-mono text-foreground">{p.cpuPct.toFixed(1)}</td>
<td className="py-1.5 pr-4 text-right font-mono text-muted-foreground">{p.memPct.toFixed(1)}</td>
<td className="max-w-0 truncate py-1.5 font-mono text-foreground" title={p.command}>{p.command}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
)}
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from './SystemMonitorScreen';
@@ -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';
@@ -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' },
@@ -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<CpuSample> {
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<string, number> = {};
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(),
});
});
+2
View File
@@ -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);