From 059929aa59a9abeda1beb8515f1bd589ba03a320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 27 Jul 2026 10:17:15 +0000 Subject: [PATCH] system-monitor: Workspace/Panel layout with a scope list (bTop / pm2 / dockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- .../SystemMonitor/SystemMonitorScreen.tsx | 222 +++--------------- .../Dashboard/SystemMonitor/defaultLayout.ts | 11 + .../api/system-monitor/system-monitor.ts | 58 +++++ .../src/AppRegistry/AppRegistry.tsx | 3 +- .../src/apps/SystemMonitor/BtopView.tsx | 170 ++++++++++++++ .../src/apps/SystemMonitor/DockerView.tsx | 60 +++++ .../src/apps/SystemMonitor/MonitorMain.tsx | 12 + .../src/apps/SystemMonitor/Pm2View.tsx | 82 +++++++ .../src/apps/SystemMonitor/ScopeList.tsx | 29 +++ .../src/apps/SystemMonitor/index.ts | 11 + .../src/apps/SystemMonitor/shared.ts | 31 +++ 11 files changed, 497 insertions(+), 192 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/SystemMonitor/defaultLayout.ts create mode 100644 src/workspaces/officerdev/src/apps/SystemMonitor/BtopView.tsx create mode 100644 src/workspaces/officerdev/src/apps/SystemMonitor/DockerView.tsx create mode 100644 src/workspaces/officerdev/src/apps/SystemMonitor/MonitorMain.tsx create mode 100644 src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx create mode 100644 src/workspaces/officerdev/src/apps/SystemMonitor/ScopeList.tsx create mode 100644 src/workspaces/officerdev/src/apps/SystemMonitor/index.ts create mode 100644 src/workspaces/officerdev/src/apps/SystemMonitor/shared.ts diff --git a/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx index b7f5b2b6..0a367ce9 100644 --- a/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx @@ -1,204 +1,44 @@ -import { useEffect, useRef, useState } from 'react'; -import { useClient } from 'hooks/useClient'; -import { Cpu, MemoryStick, HardDrive, Server, Activity } from 'lucide-react'; +import { useEffect, useMemo } from 'react'; +import type { LayoutNode } from 'officerdev'; +import { WorkspaceView } from 'officerdev'; +import { useDashboardState } from 'state/useDashboardState'; +import { defaultLayout } from './defaultLayout'; -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; -}; +// /system-monitor uses the Workspace/Panel system (like /music): a horizontal split with an (empty for +// now) left panel and the system snapshot on the right. -const POLL_MS = 2000; +const ALLOWED_APP_TYPES = new Set(['monitor-side', 'monitor-main', null]); -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 normalizeLayout(node: LayoutNode): LayoutNode { + if (node.type === 'panel') { + return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'monitor-main' }; + } + 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 }) => ( -
-
-
-); - -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); + const rawWorkspace = useDashboardState('screens/system-monitor', defaultLayout); + + const workspace = useMemo(() => { + const fixed = normalizeLayout(rawWorkspace.value); + if (fixed === rawWorkspace.value) return rawWorkspace; + return { ...rawWorkspace, value: fixed }; + }, [rawWorkspace]); 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); - }; - }, []); + if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { + rawWorkspace.setValue(workspace.value); + } + }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); 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/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/defaultLayout.ts new file mode 100644 index 00000000..4d306d8d --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/defaultLayout.ts @@ -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 }, + ], +}; diff --git a/src/servers/api/system-monitor/system-monitor.ts b/src/servers/api/system-monitor/system-monitor.ts index 08583899..9fe03291 100644 --- a/src/servers/api/system-monitor/system-monitor.ts +++ b/src/servers/api/system-monitor/system-monitor.ts @@ -141,3 +141,61 @@ systemMonitorRouter.get('/stats', async (ctx) => { 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; + 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) }); + } +}); diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 5a0fc6e0..0c65c211 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -11,13 +11,14 @@ import { appRegistryMetas as previewMetas } from '../apps/Preview'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { appRegistryMetas as desktopMetas } from '../apps/Desktop'; import { appRegistryMetas as musicMetas } from '../apps/Music'; +import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { useAppRegistry } from './useAppRegistry'; import { useUserApps } from 'state/useUserApps'; import { createUserAppPanel } from '../apps/UserApp/UserAppPanel'; import { createUserAppHeader } from '../apps/UserApp/UserAppHeader'; 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 = () => { const { registerApp } = useAppRegistry(apps); diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/BtopView.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/BtopView.tsx new file mode 100644 index 00000000..353d23b9 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/BtopView.tsx @@ -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 }) => ( +
+
+
+); + +const Card = ({ title, icon: Icon, children }: { title: string; icon: typeof Cpu; children: React.ReactNode }) => ( +
+
+ + {title} +
+ {children} +
+); + +export const BtopView = () => { + 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 ( +
+
+
+ {stats ? `${stats.hostname} · ${stats.platform}` : 'Loading…'} + {stats && ( +
+ up {fmtDuration(stats.uptimeSec * 1000)} + load {stats.loadavg.map((l) => l.toFixed(2)).join(' ')} + +
+ )} +
+ + {error &&
{error}
} + + {stats && ( + <> +
+ +
+ {stats.cpu.usagePct.toFixed(0)}% + {stats.cpu.cores} cores +
+ +

{stats.cpu.model}

+
+ {stats.cpu.perCore.map((c, i) => ( +
+
+
+ ))} +
+ + + + {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

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

no disks

+ )} +
+
+ + +
+ + + + + + + + + + + + {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/workspaces/officerdev/src/apps/SystemMonitor/DockerView.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/DockerView.tsx new file mode 100644 index 00000000..7d9cb8c2 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/DockerView.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+
+
docker · {containers?.length ?? 0} running
+ {error &&
{error}
} +
+ {(containers ?? []).map((c) => ( +
+
+
+ + {c.name} +
+ {c.status} +
+
{c.image}
+ {c.ports && ( +
{c.ports}
+ )} +
+ ))} + {containers && containers.length === 0 &&

no running containers

} +
+
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/MonitorMain.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/MonitorMain.tsx new file mode 100644 index 00000000..70103d2a --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/MonitorMain.tsx @@ -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 ; + if (scope === 'docker') return ; + return ; +}; diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx new file mode 100644 index 00000000..f5c2476d --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/Pm2View.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+
+
pm2 · {procs?.length ?? 0} processes
+ {error &&
{error}
} +
+ + + + + + + + + + + + + + {(procs ?? []).map((p) => ( + + + + + + + + + + ))} + {procs && procs.length === 0 && ( + + + + )} + +
NameStatusCPU%MemUptimePID
{p.name} + + + {p.status} + + {p.cpuPct.toFixed(0)}{fmtBytes(p.memBytes)}{p.restarts}{fmtDuration(p.uptimeMs)}{p.pid ?? '—'}
no processes
+
+
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/ScopeList.tsx b/src/workspaces/officerdev/src/apps/SystemMonitor/ScopeList.tsx new file mode 100644 index 00000000..788a58ff --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/ScopeList.tsx @@ -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 = { btop: Cpu, pm2: Server, docker: Boxes }; + +export const ScopeList = ({ panelId: _panelId }: { panelId: string }) => { + const [scope, setScope] = useMonitorScope(); + return ( +
+ {SCOPES.map((s) => { + const Icon = ICONS[s.id]; + return ( + + ); + })} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/index.ts b/src/workspaces/officerdev/src/apps/SystemMonitor/index.ts new file mode 100644 index 00000000..bbd76ecc --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/index.ts @@ -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 }, +]; diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/shared.ts b/src/workspaces/officerdev/src/apps/SystemMonitor/shared.ts new file mode 100644 index 00000000..02959c4d --- /dev/null +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/shared.ts @@ -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(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`; +}