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,280 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Launcher
3
+ //
4
+ // Owns the lifecycle of every managed process: spawn, restart, stop, and
5
+ // exponential back-off on crash. This is the ONLY place in MetWatch that
6
+ // calls child_process.spawn().
7
+ //
8
+ // Back-off schedule (resets if process lives > STABLE_UPTIME_MS):
9
+ // attempt 1 → 1s
10
+ // attempt 2 → 2s
11
+ // attempt 3 → 4s
12
+ // attempt 4 → 8s
13
+ // attempt 5 → 16s
14
+ // attempt 6+ → 30s (cap)
15
+ //
16
+ // Events emitted on bus:
17
+ // managed:started — process came up
18
+ // managed:stopped — process was intentionally stopped
19
+ // managed:crashed — process exited unexpectedly
20
+ // managed:restarted — process was restarted (crash or manual)
21
+ // log:line — stdout / stderr line from the child
22
+ // log:stream:started — child stdin/stdout attached
23
+ // log:stream:stopped — child exited, streams closed
24
+ // ---------------------------------------------------------------------------
25
+
26
+ import { spawn } from 'child_process';
27
+ import type { ChildProcess } from 'child_process';
28
+ import { bus } from './event-bus.ts';
29
+ import type { ManagedProcess, ManagedProcessDef } from '../types/managed-process.types.ts';
30
+
31
+ const BACKOFF_STEPS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
32
+ const STABLE_UPTIME_MS = 10_000; // reset back-off counter after this many ms uptime
33
+ const MAX_BACKOFF_MS = 30_000;
34
+
35
+ // ── Internal per-process state ─────────────────────────────────────────────
36
+
37
+ interface ProcessEntry {
38
+ state: ManagedProcess;
39
+ child: ChildProcess | null;
40
+ backoffTimer: ReturnType<typeof setTimeout> | null;
41
+ /** Whether stop() was explicitly called (suppresses auto-restart) */
42
+ intentional: boolean;
43
+ /** Timestamp the current run started (for stability check) */
44
+ runStart: number | null;
45
+ }
46
+
47
+ // ── Launcher handle returned to callers ───────────────────────────────────
48
+
49
+ export interface LauncherHandle {
50
+ /** Start all defined processes. Called once by bootstrap. */
51
+ startAll: () => void;
52
+ /** Restart a process by id. */
53
+ restart: (id: string) => void;
54
+ /** Gracefully stop a process by id (no auto-restart). */
55
+ stop: (id: string) => void;
56
+ /** Stop all managed processes. Called on TUI quit. */
57
+ stopAll: () => void;
58
+ /** Get a snapshot of all managed process states. */
59
+ getAll: () => ManagedProcess[];
60
+ /** Get a single managed process state by id. */
61
+ get: (id: string) => ManagedProcess | undefined;
62
+ }
63
+
64
+ // ── Factory ────────────────────────────────────────────────────────────────
65
+
66
+ export function createLauncher(defs: ManagedProcessDef[]): LauncherHandle {
67
+ const entries = new Map<string, ProcessEntry>();
68
+
69
+ // Build initial entries from defs
70
+ for (const def of defs) {
71
+ entries.set(def.name, {
72
+ state: {
73
+ ...def,
74
+ pid: null,
75
+ status: 'stopped',
76
+ restarts: 0,
77
+ startedAt: null,
78
+ exitCode: null,
79
+ },
80
+ child: null,
81
+ backoffTimer: null,
82
+ intentional: false,
83
+ runStart: null,
84
+ });
85
+ }
86
+
87
+ // Subscribe to bus requests so the TUI can trigger restart/stop
88
+ const unsubRestart = bus.on('managed:restart:requested', ({ id }) => restart(id));
89
+ const unsubStop = bus.on('managed:stop:requested', ({ id }) => stop(id));
90
+
91
+ // ── Spawn ──────────────────────────────────────────────────────────────
92
+
93
+ function spawnProcess(entry: ProcessEntry): void {
94
+ const { state } = entry;
95
+
96
+ const child = spawn(state.command, state.args, {
97
+ cwd: state.cwd ?? process.cwd(),
98
+ env: { ...process.env, ...(state.env ?? {}) },
99
+ stdio: ['ignore', 'pipe', 'pipe'],
100
+ });
101
+
102
+ entry.child = child;
103
+ entry.runStart = Date.now();
104
+ entry.intentional = false;
105
+
106
+ state.pid = child.pid ?? null;
107
+ state.status = 'running';
108
+ state.startedAt = entry.runStart;
109
+ state.exitCode = null;
110
+
111
+ bus.emit('managed:started', { id: state.name, pid: state.pid ?? 0 });
112
+ bus.emit('log:stream:started', { id: state.name, pid: state.pid ?? 0 });
113
+
114
+ // ── Stream stdout ──────────────────────────────────────────────────
115
+
116
+ child.stdout?.setEncoding('utf8');
117
+ child.stdout?.on('data', (chunk: string) => {
118
+ for (const line of chunk.split('\n')) {
119
+ if (line.trim()) {
120
+ bus.emit('log:line', {
121
+ id: state.name,
122
+ stream: 'stdout',
123
+ line,
124
+ timestamp: Date.now(),
125
+ });
126
+ }
127
+ }
128
+ });
129
+
130
+ // ── Stream stderr ──────────────────────────────────────────────────
131
+
132
+ child.stderr?.setEncoding('utf8');
133
+ child.stderr?.on('data', (chunk: string) => {
134
+ for (const line of chunk.split('\n')) {
135
+ if (line.trim()) {
136
+ bus.emit('log:line', {
137
+ id: state.name,
138
+ stream: 'stderr',
139
+ line,
140
+ timestamp: Date.now(),
141
+ });
142
+ }
143
+ }
144
+ });
145
+
146
+ // ── Exit handler ───────────────────────────────────────────────────
147
+
148
+ child.on('exit', (code, signal) => {
149
+ const exitCode = code ?? (signal ? 1 : 0);
150
+ state.exitCode = exitCode;
151
+ entry.child = null;
152
+ entry.runStart = null;
153
+
154
+ bus.emit('log:stream:stopped', { id: state.name, exitCode });
155
+
156
+ if (entry.intentional) {
157
+ state.status = 'stopped';
158
+ state.pid = null;
159
+ bus.emit('managed:stopped', { id: state.name });
160
+ return;
161
+ }
162
+
163
+ // Unexpected exit — treat as crash
164
+ state.status = 'crashed';
165
+ state.pid = null;
166
+ state.restarts++;
167
+ bus.emit('managed:crashed', {
168
+ id: state.name,
169
+ exitCode,
170
+ restarts: state.restarts,
171
+ });
172
+
173
+ if (state.autoRestart) {
174
+ scheduleRestart(entry);
175
+ }
176
+ });
177
+ }
178
+
179
+ // ── Back-off restart scheduler ─────────────────────────────────────────
180
+
181
+ function scheduleRestart(entry: ProcessEntry): void {
182
+ const { state } = entry;
183
+
184
+ // Check if previous run was stable → reset back-off
185
+ const uptime = entry.runStart ? Date.now() - entry.runStart : 0;
186
+ if (uptime >= STABLE_UPTIME_MS) {
187
+ state.restarts = 1; // keep count but reset delay
188
+ }
189
+
190
+ const stepIndex = Math.min(state.restarts - 1, BACKOFF_STEPS_MS.length - 1);
191
+ const delayMs = Math.min(BACKOFF_STEPS_MS[stepIndex] ?? MAX_BACKOFF_MS, MAX_BACKOFF_MS);
192
+
193
+ state.status = 'restarting';
194
+
195
+ entry.backoffTimer = setTimeout(() => {
196
+ entry.backoffTimer = null;
197
+ spawnProcess(entry);
198
+ bus.emit('managed:restarted', {
199
+ id: state.name,
200
+ pid: state.pid ?? 0,
201
+ restarts: state.restarts,
202
+ });
203
+ }, delayMs);
204
+ }
205
+
206
+ // ── Public API ─────────────────────────────────────────────────────────
207
+
208
+ function startAll(): void {
209
+ for (const entry of entries.values()) {
210
+ spawnProcess(entry);
211
+ }
212
+ }
213
+
214
+ function restart(id: string): void {
215
+ const entry = entries.get(id);
216
+ if (!entry) return;
217
+
218
+ // Cancel any pending back-off timer
219
+ if (entry.backoffTimer) {
220
+ clearTimeout(entry.backoffTimer);
221
+ entry.backoffTimer = null;
222
+ }
223
+
224
+ // Kill current child if running
225
+ if (entry.child) {
226
+ entry.intentional = true; // prevent crash handler from re-triggering
227
+ entry.child.kill('SIGTERM');
228
+ }
229
+
230
+ // Brief settle then re-spawn
231
+ setTimeout(() => {
232
+ entry.state.restarts++;
233
+ spawnProcess(entry);
234
+ bus.emit('managed:restarted', {
235
+ id,
236
+ pid: entry.state.pid ?? 0,
237
+ restarts: entry.state.restarts,
238
+ });
239
+ }, 300);
240
+ }
241
+
242
+ function stop(id: string): void {
243
+ const entry = entries.get(id);
244
+ if (!entry) return;
245
+
246
+ // Cancel any pending back-off restart
247
+ if (entry.backoffTimer) {
248
+ clearTimeout(entry.backoffTimer);
249
+ entry.backoffTimer = null;
250
+ }
251
+
252
+ if (entry.child) {
253
+ entry.intentional = true;
254
+ entry.child.kill('SIGTERM');
255
+ } else {
256
+ // Already dead — just mark stopped
257
+ entry.state.status = 'stopped';
258
+ bus.emit('managed:stopped', { id });
259
+ }
260
+ }
261
+
262
+ function stopAll(): void {
263
+ unsubRestart();
264
+ unsubStop();
265
+ for (const [id] of entries) {
266
+ stop(id);
267
+ }
268
+ }
269
+
270
+ function getAll(): ManagedProcess[] {
271
+ return [...entries.values()].map(e => ({ ...e.state }));
272
+ }
273
+
274
+ function get(id: string): ManagedProcess | undefined {
275
+ const entry = entries.get(id);
276
+ return entry ? { ...entry.state } : undefined;
277
+ }
278
+
279
+ return { startAll, restart, stop, stopAll, getAll, get };
280
+ }
@@ -0,0 +1,116 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Log Manager
3
+ //
4
+ // Maintains a per-process circular buffer of log lines captured from
5
+ // managed process stdout/stderr. Widgets read from this buffer on mount
6
+ // (so they show history immediately) and then subscribe to 'log:line'
7
+ // events for live updates.
8
+ //
9
+ // Design:
10
+ // - One circular buffer (fixed-size array + head pointer) per process ID
11
+ // - Lines are never sorted — insertion order is display order
12
+ // - Scrollback limit comes from ResolvedConfig.logScrollback
13
+ // - No UI awareness: log-manager only stores; widgets only render
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import { bus } from './event-bus.ts';
17
+
18
+ interface LogLine {
19
+ id: string;
20
+ stream: 'stdout' | 'stderr';
21
+ line: string;
22
+ timestamp: number;
23
+ }
24
+
25
+ // ── Circular buffer ────────────────────────────────────────────────────────
26
+
27
+ class CircularBuffer<T> {
28
+ private readonly buf: (T | undefined)[];
29
+ private head = 0;
30
+ private size = 0;
31
+
32
+ constructor(private readonly capacity: number) {
33
+ this.buf = new Array<T | undefined>(capacity).fill(undefined);
34
+ }
35
+
36
+ push(item: T): void {
37
+ this.buf[this.head] = item;
38
+ this.head = (this.head + 1) % this.capacity;
39
+ if (this.size < this.capacity) this.size++;
40
+ }
41
+
42
+ toArray(): T[] {
43
+ if (this.size === 0) return [];
44
+ if (this.size < this.capacity) {
45
+ return this.buf.slice(0, this.size) as T[];
46
+ }
47
+ // Buffer is full — head points to oldest entry
48
+ return [
49
+ ...this.buf.slice(this.head),
50
+ ...this.buf.slice(0, this.head),
51
+ ] as T[];
52
+ }
53
+
54
+ clear(): void {
55
+ this.buf.fill(undefined);
56
+ this.head = 0;
57
+ this.size = 0;
58
+ }
59
+
60
+ get length(): number {
61
+ return this.size;
62
+ }
63
+ }
64
+
65
+ // ── Log Manager ────────────────────────────────────────────────────────────
66
+
67
+ export interface LogManagerHandle {
68
+ /** Get all buffered lines for a process id. */
69
+ getLines: (id: string) => LogLine[];
70
+ /** Get all lines across all processes, sorted by timestamp. */
71
+ getAllLines: () => LogLine[];
72
+ /** Clear the buffer for a specific process. */
73
+ clearLines: (id: string) => void;
74
+ /** Stop listening to bus events. Call on TUI destroy. */
75
+ destroy: () => void;
76
+ }
77
+
78
+ export function createLogManager(scrollback: number): LogManagerHandle {
79
+ const buffers = new Map<string, CircularBuffer<LogLine>>();
80
+
81
+ function getOrCreate(id: string): CircularBuffer<LogLine> {
82
+ let buf = buffers.get(id);
83
+ if (!buf) {
84
+ buf = new CircularBuffer<LogLine>(scrollback);
85
+ buffers.set(id, buf);
86
+ }
87
+ return buf;
88
+ }
89
+
90
+ const unsub = bus.on('log:line', (payload) => {
91
+ getOrCreate(payload.id).push(payload);
92
+ });
93
+
94
+ function getLines(id: string): LogLine[] {
95
+ return buffers.get(id)?.toArray() ?? [];
96
+ }
97
+
98
+ function getAllLines(): LogLine[] {
99
+ const all: LogLine[] = [];
100
+ for (const buf of buffers.values()) {
101
+ all.push(...buf.toArray());
102
+ }
103
+ return all.sort((a, b) => a.timestamp - b.timestamp);
104
+ }
105
+
106
+ function clearLines(id: string): void {
107
+ buffers.get(id)?.clear();
108
+ }
109
+
110
+ function destroy(): void {
111
+ unsub();
112
+ buffers.clear();
113
+ }
114
+
115
+ return { getLines, getAllLines, clearLines, destroy };
116
+ }
@@ -0,0 +1,115 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Metrics Manager
3
+ //
4
+ // Orchestrates the CPU, memory, disk, and network polling loop.
5
+ // Responsibilities:
6
+ // 1. Call system services at the configured interval
7
+ // 2. Write results into state manager
8
+ // 3. Emit typed events on the bus
9
+ // 4. Handle errors without crashing (emit app:error instead)
10
+ //
11
+ // The manager knows nothing about the UI. It is a pure data pipeline.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import { bus } from './event-bus.ts';
15
+ import {
16
+ updateCpuMetrics,
17
+ updateMemoryMetrics,
18
+ updateDiskMetrics,
19
+ updateNetworkMetrics,
20
+ } from './state-manager.ts';
21
+ import { fetchCpuMetrics, fetchMemoryMetrics } from '../services/system.service.ts';
22
+ import { fetchDiskMetrics } from '../services/disk.service.ts';
23
+ import { fetchNetworkMetrics } from '../services/network.service.ts';
24
+
25
+ interface MetricsManagerOptions {
26
+ intervalMs: number;
27
+ }
28
+
29
+ interface MetricsManagerHandle {
30
+ start: () => void;
31
+ stop: () => void;
32
+ }
33
+
34
+ export function createMetricsManager(options: MetricsManagerOptions): MetricsManagerHandle {
35
+ const { intervalMs } = options;
36
+ let timer: ReturnType<typeof setInterval> | null = null;
37
+ let running = false;
38
+
39
+ async function tick(): Promise<void> {
40
+ // Run all fetches in parallel — they are independent OS calls
41
+ const [cpuResult, memResult, diskResult, netResult] = await Promise.allSettled([
42
+ fetchCpuMetrics(),
43
+ fetchMemoryMetrics(),
44
+ fetchDiskMetrics(),
45
+ fetchNetworkMetrics(),
46
+ ]);
47
+
48
+ if (cpuResult.status === 'fulfilled') {
49
+ updateCpuMetrics(cpuResult.value);
50
+ bus.emit('metrics:cpu:updated', cpuResult.value);
51
+ } else {
52
+ bus.emit('app:error', {
53
+ source: 'metrics-manager:cpu',
54
+ error: cpuResult.reason instanceof Error
55
+ ? cpuResult.reason
56
+ : new Error(String(cpuResult.reason)),
57
+ });
58
+ }
59
+
60
+ if (memResult.status === 'fulfilled') {
61
+ updateMemoryMetrics(memResult.value);
62
+ bus.emit('metrics:memory:updated', memResult.value);
63
+ } else {
64
+ bus.emit('app:error', {
65
+ source: 'metrics-manager:memory',
66
+ error: memResult.reason instanceof Error
67
+ ? memResult.reason
68
+ : new Error(String(memResult.reason)),
69
+ });
70
+ }
71
+
72
+ if (diskResult.status === 'fulfilled') {
73
+ updateDiskMetrics(diskResult.value);
74
+ bus.emit('metrics:disk:updated', diskResult.value);
75
+ } else {
76
+ bus.emit('app:error', {
77
+ source: 'metrics-manager:disk',
78
+ error: diskResult.reason instanceof Error
79
+ ? diskResult.reason
80
+ : new Error(String(diskResult.reason)),
81
+ });
82
+ }
83
+
84
+ if (netResult.status === 'fulfilled') {
85
+ updateNetworkMetrics(netResult.value);
86
+ bus.emit('metrics:network:updated', netResult.value);
87
+ } else {
88
+ bus.emit('app:error', {
89
+ source: 'metrics-manager:network',
90
+ error: netResult.reason instanceof Error
91
+ ? netResult.reason
92
+ : new Error(String(netResult.reason)),
93
+ });
94
+ }
95
+ }
96
+
97
+ function start(): void {
98
+ if (running) return;
99
+ running = true;
100
+ // Immediate first tick so the UI isn't blank on startup
101
+ void tick();
102
+ timer = setInterval(() => void tick(), intervalMs);
103
+ }
104
+
105
+ function stop(): void {
106
+ if (!running) return;
107
+ running = false;
108
+ if (timer !== null) {
109
+ clearInterval(timer);
110
+ timer = null;
111
+ }
112
+ }
113
+
114
+ return { start, stop };
115
+ }
@@ -0,0 +1,79 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Process Manager
3
+ //
4
+ // Orchestrates the process list polling loop and handles process lifecycle
5
+ // actions (kill). Follows the same pattern as metrics-manager:
6
+ // poll → normalize → write state → emit events
7
+ //
8
+ // Kill flow:
9
+ // Widget emits 'process:kill:requested' → manager catches it → sends SIGTERM
10
+ // → emits 'process:kill:result' back → widget shows feedback
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { bus } from './event-bus.ts';
14
+ import { updateProcesses } from './state-manager.ts';
15
+ import { fetchProcessList } from '../services/process.service.ts';
16
+ import type { ResolvedConfig } from '../types/config.types.ts';
17
+
18
+ interface ProcessManagerHandle {
19
+ start: () => void;
20
+ stop: () => void;
21
+ }
22
+
23
+ export function createProcessManager(config: ResolvedConfig): ProcessManagerHandle {
24
+ let timer: ReturnType<typeof setInterval> | null = null;
25
+ let running = false;
26
+
27
+ // Listen for kill requests from the UI
28
+ const unsubKill = bus.on('process:kill:requested', ({ pid }) => {
29
+ void handleKill(pid);
30
+ });
31
+
32
+ async function tick(): Promise<void> {
33
+ try {
34
+ const list = await fetchProcessList(config.maxProcesses);
35
+ updateProcesses(list);
36
+ bus.emit('processes:updated', list);
37
+ } catch (err) {
38
+ bus.emit('app:error', {
39
+ source: 'process-manager:poll',
40
+ error: err instanceof Error ? err : new Error(String(err)),
41
+ });
42
+ }
43
+ }
44
+
45
+ async function handleKill(pid: number): Promise<void> {
46
+ try {
47
+ process.kill(pid, 'SIGTERM');
48
+ // Brief delay then trigger a fresh poll so the table updates
49
+ await new Promise<void>(resolve => setTimeout(resolve, 300));
50
+ await tick();
51
+ bus.emit('process:kill:result', { pid, success: true });
52
+ } catch (err) {
53
+ bus.emit('process:kill:result', {
54
+ pid,
55
+ success: false,
56
+ error: err instanceof Error ? err.message : String(err),
57
+ });
58
+ }
59
+ }
60
+
61
+ function start(): void {
62
+ if (running) return;
63
+ running = true;
64
+ void tick();
65
+ timer = setInterval(() => void tick(), config.refreshInterval);
66
+ }
67
+
68
+ function stop(): void {
69
+ if (!running) return;
70
+ running = false;
71
+ unsubKill();
72
+ if (timer !== null) {
73
+ clearInterval(timer);
74
+ timer = null;
75
+ }
76
+ }
77
+
78
+ return { start, stop };
79
+ }