system-monitor: Workspace/Panel layout with a scope list (bTop / pm2 / dockers)
/system-monitor is now a WorkspaceView (like /music): a left ScopeList panel selects the scope over the 'monitor:scope' channel, the right MonitorMain panel renders it. Three scopes: - bTop — the existing system snapshot (CPU/mem/disks/top processes) - pm2 processes — new GET /api/system-monitor/pm2 (pm2 jlist → name/status/cpu/ mem/restarts/uptime table) - dockers — new GET /api/system-monitor/docker (docker ps → container cards with state/status/image/ports) Both new endpoints degrade gracefully to an error field. Persisted as screens/system-monitor; owner-only. Needs a restart (backend routes + rebundle) + hard-refresh to appear. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,204 +1,44 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { Cpu, MemoryStick, HardDrive, Server, Activity } from 'lucide-react';
|
import { WorkspaceView } from 'officerdev';
|
||||||
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
type Stats = {
|
// /system-monitor uses the Workspace/Panel system (like /music): a horizontal split with an (empty for
|
||||||
hostname: string;
|
// now) left panel and the system snapshot on the right.
|
||||||
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;
|
const ALLOWED_APP_TYPES = new Set<string | null>(['monitor-side', 'monitor-main', null]);
|
||||||
|
|
||||||
function fmtBytes(n: number): string {
|
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||||
if (!n) return '0 B';
|
if (node.type === 'panel') {
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'monitor-main' };
|
||||||
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]}`;
|
const children = node.children.map((c) => {
|
||||||
|
const fixed = normalizeLayout(c.node);
|
||||||
|
return fixed === c.node ? c : { ...c, node: fixed };
|
||||||
|
});
|
||||||
|
const changed = children.some((c, i) => c !== node.children[i]);
|
||||||
|
return changed ? { ...node, children } : node;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = () => {
|
export const SystemMonitorScreen = () => {
|
||||||
const { get } = useClient();
|
const rawWorkspace = useDashboardState<LayoutNode>('screens/system-monitor', defaultLayout);
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const workspace = useMemo(() => {
|
||||||
const alive = useRef(true);
|
const fixed = normalizeLayout(rawWorkspace.value);
|
||||||
|
if (fixed === rawWorkspace.value) return rawWorkspace;
|
||||||
|
return { ...rawWorkspace, value: fixed };
|
||||||
|
}, [rawWorkspace]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
alive.current = true;
|
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||||
const tick = () => {
|
rawWorkspace.setValue(workspace.value);
|
||||||
get<Stats>('/system-monitor/stats')
|
|
||||||
.then((s) => {
|
|
||||||
if (alive.current) {
|
|
||||||
setStats(s);
|
|
||||||
setError(null);
|
|
||||||
}
|
}
|
||||||
})
|
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
||||||
.catch(() => alive.current && setError('Failed to read system stats'));
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
const id = setInterval(tick, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
alive.current = false;
|
|
||||||
clearInterval(id);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full overflow-y-auto p-4 md:p-6">
|
<div className="h-full w-full pt-2">
|
||||||
<div className="mx-auto flex max-w-6xl flex-col gap-5">
|
<WorkspaceView workspace={workspace} locked />
|
||||||
{/* 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'monitor-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'monitor-side', appType: 'monitor-side' }, size: 26 },
|
||||||
|
{ node: { type: 'panel', id: 'monitor-main', appType: 'monitor-main' }, size: 74 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -141,3 +141,61 @@ systemMonitorRouter.get('/stats', async (ctx) => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /pm2 — pm2 process list (the "pm2 processes" scope).
|
||||||
|
systemMonitorRouter.get('/pm2', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const { stdout } = await exec('pm2', ['jlist'], { maxBuffer: 8 * 1024 * 1024 });
|
||||||
|
const list = JSON.parse(stdout) as Array<{
|
||||||
|
pm_id?: number;
|
||||||
|
name?: string;
|
||||||
|
pid?: number;
|
||||||
|
monit?: { cpu?: number; memory?: number };
|
||||||
|
pm2_env?: { status?: string; restart_time?: number; pm_uptime?: number };
|
||||||
|
}>;
|
||||||
|
const now = Date.now();
|
||||||
|
return ctx.json({
|
||||||
|
processes: list.map((p) => {
|
||||||
|
const env = p.pm2_env ?? {};
|
||||||
|
return {
|
||||||
|
id: p.pm_id ?? -1,
|
||||||
|
name: p.name ?? '?',
|
||||||
|
status: env.status ?? 'unknown',
|
||||||
|
pid: p.pid ?? null,
|
||||||
|
cpuPct: p.monit?.cpu ?? 0,
|
||||||
|
memBytes: p.monit?.memory ?? 0,
|
||||||
|
restarts: env.restart_time ?? 0,
|
||||||
|
uptimeMs: env.status === 'online' && env.pm_uptime ? now - env.pm_uptime : 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return ctx.json({ processes: [], error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /docker — running docker containers (the "dockers" scope).
|
||||||
|
systemMonitorRouter.get('/docker', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const { stdout } = await exec('docker', ['ps', '--no-trunc', '--format', '{{json .}}'], { maxBuffer: 8 * 1024 * 1024 });
|
||||||
|
return ctx.json({
|
||||||
|
containers: stdout
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const c = JSON.parse(line) as Record<string, string>;
|
||||||
|
return {
|
||||||
|
id: (c.ID ?? '').slice(0, 12),
|
||||||
|
name: c.Names ?? '',
|
||||||
|
image: c.Image ?? '',
|
||||||
|
state: c.State ?? '',
|
||||||
|
status: c.Status ?? '',
|
||||||
|
ports: c.Ports ?? '',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return ctx.json({ containers: [], error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ import { appRegistryMetas as previewMetas } from '../apps/Preview';
|
|||||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||||
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
||||||
|
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||||
import { useAppRegistry } from './useAppRegistry';
|
import { useAppRegistry } from './useAppRegistry';
|
||||||
import { useUserApps } from 'state/useUserApps';
|
import { useUserApps } from 'state/useUserApps';
|
||||||
import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
|
import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
|
||||||
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
|
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
|
||||||
import { resolveIcon } from '../utils/resolve-icon';
|
import { resolveIcon } from '../utils/resolve-icon';
|
||||||
|
|
||||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas, ...musicMetas];
|
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas, ...musicMetas, ...monitorMetas];
|
||||||
|
|
||||||
export const AppRegistry = () => {
|
export const AppRegistry = () => {
|
||||||
const { registerApp } = useAppRegistry(apps);
|
const { registerApp } = useAppRegistry(apps);
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { Cpu, MemoryStick, HardDrive, Server } from 'lucide-react';
|
||||||
|
import { fmtBytes, fmtDuration } from './shared';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 BtopView = () => {
|
||||||
|
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-5xl flex-col gap-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{stats ? `${stats.hostname} · ${stats.platform}` : 'Loading…'}</span>
|
||||||
|
{stats && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>up {fmtDuration(stats.uptimeSec * 1000)}</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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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,60 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
type Container = { id: string; name: string; image: string; state: string; status: string; ports: string };
|
||||||
|
|
||||||
|
const POLL_MS = 3000;
|
||||||
|
const stateColor = (s: string) => (s === 'running' ? 'bg-emerald-500' : s === 'exited' ? 'bg-red-500' : 'bg-amber-500');
|
||||||
|
|
||||||
|
export const DockerView = () => {
|
||||||
|
const { get } = useClient();
|
||||||
|
const [containers, setContainers] = useState<Container[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const alive = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
alive.current = true;
|
||||||
|
const tick = () =>
|
||||||
|
get<{ containers: Container[]; error?: string }>('/system-monitor/docker')
|
||||||
|
.then((r) => {
|
||||||
|
if (alive.current) {
|
||||||
|
setContainers(r.containers);
|
||||||
|
setError(r.error ?? null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => alive.current && setError('Failed to read docker'));
|
||||||
|
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 max-w-5xl">
|
||||||
|
<div className="mb-3 text-xs text-muted-foreground">docker · {containers?.length ?? 0} running</div>
|
||||||
|
{error && <div className="mb-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-500">{error}</div>}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{(containers ?? []).map((c) => (
|
||||||
|
<div key={c.id} className="rounded-xl border border-border bg-card p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${stateColor(c.state)}`} />
|
||||||
|
<span className="truncate font-medium text-foreground">{c.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">{c.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-xs text-muted-foreground" title={c.image}>{c.image}</div>
|
||||||
|
{c.ports && (
|
||||||
|
<div className="mt-1 truncate font-mono text-xs text-muted-foreground/70" title={c.ports}>{c.ports}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{containers && containers.length === 0 && <p className="text-sm text-muted-foreground">no running containers</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMonitorScope } from './shared';
|
||||||
|
import { BtopView } from './BtopView';
|
||||||
|
import { Pm2View } from './Pm2View';
|
||||||
|
import { DockerView } from './DockerView';
|
||||||
|
|
||||||
|
// Right panel — renders whichever scope the left ScopeList selected (default: bTop).
|
||||||
|
export const MonitorMain = ({ panelId: _panelId }: { panelId: string }) => {
|
||||||
|
const [scope] = useMonitorScope();
|
||||||
|
if (scope === 'pm2') return <Pm2View />;
|
||||||
|
if (scope === 'docker') return <DockerView />;
|
||||||
|
return <BtopView />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { fmtBytes, fmtDuration } from './shared';
|
||||||
|
|
||||||
|
type Proc = { id: number; name: string; status: string; pid: number | null; cpuPct: number; memBytes: number; restarts: number; uptimeMs: number };
|
||||||
|
|
||||||
|
const POLL_MS = 2000;
|
||||||
|
const statusColor = (s: string) =>
|
||||||
|
s === 'online' ? 'bg-emerald-500' : s === 'stopped' ? 'bg-muted-foreground/40' : s === 'errored' ? 'bg-red-500' : 'bg-amber-500';
|
||||||
|
|
||||||
|
export const Pm2View = () => {
|
||||||
|
const { get } = useClient();
|
||||||
|
const [procs, setProcs] = useState<Proc[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const alive = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
alive.current = true;
|
||||||
|
const tick = () =>
|
||||||
|
get<{ processes: Proc[]; error?: string }>('/system-monitor/pm2')
|
||||||
|
.then((r) => {
|
||||||
|
if (alive.current) {
|
||||||
|
setProcs(r.processes);
|
||||||
|
setError(r.error ?? null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => alive.current && setError('Failed to read pm2'));
|
||||||
|
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 max-w-5xl">
|
||||||
|
<div className="mb-3 text-xs text-muted-foreground">pm2 · {procs?.length ?? 0} processes</div>
|
||||||
|
{error && <div className="mb-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-500">{error}</div>}
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-border bg-card">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<th className="p-3 font-medium">Name</th>
|
||||||
|
<th className="p-3 font-medium">Status</th>
|
||||||
|
<th className="p-3 text-right font-medium">CPU%</th>
|
||||||
|
<th className="p-3 text-right font-medium">Mem</th>
|
||||||
|
<th className="p-3 text-right font-medium">↺</th>
|
||||||
|
<th className="p-3 font-medium">Uptime</th>
|
||||||
|
<th className="p-3 text-right font-medium">PID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(procs ?? []).map((p) => (
|
||||||
|
<tr key={p.id} className="border-t border-border/60">
|
||||||
|
<td className="p-3 font-medium text-foreground">{p.name}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${statusColor(p.status)}`} />
|
||||||
|
{p.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right font-mono text-foreground">{p.cpuPct.toFixed(0)}</td>
|
||||||
|
<td className="p-3 text-right font-mono text-muted-foreground">{fmtBytes(p.memBytes)}</td>
|
||||||
|
<td className="p-3 text-right font-mono text-muted-foreground">{p.restarts}</td>
|
||||||
|
<td className="p-3 text-muted-foreground">{fmtDuration(p.uptimeMs)}</td>
|
||||||
|
<td className="p-3 text-right font-mono text-muted-foreground">{p.pid ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{procs && procs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="p-3 text-muted-foreground">no processes</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Cpu, Server, Boxes } from 'lucide-react';
|
||||||
|
import { useMonitorScope, SCOPES, type MonitorScope } from './shared';
|
||||||
|
|
||||||
|
// Left panel — a single-column list of scopes; selecting one drives the right panel via the channel.
|
||||||
|
const ICONS: Record<MonitorScope, typeof Cpu> = { btop: Cpu, pm2: Server, docker: Boxes };
|
||||||
|
|
||||||
|
export const ScopeList = ({ panelId: _panelId }: { panelId: string }) => {
|
||||||
|
const [scope, setScope] = useMonitorScope();
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-y-auto p-3">
|
||||||
|
{SCOPES.map((s) => {
|
||||||
|
const Icon = ICONS[s.id];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScope(s.id)}
|
||||||
|
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${
|
||||||
|
scope === s.id ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={15} className="shrink-0" />
|
||||||
|
<span className="truncate">{s.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
|
import { Activity, PanelLeft } from 'lucide-react';
|
||||||
|
import { ScopeList } from './ScopeList';
|
||||||
|
import { MonitorMain } from './MonitorMain';
|
||||||
|
|
||||||
|
export { ScopeList, MonitorMain };
|
||||||
|
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'monitor-side', name: 'Scope', icon: PanelLeft, component: ScopeList, availableOnPanel: false },
|
||||||
|
{ key: 'monitor-main', name: 'System Monitor', icon: Activity, component: MonitorMain, availableOnPanel: false },
|
||||||
|
];
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
|
|
||||||
|
// The left panel picks a scope; the right panel renders it. Coordinated over a panel channel, like the
|
||||||
|
// /music browser → detail split.
|
||||||
|
export type MonitorScope = 'btop' | 'pm2' | 'docker';
|
||||||
|
|
||||||
|
export const MONITOR_SCOPE_CHANNEL = 'monitor:scope';
|
||||||
|
|
||||||
|
export const SCOPES: { id: MonitorScope; label: string }[] = [
|
||||||
|
{ id: 'btop', label: 'bTop' },
|
||||||
|
{ id: 'pm2', label: 'pm2 processes' },
|
||||||
|
{ id: 'docker', label: 'dockers' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useMonitorScope = () => usePanelChannel<MonitorScope>(MONITOR_SCOPE_CHANNEL, 'btop');
|
||||||
|
|
||||||
|
export 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]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDuration(ms: number): string {
|
||||||
|
if (!ms || ms < 0) return '—';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
return [d && `${d}d`, (d || h) && `${h}h`, `${m}m`].filter(Boolean).join(' ') || `${s}s`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user