metwatch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +336 -0
  3. package/bin/mw.ts +20 -0
  4. package/index.ts +174 -0
  5. package/metwatch.config.json +9 -0
  6. package/package.json +50 -0
  7. package/src/cli/args.ts +115 -0
  8. package/src/cli/commands/list.ts +77 -0
  9. package/src/cli/commands/logs.ts +69 -0
  10. package/src/cli/commands/monitor.ts +12 -0
  11. package/src/cli/commands/start.ts +124 -0
  12. package/src/cli/commands/stop.ts +33 -0
  13. package/src/cli/help.ts +96 -0
  14. package/src/core/event-bus.ts +113 -0
  15. package/src/core/launcher.ts +280 -0
  16. package/src/core/log-manager.ts +116 -0
  17. package/src/core/metrics-manager.ts +115 -0
  18. package/src/core/process-manager.ts +79 -0
  19. package/src/core/runtime-manager.ts +299 -0
  20. package/src/core/state-manager.ts +88 -0
  21. package/src/services/disk.service.ts +76 -0
  22. package/src/services/network.service.ts +131 -0
  23. package/src/services/process.service.ts +49 -0
  24. package/src/services/system.service.ts +63 -0
  25. package/src/types/blessed.d.ts +332 -0
  26. package/src/types/config.types.ts +77 -0
  27. package/src/types/managed-process.types.ts +55 -0
  28. package/src/types/metrics.types.ts +182 -0
  29. package/src/types/process.types.ts +49 -0
  30. package/src/ui/layout.ts +318 -0
  31. package/src/ui/screen.ts +45 -0
  32. package/src/ui/widgets/cpu.widget.ts +98 -0
  33. package/src/ui/widgets/disk.widget.ts +134 -0
  34. package/src/ui/widgets/logs.widget.ts +168 -0
  35. package/src/ui/widgets/memory.widget.ts +94 -0
  36. package/src/ui/widgets/network.widget.ts +185 -0
  37. package/src/ui/widgets/process-table.widget.ts +293 -0
  38. package/src/ui/widgets/runtime.widget.ts +119 -0
  39. package/src/utils/formatters.ts +74 -0
@@ -0,0 +1,299 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Runtime Manager
3
+ //
4
+ // Collects Node/Bun runtime metrics from managed processes via the Chrome
5
+ // DevTools Protocol (inspector).
6
+ //
7
+ // How it works:
8
+ // 1. The launcher passes --inspect=0 to each Node/Bun child process,
9
+ // which picks a random available port and prints:
10
+ // "Debugger listening on ws://127.0.0.1:<port>/..."
11
+ // 2. RuntimeManager reads that port (supplied via managedPorts map),
12
+ // opens a WebSocket to the inspector endpoint, and polls CDP APIs.
13
+ // 3. Metrics are emitted as 'metrics:runtime:updated' events on the bus.
14
+ //
15
+ // CDP methods used:
16
+ // Runtime.getHeapUsage() → heapUsed, heapTotal
17
+ // Runtime.evaluate(expression) → RSS, external, uptime, handles etc.
18
+ // (Uses process.memoryUsage() and process._getActiveHandles() evaluated
19
+ // inside the target process via the inspector)
20
+ //
21
+ // Event loop lag measurement:
22
+ // A lightweight probe is evaluated in the target via inspector:
23
+ // const start = Date.now(); setImmediate(() => resolve(Date.now() - start));
24
+ // Lag = actual delay - expected 0ms.
25
+ //
26
+ // GC metrics are not available via stable CDP; they require the
27
+ // --expose-gc flag and custom instrumentation. We track cumulative
28
+ // estimate via heap growth heuristic (Phase 2). Native GC events
29
+ // will be added in Phase 3 via v8.GCProfiler or perf_hooks.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ import { bus } from './event-bus.ts';
33
+ import { updateRuntimeMetrics } from './state-manager.ts';
34
+ import type { RuntimeMetrics, GcMetrics } from '../types/metrics.types.ts';
35
+
36
+ // ── Types ─────────────────────────────────────────────────────────────────────
37
+
38
+ interface WsLike {
39
+ send: (data: string) => void;
40
+ close: () => void;
41
+ on: (event: string, handler: (...args: unknown[]) => void) => void;
42
+ }
43
+
44
+ interface InspectorSession {
45
+ ws: WsLike;
46
+ id: number;
47
+ pending: Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>;
48
+ }
49
+
50
+ export interface RuntimeManagerHandle {
51
+ /** Register a managed process inspector port. Called by the launcher after spawn. */
52
+ register: (id: string, pid: number, wsUrl: string) => void;
53
+ /** Unregister a managed process. Called when the process exits. */
54
+ unregister: (id: string) => void;
55
+ /** Stop all inspector sessions and timers. */
56
+ stop: () => void;
57
+ }
58
+
59
+ // ── Internal session state ────────────────────────────────────────────────────
60
+
61
+ interface ProcessSession {
62
+ id: string;
63
+ pid: number;
64
+ wsUrl: string;
65
+ session: InspectorSession | null;
66
+ gcState: { lastHeap: number; count: number; totalMs: number; lastPauseMs: number | null };
67
+ }
68
+
69
+ // ── WebSocket helper ──────────────────────────────────────────────────────────
70
+ // Use the global WebSocket available in Bun 1+ and Node 22+.
71
+ // Falls back gracefully if the runtime doesn't support it.
72
+
73
+ async function openSession(wsUrl: string): Promise<InspectorSession> {
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ const WS = (globalThis as Record<string, unknown>)['WebSocket'] as (new (url: string) => WsLike) | undefined;
76
+ if (!WS) throw new Error('WebSocket not available in this runtime (need Bun 1+ or Node 22+)');
77
+
78
+ const ws = new WS(wsUrl);
79
+
80
+ return new Promise((resolve, reject) => {
81
+ const session: InspectorSession = {
82
+ ws,
83
+ id: 1,
84
+ pending: new Map(),
85
+ };
86
+
87
+ ws.on('open', () => resolve(session));
88
+ ws.on('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))));
89
+ ws.on('message', (raw: unknown) => {
90
+ try {
91
+ const str = typeof raw === 'string' ? raw : String(raw);
92
+ const msg = JSON.parse(str) as {
93
+ id?: number;
94
+ result?: unknown;
95
+ error?: { message: string };
96
+ };
97
+ if (msg.id !== undefined) {
98
+ const cb = session.pending.get(msg.id);
99
+ if (cb) {
100
+ session.pending.delete(msg.id);
101
+ if (msg.error) {
102
+ cb.reject(new Error(msg.error.message));
103
+ } else {
104
+ cb.resolve(msg.result);
105
+ }
106
+ }
107
+ }
108
+ } catch {
109
+ // ignore malformed messages
110
+ }
111
+ });
112
+ });
113
+ }
114
+
115
+ function cdpCall<T>(
116
+ session: InspectorSession,
117
+ method: string,
118
+ params: Record<string, unknown> = {}
119
+ ): Promise<T> {
120
+ return new Promise((resolve, reject) => {
121
+ const id = session.id++;
122
+ session.pending.set(id, {
123
+ resolve: resolve as (v: unknown) => void,
124
+ reject,
125
+ });
126
+ session.ws.send(JSON.stringify({ id, method, params }));
127
+ // Timeout individual calls to avoid hanging
128
+ setTimeout(() => {
129
+ if (session.pending.has(id)) {
130
+ session.pending.delete(id);
131
+ reject(new Error(`CDP timeout: ${method}`));
132
+ }
133
+ }, 3000);
134
+ });
135
+ }
136
+
137
+ // JS expression evaluated inside the target process
138
+ const METRICS_EXPR = `(function() {
139
+ const m = process.memoryUsage();
140
+ const handles = (process._getActiveHandles ? process._getActiveHandles().length : 0);
141
+ const requests = (process._getActiveRequests ? process._getActiveRequests().length : 0);
142
+ const uptime = process.uptime();
143
+ return JSON.stringify({ rss: m.rss, external: m.external, arrayBuffers: m.arrayBuffers, handles, requests, uptime });
144
+ })()`;
145
+
146
+ const LAG_EXPR = `(function() {
147
+ return new Promise(resolve => {
148
+ const start = Date.now();
149
+ setImmediate(() => resolve(Date.now() - start));
150
+ });
151
+ })()`;
152
+
153
+ // ── Poll a single process ──────────────────────────────────────────────────────
154
+
155
+ async function pollProcess(
156
+ ps: ProcessSession,
157
+ intervalMs: number
158
+ ): Promise<RuntimeMetrics | null> {
159
+ if (!ps.session) return null;
160
+
161
+ try {
162
+ const [heapResult, evalResult, lagResult] = await Promise.allSettled([
163
+ cdpCall<{ usedSize: number; totalSize: number }>(
164
+ ps.session, 'Runtime.getHeapUsage'
165
+ ),
166
+ cdpCall<{ result: { value: string } }>(
167
+ ps.session, 'Runtime.evaluate',
168
+ { expression: METRICS_EXPR, returnByValue: true, awaitPromise: false }
169
+ ),
170
+ cdpCall<{ result: { value: number } }>(
171
+ ps.session, 'Runtime.evaluate',
172
+ { expression: LAG_EXPR, returnByValue: true, awaitPromise: true,
173
+ timeout: Math.min(intervalMs, 2000) }
174
+ ),
175
+ ]);
176
+
177
+ const heap = heapResult.status === 'fulfilled' ? heapResult.value : null;
178
+ const evalData = evalResult.status === 'fulfilled'
179
+ ? JSON.parse(evalResult.value.result.value) as {
180
+ rss: number; external: number; arrayBuffers: number;
181
+ handles: number; requests: number; uptime: number;
182
+ }
183
+ : null;
184
+ const lag = lagResult.status === 'fulfilled'
185
+ ? lagResult.value.result.value
186
+ : 0;
187
+
188
+ // GC heuristic: if heap used dropped significantly, a GC likely ran
189
+ const heapUsed = heap?.usedSize ?? 0;
190
+ const heapTotal = heap?.totalSize ?? 0;
191
+ const prevHeap = ps.gcState.lastHeap;
192
+ if (prevHeap > 0 && heapUsed < prevHeap * 0.85) {
193
+ const freedMs = Math.round((prevHeap - heapUsed) / (1024 * 1024)); // crude proxy
194
+ ps.gcState.count++;
195
+ ps.gcState.totalMs += freedMs;
196
+ ps.gcState.lastPauseMs = freedMs;
197
+ }
198
+ ps.gcState.lastHeap = heapUsed;
199
+
200
+ const gc: GcMetrics = {
201
+ count: ps.gcState.count,
202
+ totalPauseMs: ps.gcState.totalMs,
203
+ lastPauseMs: ps.gcState.lastPauseMs,
204
+ };
205
+
206
+ return {
207
+ managedId: ps.id,
208
+ pid: ps.pid,
209
+ heapUsed,
210
+ heapTotal,
211
+ rss: evalData?.rss ?? 0,
212
+ external: evalData?.external ?? 0,
213
+ arrayBuffers: evalData?.arrayBuffers ?? 0,
214
+ eventLoopLag: typeof lag === 'number' ? lag : 0,
215
+ activeHandles: evalData?.handles ?? 0,
216
+ activeRequests: evalData?.requests ?? 0,
217
+ gc,
218
+ uptime: evalData?.uptime ?? 0,
219
+ timestamp: Date.now(),
220
+ };
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ // ── Factory ───────────────────────────────────────────────────────────────────
227
+
228
+ export function createRuntimeManager(intervalMs: number): RuntimeManagerHandle {
229
+ const sessions = new Map<string, ProcessSession>();
230
+ let timer: ReturnType<typeof setInterval> | null = null;
231
+
232
+ async function tick(): Promise<void> {
233
+ for (const ps of sessions.values()) {
234
+ if (!ps.session) continue;
235
+ const metrics = await pollProcess(ps, intervalMs);
236
+ if (metrics) {
237
+ updateRuntimeMetrics(metrics);
238
+ bus.emit('metrics:runtime:updated', metrics);
239
+ }
240
+ }
241
+ }
242
+
243
+ function startTimer(): void {
244
+ if (timer !== null) return;
245
+ // Slight delay on first poll to give the inspector time to warm up
246
+ setTimeout(() => {
247
+ void tick();
248
+ timer = setInterval(() => void tick(), intervalMs);
249
+ }, 1500);
250
+ }
251
+
252
+ async function register(id: string, pid: number, wsUrl: string): Promise<void> {
253
+ const ps: ProcessSession = {
254
+ id, pid, wsUrl,
255
+ session: null,
256
+ gcState: { lastHeap: 0, count: 0, totalMs: 0, lastPauseMs: null },
257
+ };
258
+ sessions.set(id, ps);
259
+
260
+ try {
261
+ ps.session = await openSession(wsUrl);
262
+ // Enable Runtime domain
263
+ await cdpCall(ps.session, 'Runtime.enable');
264
+ } catch (err) {
265
+ bus.emit('app:error', {
266
+ source: `runtime-manager:${id}`,
267
+ error: err instanceof Error ? err : new Error(String(err)),
268
+ });
269
+ ps.session = null;
270
+ }
271
+
272
+ startTimer();
273
+ }
274
+
275
+ function unregister(id: string): void {
276
+ const ps = sessions.get(id);
277
+ if (ps?.session) {
278
+ try { ps.session.ws.close(); } catch { /* ignore */ }
279
+ }
280
+ sessions.delete(id);
281
+ }
282
+
283
+ function stop(): void {
284
+ if (timer !== null) { clearInterval(timer); timer = null; }
285
+ for (const ps of sessions.values()) {
286
+ try { ps.session?.ws.close(); } catch { /* ignore */ }
287
+ }
288
+ sessions.clear();
289
+ }
290
+
291
+ // Expose register as async but return the synchronous-compatible handle
292
+ const handle: RuntimeManagerHandle = {
293
+ register: (id, pid, wsUrl) => { void register(id, pid, wsUrl); },
294
+ unregister,
295
+ stop,
296
+ };
297
+
298
+ return handle;
299
+ }
@@ -0,0 +1,88 @@
1
+ // ---------------------------------------------------------------------------
2
+ // State Manager
3
+ //
4
+ // Single source of truth for the latest system snapshot.
5
+ // Widgets NEVER fetch data — they read from state and react to bus events.
6
+ //
7
+ // This is intentionally NOT a reactive store (no proxies, no signals).
8
+ // The event bus handles reactivity. State is just a plain in-memory cache
9
+ // so widgets can read the last known value synchronously on first render
10
+ // without waiting for the next poll cycle.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import type {
14
+ CpuMetrics,
15
+ MemoryMetrics,
16
+ SystemMetrics,
17
+ DiskMetrics,
18
+ NetworkMetrics,
19
+ RuntimeMetrics,
20
+ } from '../types/metrics.types.ts';
21
+ import type { ProcessList } from '../types/process.types.ts';
22
+
23
+ // ── Internal state (module-scoped, not exported) ──────────────────────────────
24
+
25
+ let _cpu: CpuMetrics | null = null;
26
+ let _memory: MemoryMetrics | null = null;
27
+ let _disk: DiskMetrics | null = null;
28
+ let _network: NetworkMetrics | null = null;
29
+ let _processes: ProcessList = [];
30
+ // Runtime metrics keyed by managed process id
31
+ const _runtime = new Map<string, RuntimeMetrics>();
32
+
33
+ // ── CPU ───────────────────────────────────────────────────────────────────────
34
+
35
+ export function getCpuMetrics(): CpuMetrics | null { return _cpu; }
36
+ export function updateCpuMetrics(m: CpuMetrics): void { _cpu = m; }
37
+
38
+ // ── Memory ────────────────────────────────────────────────────────────────────
39
+
40
+ export function getMemoryMetrics(): MemoryMetrics | null { return _memory; }
41
+ export function updateMemoryMetrics(m: MemoryMetrics): void { _memory = m; }
42
+
43
+ // ── System (convenience) ──────────────────────────────────────────────────────
44
+
45
+ export function getSystemMetrics(): SystemMetrics | null {
46
+ if (!_cpu || !_memory) return null;
47
+ return { cpu: _cpu, memory: _memory };
48
+ }
49
+
50
+ // ── Disk ──────────────────────────────────────────────────────────────────────
51
+
52
+ export function getDiskMetrics(): DiskMetrics | null { return _disk; }
53
+ export function updateDiskMetrics(m: DiskMetrics): void { _disk = m; }
54
+
55
+ // ── Network ───────────────────────────────────────────────────────────────────
56
+
57
+ export function getNetworkMetrics(): NetworkMetrics | null { return _network; }
58
+ export function updateNetworkMetrics(m: NetworkMetrics): void { _network = m; }
59
+
60
+ // ── Processes ─────────────────────────────────────────────────────────────────
61
+
62
+ export function getProcesses(): ProcessList { return _processes; }
63
+ export function updateProcesses(list: ProcessList): void { _processes = list; }
64
+
65
+ // ── Runtime (per managed process) ─────────────────────────────────────────────
66
+
67
+ export function getRuntimeMetrics(id: string): RuntimeMetrics | null {
68
+ return _runtime.get(id) ?? null;
69
+ }
70
+
71
+ export function getAllRuntimeMetrics(): RuntimeMetrics[] {
72
+ return [..._runtime.values()];
73
+ }
74
+
75
+ export function updateRuntimeMetrics(m: RuntimeMetrics): void {
76
+ _runtime.set(m.managedId, m);
77
+ }
78
+
79
+ // ── Reset ─────────────────────────────────────────────────────────────────────
80
+
81
+ export function resetState(): void {
82
+ _cpu = null;
83
+ _memory = null;
84
+ _disk = null;
85
+ _network = null;
86
+ _processes = [];
87
+ _runtime.clear();
88
+ }
@@ -0,0 +1,76 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Disk Service
3
+ //
4
+ // Wraps systeminformation filesystem APIs to produce normalized DiskMetrics.
5
+ //
6
+ // Two si calls per tick:
7
+ // si.fsSize() → per-mount space usage (total/used/free/%)
8
+ // si.fsStats() → aggregate read/write bytes since last call (delta-based)
9
+ //
10
+ // IO rates are calculated as deltas between successive calls. The first call
11
+ // returns zero rates (no baseline yet). Subsequent calls divide the byte delta
12
+ // by the elapsed seconds to get bytes/sec.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import si from 'systeminformation';
16
+ import type { DiskMetrics, DiskMount, DiskIO } from '../types/metrics.types.ts';
17
+
18
+ // ── Fetch ─────────────────────────────────────────────────────────────────────
19
+
20
+ export async function fetchDiskMetrics(): Promise<DiskMetrics> {
21
+ const now = Date.now();
22
+
23
+ const [fsSizes, disksIO] = await Promise.all([
24
+ si.fsSize(),
25
+ si.disksIO().catch(() => null),
26
+ ]);
27
+
28
+ // ── IO delta calculation ────────────────────────────────────────────────────
29
+ // disksIO may return null on some platforms (e.g. Windows without admin).
30
+
31
+ let diskIo: DiskIO | null = null;
32
+
33
+ // disksIO returns an object with rIO/wIO/tIO/rIO_sec/wIO_sec/tIO_sec/rIO_ms/wIO_ms...
34
+ // The *_sec fields are already rates calculated by systeminformation.
35
+ if (disksIO !== null && typeof disksIO === 'object') {
36
+ const d = disksIO as unknown as Record<string, number>;
37
+ const readBPS = (d['rIO_sec'] ?? 0) * 512; // sectors → bytes (approx)
38
+ const writeBPS = (d['wIO_sec'] ?? 0) * 512;
39
+ const readIops = d['rIO_sec'] ?? 0;
40
+ const writeIops = d['wIO_sec'] ?? 0;
41
+ const totalBPS = readBPS + writeBPS;
42
+ const utilization = Math.min(100, (totalBPS / (500 * 1024 * 1024)) * 100);
43
+
44
+ diskIo = {
45
+ readBytesPerSec: Math.round(readBPS),
46
+ writeBytesPerSec: Math.round(writeBPS),
47
+ readIOPS: Math.round(readIops),
48
+ writeIOPS: Math.round(writeIops),
49
+ utilization: Math.round(utilization * 10) / 10,
50
+ };
51
+ }
52
+
53
+ void 0; // placeholder
54
+ // ── Mount entries ──────────────────────────────────────────────────────────
55
+ // Filter out pseudo-filesystems (tmpfs, devfs, etc.) that have 0 total size.
56
+
57
+ const mounts: DiskMount[] = fsSizes
58
+ .filter(fs => fs.size > 0)
59
+ .map(fs => ({
60
+ fs: fs.fs ?? 'unknown',
61
+ mount: fs.mount ?? '/',
62
+ type: fs.type ?? 'unknown',
63
+ total: fs.size ?? 0,
64
+ used: fs.used ?? 0,
65
+ free: (fs.size ?? 0) - (fs.used ?? 0),
66
+ percent: fs.use ?? 0,
67
+ io: diskIo, // same IO object for all mounts (aggregate only for now)
68
+ }));
69
+
70
+ return {
71
+ mounts,
72
+ totalReadBytesPerSec: diskIo?.readBytesPerSec ?? 0,
73
+ totalWriteBytesPerSec: diskIo?.writeBytesPerSec ?? 0,
74
+ timestamp: now,
75
+ };
76
+ }
@@ -0,0 +1,131 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Network Service
3
+ //
4
+ // Wraps systeminformation network APIs to produce normalized NetworkMetrics.
5
+ //
6
+ // Two si calls per tick:
7
+ // si.networkInterfaces() → static info (IP addresses, operstate)
8
+ // si.networkStats() → cumulative byte/packet counters (delta-based)
9
+ //
10
+ // Rates are calculated as deltas between successive calls — same approach as
11
+ // disk.service.ts. First call returns zero rates.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import si from 'systeminformation';
15
+ import type { NetworkMetrics, NetworkInterface } from '../types/metrics.types.ts';
16
+
17
+ // ── Delta tracking ────────────────────────────────────────────────────────────
18
+
19
+ interface IfaceSnapshot {
20
+ rx_bytes: number;
21
+ tx_bytes: number;
22
+ rx_sec: number; // packets
23
+ tx_sec: number;
24
+ rx_errors: number;
25
+ tx_errors: number;
26
+ rx_dropped: number;
27
+ tx_dropped: number;
28
+ ts: number;
29
+ }
30
+
31
+ const _prev = new Map<string, IfaceSnapshot>();
32
+
33
+ // ── Helpers ───────────────────────────────────────────────────────────────────
34
+
35
+ function operstate(s: string | undefined): 'up' | 'down' | 'unknown' {
36
+ if (s === 'up') return 'up';
37
+ if (s === 'down') return 'down';
38
+ return 'unknown';
39
+ }
40
+
41
+ // ── Fetch ─────────────────────────────────────────────────────────────────────
42
+
43
+ export async function fetchNetworkMetrics(): Promise<NetworkMetrics> {
44
+ const now = Date.now();
45
+
46
+ const [ifaces, stats] = await Promise.all([
47
+ si.networkInterfaces(),
48
+ si.networkStats('*'),
49
+ ]);
50
+
51
+ // Build a map of static info keyed by iface name
52
+ const ifaceArray = Array.isArray(ifaces) ? ifaces : [ifaces];
53
+ const staticInfo = new Map(ifaceArray.map(i => [i.iface, i]));
54
+
55
+ const statsArray = Array.isArray(stats) ? stats : [stats];
56
+
57
+ const interfaces: NetworkInterface[] = [];
58
+ let totalRx = 0;
59
+ let totalTx = 0;
60
+
61
+ for (const s of statsArray) {
62
+ const iface = s.iface ?? '';
63
+ if (!iface) continue;
64
+
65
+ const prev = _prev.get(iface);
66
+ const dtSec = prev ? Math.max(0.001, (now - prev.ts) / 1000) : 1;
67
+
68
+ const rxBPS = prev
69
+ ? Math.max(0, ((s.rx_bytes ?? 0) - prev.rx_bytes) / dtSec)
70
+ : 0;
71
+ const txBPS = prev
72
+ ? Math.max(0, ((s.tx_bytes ?? 0) - prev.tx_bytes) / dtSec)
73
+ : 0;
74
+ const rxPPS = prev
75
+ ? Math.max(0, ((s.rx_sec ?? 0) - prev.rx_sec) / dtSec)
76
+ : 0;
77
+ const txPPS = prev
78
+ ? Math.max(0, ((s.tx_sec ?? 0) - prev.tx_sec) / dtSec)
79
+ : 0;
80
+
81
+ _prev.set(iface, {
82
+ rx_bytes: s.rx_bytes ?? 0,
83
+ tx_bytes: s.tx_bytes ?? 0,
84
+ rx_sec: s.rx_sec ?? 0,
85
+ tx_sec: s.tx_sec ?? 0,
86
+ rx_errors: s.rx_errors ?? 0,
87
+ tx_errors: s.tx_errors ?? 0,
88
+ rx_dropped: s.rx_dropped ?? 0,
89
+ tx_dropped: s.tx_dropped ?? 0,
90
+ ts: now,
91
+ });
92
+
93
+ const info = staticInfo.get(iface);
94
+
95
+ totalRx += rxBPS;
96
+ totalTx += txBPS;
97
+
98
+ // Skip loopback in displayed interfaces but keep it in totals
99
+ if (iface === 'lo' || iface === 'loopback') continue;
100
+
101
+ interfaces.push({
102
+ iface,
103
+ ip4: info?.ip4 ?? '',
104
+ ip6: info?.ip6 ?? '',
105
+ operstate: operstate(info?.operstate),
106
+ rxBytesPerSec: Math.round(rxBPS),
107
+ txBytesPerSec: Math.round(txBPS),
108
+ rxPacketsPerSec: Math.round(rxPPS),
109
+ txPacketsPerSec: Math.round(txPPS),
110
+ rxErrors: s.rx_errors ?? 0,
111
+ txErrors: s.tx_errors ?? 0,
112
+ rxDrops: s.rx_dropped ?? 0,
113
+ txDrops: s.tx_dropped ?? 0,
114
+ });
115
+ }
116
+
117
+ // Sort: up interfaces first, then by name
118
+ interfaces.sort((a, b) => {
119
+ if (a.operstate !== b.operstate) {
120
+ return a.operstate === 'up' ? -1 : 1;
121
+ }
122
+ return a.iface.localeCompare(b.iface);
123
+ });
124
+
125
+ return {
126
+ interfaces,
127
+ totalRxBytesPerSec: Math.round(totalRx),
128
+ totalTxBytesPerSec: Math.round(totalTx),
129
+ timestamp: now,
130
+ };
131
+ }
@@ -0,0 +1,49 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Process Service
3
+ //
4
+ // Fetches and normalizes the process list using systeminformation.
5
+ // Maps raw si data to the canonical ProcessInfo shape — including the
6
+ // Phase 2 extended fields: user, threads, handles.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import si from 'systeminformation';
10
+ import type { ProcessInfo, ProcessList, ProcessStatus } from '../types/process.types.ts';
11
+
12
+ function mapStatus(raw: string): ProcessStatus {
13
+ switch (raw.toLowerCase()) {
14
+ case 'running': return 'running';
15
+ case 'sleeping':
16
+ case 'sleep': return 'sleeping';
17
+ case 'stopped': return 'stopped';
18
+ case 'zombie': return 'zombie';
19
+ default: return 'unknown';
20
+ }
21
+ }
22
+
23
+ export async function fetchProcessList(limit = 100): Promise<ProcessList> {
24
+ const { list } = await si.processes();
25
+
26
+ // Sort by CPU descending before slicing so we surface the most active
27
+ const sorted = [...list].sort((a, b) => b.cpu - a.cpu);
28
+ const top = sorted.slice(0, limit);
29
+
30
+ const totalMem = (await si.mem()).total;
31
+
32
+ return top.map((p): ProcessInfo => ({
33
+ pid: p.pid,
34
+ name: p.name || 'unknown',
35
+ command: p.command || p.name || '',
36
+ cpu: Math.round((p.cpu ?? 0) * 10) / 10,
37
+ memory: p.memRss ?? 0,
38
+ memoryPercent: totalMem > 0
39
+ ? Math.round(((p.memRss ?? 0) / totalMem) * 1000) / 10
40
+ : 0,
41
+ status: mapStatus(p.state ?? ''),
42
+ startedAt: p.started ? new Date(p.started).getTime() : null,
43
+ ppid: p.parentPid ?? null,
44
+ // ── Extended (Phase 2) ──────────────────────────────────────────────────
45
+ user: (p as unknown as { user?: string }).user ?? '',
46
+ threads: (p as unknown as { threads?: number }).threads ?? 1,
47
+ handles: (p as unknown as { fd?: number }).fd ?? 0,
48
+ }));
49
+ }