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,182 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Metrics Types
3
+ //
4
+ // Domain types for all system metrics flowing through the event bus and state
5
+ // manager. Never expose raw systeminformation objects outside the services layer.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ // ── CPU ───────────────────────────────────────────────────────────────────────
9
+
10
+ export interface CpuCore {
11
+ /** Core index (0-based) */
12
+ index: number;
13
+ /** Usage percentage 0–100 */
14
+ usage: number;
15
+ }
16
+
17
+ export interface CpuMetrics {
18
+ /** Overall CPU usage percentage (0–100) */
19
+ usage: number;
20
+ /** Per-core usage */
21
+ cores: CpuCore[];
22
+ /** Human-readable model string e.g. "Intel Core i7-1185G7" */
23
+ model: string;
24
+ /** Number of logical cores */
25
+ coreCount: number;
26
+ /** Timestamp of this snapshot (unix ms) */
27
+ timestamp: number;
28
+ }
29
+
30
+ // ── Memory ────────────────────────────────────────────────────────────────────
31
+
32
+ export interface MemoryMetrics {
33
+ /** Total physical memory in bytes */
34
+ total: number;
35
+ /** Used memory in bytes */
36
+ used: number;
37
+ /** Free memory in bytes */
38
+ free: number;
39
+ /** Used percentage 0–100 */
40
+ percent: number;
41
+ /** Active memory in bytes */
42
+ active: number;
43
+ /** Cached/buffered memory in bytes */
44
+ cached: number;
45
+ /** Timestamp of this snapshot (unix ms) */
46
+ timestamp: number;
47
+ }
48
+
49
+ // ── System (convenience composite) ───────────────────────────────────────────
50
+
51
+ export interface SystemMetrics {
52
+ cpu: CpuMetrics;
53
+ memory: MemoryMetrics;
54
+ }
55
+
56
+ // ── Disk ──────────────────────────────────────────────────────────────────────
57
+
58
+ export interface DiskIO {
59
+ /** Read throughput in bytes/sec */
60
+ readBytesPerSec: number;
61
+ /** Write throughput in bytes/sec */
62
+ writeBytesPerSec: number;
63
+ /** Read operations per second */
64
+ readIOPS: number;
65
+ /** Write operations per second */
66
+ writeIOPS: number;
67
+ /** Disk utilization 0–100% */
68
+ utilization: number;
69
+ }
70
+
71
+ export interface DiskMount {
72
+ /** Block device path e.g. "/dev/sda1" */
73
+ fs: string;
74
+ /** Mount point e.g. "/" or "/home" */
75
+ mount: string;
76
+ /** Filesystem type e.g. "ext4", "NTFS" */
77
+ type: string;
78
+ /** Total capacity in bytes */
79
+ total: number;
80
+ /** Used bytes */
81
+ used: number;
82
+ /** Free bytes */
83
+ free: number;
84
+ /** Used percentage 0–100 */
85
+ percent: number;
86
+ /** IO stats — null if unavailable on this platform */
87
+ io: DiskIO | null;
88
+ }
89
+
90
+ export interface DiskMetrics {
91
+ /** One entry per mounted filesystem */
92
+ mounts: DiskMount[];
93
+ /** Aggregate read across all disks (bytes/sec) */
94
+ totalReadBytesPerSec: number;
95
+ /** Aggregate write across all disks (bytes/sec) */
96
+ totalWriteBytesPerSec: number;
97
+ /** Timestamp of this snapshot (unix ms) */
98
+ timestamp: number;
99
+ }
100
+
101
+ // ── Network ───────────────────────────────────────────────────────────────────
102
+
103
+ export interface NetworkInterface {
104
+ /** Interface name e.g. "eth0", "en0", "Wi-Fi" */
105
+ iface: string;
106
+ /** IPv4 address (empty string if none) */
107
+ ip4: string;
108
+ /** IPv6 address (empty string if none) */
109
+ ip6: string;
110
+ /** Whether the interface is up */
111
+ operstate: 'up' | 'down' | 'unknown';
112
+ /** Received bytes per second */
113
+ rxBytesPerSec: number;
114
+ /** Transmitted bytes per second */
115
+ txBytesPerSec: number;
116
+ /** Received packets per second */
117
+ rxPacketsPerSec: number;
118
+ /** Transmitted packets per second */
119
+ txPacketsPerSec: number;
120
+ /** Cumulative RX errors */
121
+ rxErrors: number;
122
+ /** Cumulative TX errors */
123
+ txErrors: number;
124
+ /** Cumulative RX drops */
125
+ rxDrops: number;
126
+ /** Cumulative TX drops */
127
+ txDrops: number;
128
+ }
129
+
130
+ export interface NetworkMetrics {
131
+ /** One entry per active network interface */
132
+ interfaces: NetworkInterface[];
133
+ /** Total download across all interfaces (bytes/sec) */
134
+ totalRxBytesPerSec: number;
135
+ /** Total upload across all interfaces (bytes/sec) */
136
+ totalTxBytesPerSec: number;
137
+ /** Timestamp of this snapshot (unix ms) */
138
+ timestamp: number;
139
+ }
140
+
141
+ // ── Runtime (Node / Bun specific) ─────────────────────────────────────────────
142
+
143
+ export interface GcMetrics {
144
+ /** Most recent GC pause duration in ms (null if no GC has occurred) */
145
+ lastPauseMs: number | null;
146
+ /** Total GC pause time in ms since process start */
147
+ totalPauseMs: number;
148
+ /** Number of GC events since process start */
149
+ count: number;
150
+ }
151
+
152
+ export interface RuntimeMetrics {
153
+ /** Managed process ID (name from ManagedProcessDef) */
154
+ managedId: string;
155
+ /** OS PID of the process */
156
+ pid: number;
157
+ /** V8 heap used in bytes */
158
+ heapUsed: number;
159
+ /** V8 heap total allocated in bytes */
160
+ heapTotal: number;
161
+ /** RSS (Resident Set Size) in bytes */
162
+ rss: number;
163
+ /** External memory held by C++ objects in bytes */
164
+ external: number;
165
+ /** ArrayBuffer memory in bytes */
166
+ arrayBuffers: number;
167
+ /**
168
+ * Event loop lag in milliseconds — how long the event loop is blocked
169
+ * beyond the expected tick interval. > 100ms = problematic.
170
+ */
171
+ eventLoopLag: number;
172
+ /** Number of active libuv handles (timers, sockets, etc.) */
173
+ activeHandles: number;
174
+ /** Number of active libuv requests */
175
+ activeRequests: number;
176
+ /** Garbage collector metrics */
177
+ gc: GcMetrics;
178
+ /** Process uptime in seconds */
179
+ uptime: number;
180
+ /** Timestamp of this snapshot (unix ms) */
181
+ timestamp: number;
182
+ }
@@ -0,0 +1,49 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Process Types
3
+ // Domain types for process data. ProcessInfo is the normalized shape used
4
+ // throughout MetWatch regardless of whether data comes from systeminformation
5
+ // or pidusage. Never expose raw library types outside the services layer.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export type ProcessStatus = 'running' | 'sleeping' | 'stopped' | 'zombie' | 'unknown';
9
+
10
+ export interface ProcessInfo {
11
+ pid: number;
12
+ /** Human-readable process name */
13
+ name: string;
14
+ /** Full command line with arguments */
15
+ command: string;
16
+ /** CPU usage percentage (0–100, can exceed 100 on multi-core) */
17
+ cpu: number;
18
+ /** Memory usage in bytes (RSS) */
19
+ memory: number;
20
+ /** Memory as a percentage of total RAM (0–100) */
21
+ memoryPercent: number;
22
+ status: ProcessStatus;
23
+ /** Process start time as unix ms, if available */
24
+ startedAt: number | null;
25
+ /** Parent PID */
26
+ ppid: number | null;
27
+ // ── Extended fields (Phase 2) ──────────────────────────────────────────
28
+ /** Username that owns the process (empty string if unavailable) */
29
+ user: string;
30
+ /** Number of threads */
31
+ threads: number;
32
+ /** Open file descriptors / handles */
33
+ handles: number;
34
+ }
35
+
36
+ export type ProcessList = ProcessInfo[];
37
+
38
+ /** View mode for the process table widget */
39
+ export type ProcessViewMode = 'all' | 'watched';
40
+
41
+ /** Column to sort the process table by */
42
+ export type ProcessSortKey = 'cpu' | 'memory' | 'name' | 'pid';
43
+
44
+ export interface ProcessTableState {
45
+ viewMode: ProcessViewMode;
46
+ sortKey: ProcessSortKey;
47
+ sortDesc: boolean;
48
+ selectedIndex: number;
49
+ }
@@ -0,0 +1,318 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Layout — Dynamic Collapsible Grid
3
+ //
4
+ // btop-inspired layout: all panels live on one screen, user can toggle each
5
+ // panel on/off with a key. Hidden panels collapse; remaining panels expand.
6
+ //
7
+ // Default grid (all panels visible, ~50 row terminal assumed):
8
+ //
9
+ // ┌────────────┬────────────┬────────────┐ ← row A height ~20%
10
+ // │ CPU │ Memory │ Disk │
11
+ // ├────────────┴────────────┴────────────┤ ← row B height ~18%
12
+ // │ Network │ Runtime │
13
+ // ├─────────────────────────────────────┤ ← row C height ~32%
14
+ // │ Processes │
15
+ // ├─────────────────────────────────────┤ ← row D height ~30%
16
+ // │ Logs │
17
+ // └─────────────────────────────────────┘
18
+ //
19
+ // Panel toggle keys:
20
+ // d → Disk n → Network
21
+ // R → Runtime p → Processes
22
+ // l → Logs (focus/toggle)
23
+ // (CPU and Memory are always visible)
24
+ //
25
+ // Heights are recalculated every time a panel is toggled. The layout
26
+ // re-builds widget positions using blessed's hide()/show() — no widget
27
+ // teardown needed (positions are updated via .top/.height attributes).
28
+ // ---------------------------------------------------------------------------
29
+
30
+ import blessed from 'blessed';
31
+ import type { BlessedScreen, BlessedElement } from 'blessed';
32
+ import type { ResolvedConfig } from '../types/config.types.ts';
33
+ import type { LauncherHandle } from '../core/launcher.ts';
34
+ import type { LogManagerHandle } from '../core/log-manager.ts';
35
+ import { createCpuWidget } from './widgets/cpu.widget.ts';
36
+ import { createMemoryWidget } from './widgets/memory.widget.ts';
37
+ import { createDiskWidget } from './widgets/disk.widget.ts';
38
+ import { createNetworkWidget } from './widgets/network.widget.ts';
39
+ import { createRuntimeWidget } from './widgets/runtime.widget.ts';
40
+ import { createProcessTableWidget } from './widgets/process-table.widget.ts';
41
+ import { createLogsWidget } from './widgets/logs.widget.ts';
42
+
43
+ interface LayoutOptions {
44
+ screen: BlessedScreen;
45
+ config: ResolvedConfig;
46
+ launcher: LauncherHandle | null;
47
+ logManager: LogManagerHandle | null;
48
+ }
49
+
50
+ interface LayoutHandles {
51
+ destroy: () => void;
52
+ }
53
+
54
+ // ── Height constants (percentages, must sum to 100 when all panels visible) ──
55
+
56
+ const H = {
57
+ ROW_A: 20, // CPU + Memory + Disk
58
+ ROW_B: 24, // Network + Runtime (extra height for line graph)
59
+ ROW_C: 30, // Processes
60
+ ROW_D: 26, // Logs
61
+ } as const;
62
+
63
+ // ── Utility ───────────────────────────────────────────────────────────────────
64
+
65
+ type Pct = `${number}%`;
66
+ function pct(n: number): Pct { return `${n}%`; }
67
+
68
+ /** Given a set of visible row heights (% units), compute their cumulative tops. */
69
+ function tops(heights: number[]): number[] {
70
+ const result: number[] = [];
71
+ let acc = 0;
72
+ for (const h of heights) {
73
+ result.push(acc);
74
+ acc += h;
75
+ }
76
+ return result;
77
+ }
78
+
79
+ // ── Factory ───────────────────────────────────────────────────────────────────
80
+
81
+ export function buildLayout(opts: LayoutOptions): LayoutHandles {
82
+ const { screen, config, launcher, logManager } = opts;
83
+
84
+ const panels = config.panels;
85
+ const managedNames = new Set<string>(
86
+ launcher ? launcher.getAll().map(p => p.name) : []
87
+ );
88
+
89
+ // Track which optional panels are currently visible
90
+ const visible = {
91
+ disk: panels.disk !== false,
92
+ network: panels.network !== false,
93
+ runtime: panels.runtime !== false,
94
+ processes: panels.processes !== false,
95
+ logs: panels.logs !== false,
96
+ };
97
+
98
+ // ── Create all widgets ─────────────────────────────────────────────────────
99
+ // Initial positions are placeholders; recalc() sets real values.
100
+
101
+ const cpu = createCpuWidget({
102
+ screen, top: 0, left: 0, width: '34%', height: pct(H.ROW_A),
103
+ });
104
+
105
+ const memory = createMemoryWidget({
106
+ screen, top: 0, left: '34%', width: '33%', height: pct(H.ROW_A),
107
+ });
108
+
109
+ const disk = createDiskWidget({
110
+ screen, top: 0, left: '67%', width: '33%', height: pct(H.ROW_A),
111
+ });
112
+
113
+ const network = createNetworkWidget({
114
+ screen, top: pct(H.ROW_A), left: 0, width: '55%', height: pct(H.ROW_B),
115
+ });
116
+
117
+ const runtime = createRuntimeWidget({
118
+ screen, top: pct(H.ROW_A), left: '55%', width: '45%', height: pct(H.ROW_B),
119
+ });
120
+
121
+ const processes = createProcessTableWidget({
122
+ screen,
123
+ config,
124
+ getManagedById: (id) => launcher?.get(id),
125
+ managedNames,
126
+ top: pct(H.ROW_A + H.ROW_B),
127
+ left: 0,
128
+ width: '100%',
129
+ height: pct(H.ROW_C),
130
+ });
131
+
132
+ const logs = createLogsWidget({
133
+ screen,
134
+ logManager: logManager ?? {
135
+ getLines: () => [],
136
+ getAllLines: () => [],
137
+ clearLines: () => undefined,
138
+ destroy: () => undefined,
139
+ },
140
+ processCount: managedNames.size,
141
+ top: pct(H.ROW_A + H.ROW_B + H.ROW_C),
142
+ left: 0,
143
+ width: '100%',
144
+ height: pct(H.ROW_D),
145
+ });
146
+
147
+ // ── Apply initial visibility from config ───────────────────────────────────
148
+
149
+ function applyVisibility(): void {
150
+ // Row A: CPU + Memory always shown; Disk optional.
151
+ // When Disk is hidden, CPU and Memory each take 50%.
152
+ const cpuBox = cpu.box as unknown as { top: string; left: string; width: string; height: string };
153
+ const memBox = memory.box as unknown as { top: string; left: string; width: string; height: string };
154
+ const diskBox = disk.box as unknown as { top: string; left: string; width: string; height: string; hide: () => void; show: () => void };
155
+ const netBox = network.box as unknown as { top: string; left: string; width: string; height: string; hide: () => void; show: () => void };
156
+ const runtimeBox = runtime.box as unknown as { top: string; left: string; width: string; height: string; hide: () => void; show: () => void };
157
+ const procBox = processes.box as unknown as { top: string; height: string; hide: () => void; show: () => void };
158
+ const logsBox = logs.box as unknown as { top: string; height: string; hide: () => void; show: () => void };
159
+
160
+ // Row A widths
161
+ if (visible.disk) {
162
+ cpuBox.width = '34%'; cpuBox.left = '0%';
163
+ memBox.width = '33%'; memBox.left = '34%';
164
+ diskBox.width = '33%'; diskBox.left = '67%';
165
+ diskBox.show();
166
+ } else {
167
+ cpuBox.width = '50%'; cpuBox.left = '0%';
168
+ memBox.width = '50%'; memBox.left = '50%';
169
+ diskBox.hide();
170
+ }
171
+
172
+ // Row A height
173
+ const rowAH = H.ROW_A;
174
+ cpuBox.height = pct(rowAH); memBox.height = pct(rowAH);
175
+
176
+ // Row B: Network + Runtime. If both hidden, row B has height 0.
177
+ let rowBTop = rowAH;
178
+ let rowBH = 0;
179
+
180
+ if (visible.network || visible.runtime) {
181
+ rowBH = H.ROW_B;
182
+ if (visible.network && visible.runtime) {
183
+ netBox.width = '55%'; netBox.left = '0%';
184
+ runtimeBox.width = '45%'; runtimeBox.left = '55%';
185
+ } else if (visible.network) {
186
+ netBox.width = '100%'; netBox.left = '0%';
187
+ } else {
188
+ runtimeBox.width = '100%'; runtimeBox.left = '0%';
189
+ }
190
+ if (visible.network) { netBox.top = pct(rowBTop); netBox.height = pct(rowBH); netBox.show(); }
191
+ else netBox.hide();
192
+ if (visible.runtime) { runtimeBox.top = pct(rowBTop); runtimeBox.height = pct(rowBH); runtimeBox.show(); }
193
+ else runtimeBox.hide();
194
+ } else {
195
+ netBox.hide();
196
+ runtimeBox.hide();
197
+ }
198
+
199
+ // Remaining height after rows A and B
200
+ const remaining = 100 - rowAH - rowBH;
201
+
202
+ // Split remaining between Processes and Logs
203
+ const showProc = visible.processes;
204
+ const showLogs = visible.logs;
205
+
206
+ let procH = 0;
207
+ let logsH = 0;
208
+
209
+ if (showProc && showLogs) {
210
+ procH = Math.round(remaining * 0.55);
211
+ logsH = remaining - procH;
212
+ } else if (showProc) {
213
+ procH = remaining;
214
+ } else if (showLogs) {
215
+ logsH = remaining;
216
+ }
217
+
218
+ const rowCTop = rowAH + rowBH;
219
+ const rowDTop = rowCTop + procH;
220
+
221
+ if (showProc) {
222
+ procBox.top = pct(rowCTop); procBox.height = pct(procH); procBox.show();
223
+ } else {
224
+ procBox.hide();
225
+ }
226
+
227
+ if (showLogs) {
228
+ logsBox.top = pct(rowDTop); logsBox.height = pct(logsH); logsBox.show();
229
+ } else {
230
+ logsBox.hide();
231
+ }
232
+
233
+ screen.render();
234
+ }
235
+
236
+ applyVisibility();
237
+
238
+ // ── Toggle keybindings ─────────────────────────────────────────────────────
239
+
240
+ screen.key(['d'], () => {
241
+ visible.disk = !visible.disk;
242
+ applyVisibility();
243
+ });
244
+
245
+ screen.key(['n'], () => {
246
+ visible.network = !visible.network;
247
+ applyVisibility();
248
+ });
249
+
250
+ screen.key(['R'], () => {
251
+ visible.runtime = !visible.runtime;
252
+ applyVisibility();
253
+ });
254
+
255
+ screen.key(['p'], () => {
256
+ visible.processes = !visible.processes;
257
+ applyVisibility();
258
+ });
259
+
260
+ // ── Help overlay ───────────────────────────────────────────────────────────
261
+ // Press ? to show keybindings overlay
262
+
263
+ const helpBox = blessed.box({
264
+ parent: screen as unknown as BlessedElement,
265
+ top: 'center', left: 'center',
266
+ width: 52, height: 18,
267
+ tags: true,
268
+ border: { type: 'line' },
269
+ hidden: true,
270
+ style: { border: { fg: 'white' }, bg: 'black' },
271
+ label: ' MetWatch — Keybindings ',
272
+ content: [
273
+ '',
274
+ ' {bold}Navigation{/bold}',
275
+ ' ↑ / k Move process selection up',
276
+ ' ↓ / j Move process selection down',
277
+ '',
278
+ ' {bold}View{/bold}',
279
+ ' a Process table: All mode',
280
+ ' f Process table: Watched mode',
281
+ ' c / m Sort by CPU / Memory',
282
+ '',
283
+ ' {bold}Panel Toggles{/bold}',
284
+ ' d Toggle Disk panel',
285
+ ' n Toggle Network panel',
286
+ ' R Toggle Runtime panel',
287
+ ' p Toggle Process panel',
288
+ '',
289
+ ' {bold}Actions{/bold}',
290
+ ' K Kill selected process',
291
+ ' r Restart managed process',
292
+ ' s Stop managed process',
293
+ ' q / Ctrl+C Quit',
294
+ ' ? Close this help',
295
+ ].join('\n'),
296
+ });
297
+
298
+ screen.key(['?'], () => {
299
+ const box = helpBox as unknown as { hidden: boolean; show: () => void; hide: () => void };
300
+ if (box.hidden) { box.show(); } else { box.hide(); }
301
+ screen.render();
302
+ });
303
+
304
+ // ── Destroy ────────────────────────────────────────────────────────────────
305
+
306
+ function destroy(): void {
307
+ cpu.destroy();
308
+ memory.destroy();
309
+ disk.destroy();
310
+ network.destroy();
311
+ runtime.destroy();
312
+ processes.destroy();
313
+ logs.destroy();
314
+ helpBox.destroy();
315
+ }
316
+
317
+ return { destroy };
318
+ }
@@ -0,0 +1,45 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Screen
3
+ //
4
+ // Creates and exports the blessed screen singleton. This is the only place
5
+ // in the codebase where `blessed.screen()` is called. All other modules
6
+ // receive the screen via parameter or import this module.
7
+ //
8
+ // Configuration choices:
9
+ // smartCSR — only redraw damaged regions (significant perf boost)
10
+ // fullUnicode — required for block chars (█) used in bars/gauges
11
+ // dockBorders — adjacent panels share border chars cleanly (┬ ┤ etc.)
12
+ // autoPadding — children auto-respect parent border+padding
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import blessed from 'blessed';
16
+ import type { BlessedScreen } from 'blessed';
17
+
18
+ let _screen: BlessedScreen | null = null;
19
+
20
+ export function createScreen(): BlessedScreen {
21
+ if (_screen) return _screen;
22
+
23
+ _screen = blessed.screen({
24
+ smartCSR: true,
25
+ fullUnicode: true,
26
+ dockBorders: true,
27
+ autoPadding: true,
28
+ title: 'MetWatch',
29
+ ignoreLocked: ['C-c'],
30
+ });
31
+
32
+ return _screen;
33
+ }
34
+
35
+ export function getScreen(): BlessedScreen {
36
+ if (!_screen) throw new Error('Screen not initialized. Call createScreen() first.');
37
+ return _screen;
38
+ }
39
+
40
+ export function destroyScreen(): void {
41
+ if (_screen) {
42
+ _screen.destroy();
43
+ _screen = null;
44
+ }
45
+ }
@@ -0,0 +1,98 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CPU Widget
3
+ //
4
+ // Renders per-core CPU usage as stacked horizontal gauges plus an overall
5
+ // usage bar. Subscribes to 'metrics:cpu:updated' events and re-renders.
6
+ //
7
+ // Layout:
8
+ // ┌─ CPU ──────────────────────────────────┐
9
+ // │ Overall ████████░░░░░░░░ 45.3% │
10
+ // │ Core 0 ██████░░░░░░░░░░ 38.1% │
11
+ // │ Core 1 █████████░░░░░░░ 52.4% │
12
+ // │ ... │
13
+ // └─────────────────────────────────────────┘
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import blessed from 'blessed';
17
+ import type { BlessedScreen, Box } from 'blessed';
18
+ import { bus } from '../../core/event-bus.ts';
19
+ import { getCpuMetrics } from '../../core/state-manager.ts';
20
+ import type { CpuMetrics } from '../../types/metrics.types.ts';
21
+ import { formatPercent, colorPercent } from '../../utils/formatters.ts';
22
+
23
+ interface CpuWidgetOptions {
24
+ screen: BlessedScreen;
25
+ top: number | string;
26
+ left: number | string;
27
+ width: number | string;
28
+ height: number | string;
29
+ }
30
+
31
+ interface CpuWidgetHandle {
32
+ box: Box;
33
+ destroy: () => void;
34
+ }
35
+
36
+ export function createCpuWidget(options: CpuWidgetOptions): CpuWidgetHandle {
37
+ const { screen, top, left, width, height } = options;
38
+
39
+ const box = blessed.box({
40
+ top,
41
+ left,
42
+ width,
43
+ height,
44
+ label: ' CPU ',
45
+ tags: true,
46
+ border: { type: 'line' },
47
+ style: {
48
+ border: { fg: 'cyan' },
49
+ label: { fg: 'cyan', bold: true },
50
+ },
51
+ padding: { top: 0, left: 1, right: 1, bottom: 0 },
52
+ });
53
+
54
+ screen.append(box as unknown as import('blessed').BlessedElement);
55
+
56
+ function renderMetrics(metrics: CpuMetrics): void {
57
+ const innerWidth = (typeof width === 'string' ? box.width : width as number) - 4;
58
+ const barWidth = Math.max(10, innerWidth - 14); // reserve space for label + percent
59
+
60
+ function makeLine(label: string, usage: number): string {
61
+ const filled = Math.round((Math.min(100, usage) / 100) * barWidth);
62
+ const empty = barWidth - filled;
63
+ const barColor = usage >= 85 ? '{red-fg}' : usage >= 60 ? '{yellow-fg}' : '{green-fg}';
64
+ const bar = `${barColor}${'█'.repeat(filled)}{/}${'░'.repeat(empty)}`;
65
+ const pct = colorPercent(usage);
66
+ return ` ${label.padEnd(8)} ${bar} ${pct}`;
67
+ }
68
+
69
+ const lines: string[] = [
70
+ makeLine('Overall', metrics.usage),
71
+ '',
72
+ ...metrics.cores.slice(0, 8).map(c => makeLine(`Core ${c.index}`, c.usage)),
73
+ ];
74
+
75
+ if (metrics.cores.length > 8) {
76
+ lines.push(` {gray-fg} ... +${metrics.cores.length - 8} cores{/gray-fg}`);
77
+ }
78
+
79
+ lines.push('');
80
+ lines.push(` {gray-fg}Model: ${metrics.model}{/gray-fg}`);
81
+
82
+ box.setContent(lines.join('\n'));
83
+ screen.render();
84
+ }
85
+
86
+ // Paint immediately if state is already populated (re-mount case)
87
+ const initial = getCpuMetrics();
88
+ if (initial) renderMetrics(initial);
89
+
90
+ const unsub = bus.on('metrics:cpu:updated', renderMetrics);
91
+
92
+ function destroy(): void {
93
+ unsub();
94
+ box.destroy();
95
+ }
96
+
97
+ return { box: box as unknown as Box, destroy };
98
+ }