metwatch 0.1.0 → 0.2.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.
package/src/ui/App.tsx ADDED
@@ -0,0 +1,165 @@
1
+ // ---------------------------------------------------------------------------
2
+ // App — Top-level ink/React component
3
+ //
4
+ // Owns:
5
+ // - Panel visibility state (collapsible layout)
6
+ // - Global keyboard bindings (q, ?, d, n, R, p, l)
7
+ // - Help overlay
8
+ // - Focus management (process table vs logs panel)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import React, { useState, useCallback } from 'react';
12
+ import { Box, Text, useInput, useApp } from 'ink';
13
+ import type { ResolvedConfig } from '../types/config.types.ts';
14
+ import type { LauncherHandle } from '../core/launcher.ts';
15
+ import type { LogManagerHandle } from '../core/log-manager.ts';
16
+ import { CpuPanel } from './widgets/cpu.widget.tsx';
17
+ import { MemoryPanel } from './widgets/memory.widget.tsx';
18
+ import { DiskPanel } from './widgets/disk.widget.tsx';
19
+ import { NetworkPanel } from './widgets/network.widget.tsx';
20
+ import { RuntimePanel } from './widgets/runtime.widget.tsx';
21
+ import { ProcessTablePanel } from './widgets/process-table.widget.tsx';
22
+ import { LogsPanel } from './widgets/logs.widget.tsx';
23
+
24
+ interface AppProps {
25
+ config: ResolvedConfig;
26
+ launcher: LauncherHandle | null;
27
+ logManager: LogManagerHandle | null;
28
+ onQuit: () => void;
29
+ }
30
+
31
+ type FocusTarget = 'processes' | 'logs';
32
+
33
+ const HELP_CONTENT = `
34
+ Navigation
35
+ ↑ / k Move process selection up
36
+ ↓ / j Move process selection down
37
+
38
+ View
39
+ a Process table: All mode
40
+ f Process table: Watched mode
41
+ c / m Sort by CPU / Memory
42
+
43
+ Panel Toggles
44
+ d Toggle Disk panel
45
+ n Toggle Network panel
46
+ R Toggle Runtime panel
47
+ p Toggle Process panel
48
+
49
+ Actions
50
+ K Kill selected process
51
+ r Restart managed process
52
+ s Stop managed process
53
+ l Focus log panel
54
+ Escape Unfocus log panel
55
+ q / Ctrl+C Quit
56
+ ? Close this help
57
+ `.trim();
58
+
59
+ export function App({ config, launcher, logManager, onQuit }: AppProps): React.ReactElement {
60
+ const { exit } = useApp();
61
+ const panels = config.panels;
62
+
63
+ const [visible, setVisible] = useState({
64
+ disk: panels.disk !== false,
65
+ network: panels.network !== false,
66
+ runtime: panels.runtime !== false,
67
+ processes: panels.processes !== false,
68
+ logs: panels.logs !== false,
69
+ });
70
+
71
+ const [showHelp, setShowHelp] = useState(false);
72
+ const [focused, setFocused] = useState<FocusTarget>('processes');
73
+
74
+ const toggle = useCallback((key: keyof typeof visible): void => {
75
+ setVisible(v => ({ ...v, [key]: !v[key] }));
76
+ }, []);
77
+
78
+ const quit = useCallback((): void => {
79
+ onQuit();
80
+ exit();
81
+ }, [onQuit, exit]);
82
+
83
+ useInput((input, key) => {
84
+ if (key.ctrl && input === 'c') { quit(); return; }
85
+ if (input === 'q') { quit(); return; }
86
+ if (input === '?') { setShowHelp(s => !s); return; }
87
+ if (input === 'd') { toggle('disk'); return; }
88
+ if (input === 'n') { toggle('network'); return; }
89
+ if (input === 'R') { toggle('runtime'); return; }
90
+ if (input === 'p') { toggle('processes'); return; }
91
+ if (input === 'l') { setFocused('logs'); return; }
92
+ if (key.escape && focused === 'logs') { setFocused('processes'); return; }
93
+ });
94
+
95
+ const managedNames = new Set<string>(launcher ? launcher.getAll().map(p => p.name) : []);
96
+ const getManagedById = (id: string) => launcher?.get(id);
97
+
98
+ const nullLogManager: LogManagerHandle = {
99
+ getLines: () => [],
100
+ getAllLines: () => [],
101
+ clearLines: () => undefined,
102
+ destroy: () => undefined,
103
+ };
104
+
105
+ return (
106
+ <Box flexDirection="column" width="100%" height="100%">
107
+ {/* Row A: CPU + Memory + (Disk) — fixed height ~20% */}
108
+ <Box flexDirection="row" height="20%" flexShrink={0}>
109
+ <CpuPanel />
110
+ <MemoryPanel />
111
+ {visible.disk && <DiskPanel />}
112
+ </Box>
113
+
114
+ {/* Row B: Network + Runtime — ~24% if either visible */}
115
+ {(visible.network || visible.runtime) && (
116
+ <Box flexDirection="row" height="24%" flexShrink={0}>
117
+ {visible.network && <NetworkPanel />}
118
+ {visible.runtime && <RuntimePanel />}
119
+ </Box>
120
+ )}
121
+
122
+ {/* Row C + D: Processes + Logs — share remaining space */}
123
+ <Box flexDirection="column" flexGrow={1}>
124
+ {visible.processes && (
125
+ <Box height={visible.logs ? "55%" : "100%"}>
126
+ <ProcessTablePanel
127
+ config={config}
128
+ getManagedById={getManagedById}
129
+ managedNames={managedNames}
130
+ isFocused={focused === 'processes'}
131
+ />
132
+ </Box>
133
+ )}
134
+ {visible.logs && (
135
+ <Box height={visible.processes ? "45%" : "100%"}>
136
+ <LogsPanel
137
+ logManager={logManager ?? nullLogManager}
138
+ processCount={managedNames.size}
139
+ isFocused={focused === 'logs'}
140
+ />
141
+ </Box>
142
+ )}
143
+ </Box>
144
+
145
+ {/* Help overlay */}
146
+ {showHelp && (
147
+ <Box
148
+ position="absolute"
149
+ borderStyle="single"
150
+ borderColor="white"
151
+ flexDirection="column"
152
+ paddingX={2}
153
+ paddingY={1}
154
+ width={54}
155
+ >
156
+ <Text bold> MetWatch — Keybindings </Text>
157
+ <Text> </Text>
158
+ {HELP_CONTENT.split('\n').map((line, i) => (
159
+ <Text key={i}>{line}</Text>
160
+ ))}
161
+ </Box>
162
+ )}
163
+ </Box>
164
+ );
165
+ }
package/src/ui/screen.ts CHANGED
@@ -1,45 +1,16 @@
1
1
  // ---------------------------------------------------------------------------
2
- // Screen
2
+ // Screen shim — ink/React replacement
3
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
4
+ // In the blessed era this module owned the screen singleton and a render
5
+ // coalescer. With ink, React drives re-renders automatically; this module
6
+ // is kept as a no-op shim so any residual imports compile without changes.
13
7
  // ---------------------------------------------------------------------------
14
8
 
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
- }
9
+ /** No-op in ink mode — React re-renders on state change. */
10
+ export function scheduleRender(): void { /* no-op */ }
34
11
 
35
- export function getScreen(): BlessedScreen {
36
- if (!_screen) throw new Error('Screen not initialized. Call createScreen() first.');
37
- return _screen;
38
- }
12
+ /** No-op — ink's render() in index.ts owns the terminal. */
13
+ export function createScreen(): void { /* no-op */ }
39
14
 
40
- export function destroyScreen(): void {
41
- if (_screen) {
42
- _screen.destroy();
43
- _screen = null;
44
- }
45
- }
15
+ /** No-op — ink's unmount() in index.ts owns teardown. */
16
+ export function destroyScreen(): void { /* no-op */ }
@@ -0,0 +1,71 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CPU Widget — ink/React
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import React, { useState, useEffect } from 'react';
6
+ import { Box, Text, useStdout } from 'ink';
7
+ import { bus } from '../../core/event-bus.ts';
8
+ import { getCpuMetrics } from '../../core/state-manager.ts';
9
+ import type { CpuMetrics } from '../../types/metrics.types.ts';
10
+ import { formatPercent, colorPercent } from '../../utils/formatters.ts';
11
+
12
+ interface BarRowProps {
13
+ label: string;
14
+ usage: number;
15
+ barWidth: number;
16
+ }
17
+
18
+ function BarRow({ label, usage, barWidth }: BarRowProps): React.ReactElement {
19
+ const filled = Math.round((Math.min(100, Math.max(0, usage)) / 100) * barWidth);
20
+ const empty = barWidth - filled;
21
+ const color = colorPercent(usage);
22
+ return (
23
+ <Box flexDirection="row">
24
+ <Text> {label.padEnd(8)} </Text>
25
+ <Text color={color}>{'█'.repeat(filled)}</Text><Text color="gray">{'░'.repeat(empty)}</Text>
26
+ <Text> </Text>
27
+ <Text color={color}>{formatPercent(usage)}</Text>
28
+ </Box>
29
+ );
30
+ }
31
+
32
+ export function CpuPanel(): React.ReactElement {
33
+ const { stdout } = useStdout();
34
+ const [metrics, setMetrics] = useState<CpuMetrics | null>(getCpuMetrics);
35
+
36
+ useEffect(() => {
37
+ const unsub = bus.on('metrics:cpu:updated', setMetrics);
38
+ return unsub;
39
+ }, []);
40
+
41
+ // ~34% of terminal width minus borders/label/padding
42
+ const barWidth = Math.max(10, Math.floor((stdout.columns ?? 80) * 0.34) - 22);
43
+
44
+ return (
45
+ <Box
46
+ borderStyle="single"
47
+ borderColor="cyan"
48
+ flexDirection="column"
49
+ flexGrow={1}
50
+ paddingX={1}
51
+ >
52
+ <Text color="cyan" bold> CPU </Text>
53
+ {metrics === null ? (
54
+ <Text color="gray"> Collecting…</Text>
55
+ ) : (
56
+ <>
57
+ <BarRow label="Overall" usage={metrics.usage} barWidth={barWidth} />
58
+ <Text> </Text>
59
+ {metrics.cores.slice(0, 8).map(core => (
60
+ <BarRow key={core.index} label={`Core ${core.index}`} usage={core.usage} barWidth={barWidth} />
61
+ ))}
62
+ {metrics.cores.length > 8 && (
63
+ <Text color="gray"> ... +{metrics.cores.length - 8} cores</Text>
64
+ )}
65
+ <Text> </Text>
66
+ <Text color="gray"> Model: {metrics.model}</Text>
67
+ </>
68
+ )}
69
+ </Box>
70
+ );
71
+ }
@@ -0,0 +1,85 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Disk Widget — ink/React
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import React, { useState, useEffect } from 'react';
6
+ import { Box, Text } from 'ink';
7
+ import { bus } from '../../core/event-bus.ts';
8
+ import { getDiskMetrics } from '../../core/state-manager.ts';
9
+ import type { DiskMetrics, DiskMount } from '../../types/metrics.types.ts';
10
+ import { formatBytes, colorPercent, truncate } from '../../utils/formatters.ts';
11
+
12
+ function formatRate(bps: number): string {
13
+ if (bps < 1024) return `${bps.toFixed(0)} B/s`;
14
+ if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
15
+ return `${(bps / (1024 * 1024)).toFixed(1)} MB/s`;
16
+ }
17
+
18
+ function MountRow({ m }: { m: DiskMount }): React.ReactElement {
19
+ const barWidth = 10;
20
+ const filled = Math.round((Math.min(100, m.percent) / 100) * barWidth);
21
+ const empty = barWidth - filled;
22
+ const barColor = m.percent >= 90 ? 'red' : m.percent >= 75 ? 'yellow' : 'green';
23
+ const pctColor = colorPercent(m.percent);
24
+
25
+ return (
26
+ <Box>
27
+ <Text> [</Text>
28
+ <Text color={barColor}>{'█'.repeat(filled)}</Text>
29
+ <Text color="gray">{'░'.repeat(empty)}</Text>
30
+ <Text>] </Text>
31
+ <Text>{truncate(m.fs, 14)} </Text>
32
+ <Text color="gray">{truncate(m.mount, 12)} </Text>
33
+ <Text color="gray">{truncate(m.type, 6)} </Text>
34
+ <Text>{formatBytes(m.used, 0)}/{formatBytes(m.total, 0)} </Text>
35
+ <Text color={pctColor}>{m.percent.toFixed(1)}%</Text>
36
+ </Box>
37
+ );
38
+ }
39
+
40
+ export function DiskPanel(): React.ReactElement {
41
+ const [metrics, setMetrics] = useState<DiskMetrics | null>(getDiskMetrics);
42
+
43
+ useEffect(() => {
44
+ const unsub = bus.on('metrics:disk:updated', setMetrics);
45
+ return unsub;
46
+ }, []);
47
+
48
+ return (
49
+ <Box
50
+ borderStyle="single"
51
+ borderColor="blue"
52
+ flexDirection="column"
53
+ flexGrow={1}
54
+ paddingX={1}
55
+ overflow="hidden"
56
+ >
57
+ <Text color="blue" bold> Disk [d=toggle] </Text>
58
+ {metrics === null ? (
59
+ <Text color="gray"> Collecting disk metrics…</Text>
60
+ ) : (
61
+ <>
62
+ <Box>
63
+ <Text bold> IO: </Text>
64
+ <Text color="green">↓ {formatRate(metrics.totalReadBytesPerSec).padEnd(12)}</Text>
65
+ <Text> </Text>
66
+ <Text color="red">↑ {formatRate(metrics.totalWriteBytesPerSec)}</Text>
67
+ </Box>
68
+ <Text> </Text>
69
+ {metrics.mounts.length === 0 ? (
70
+ <Text color="gray"> No mounted filesystems detected.</Text>
71
+ ) : (
72
+ <>
73
+ <Box>
74
+ <Text color="cyan" bold> DEVICE MOUNT TYPE USED / TOTAL USE%</Text>
75
+ </Box>
76
+ {metrics.mounts.map((m, i) => (
77
+ <MountRow key={i} m={m} />
78
+ ))}
79
+ </>
80
+ )}
81
+ </>
82
+ )}
83
+ </Box>
84
+ );
85
+ }
@@ -0,0 +1,126 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Logs Widget — ink/React
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import React, { useState, useEffect, useRef } from 'react';
6
+ import { Box, Text, useStdout } from 'ink';
7
+ import { bus } from '../../core/event-bus.ts';
8
+ import type { LogManagerHandle } from '../../core/log-manager.ts';
9
+
10
+ const MAX_LINES = 500;
11
+
12
+ interface LogsProps {
13
+ logManager: LogManagerHandle;
14
+ processCount: number;
15
+ isFocused: boolean;
16
+ }
17
+
18
+ function fmtTime(ts: number): string {
19
+ const totalSec = Math.floor(ts / 1000);
20
+ const s = totalSec % 60;
21
+ const totalMin = Math.floor(totalSec / 60);
22
+ const m = totalMin % 60;
23
+ const h = Math.floor(totalMin / 60) % 24;
24
+ return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
25
+ }
26
+
27
+ interface LogLine {
28
+ id: string;
29
+ stream: 'stdout' | 'stderr';
30
+ text: string;
31
+ timestamp: number;
32
+ kind: 'log' | 'system';
33
+ systemColor?: 'gray' | 'red' | 'yellow';
34
+ }
35
+
36
+ export function LogsPanel({ logManager, processCount, isFocused }: LogsProps): React.ReactElement {
37
+ const { stdout } = useStdout();
38
+ const showPrefix = processCount > 1;
39
+
40
+ const [lines, setLines] = useState<LogLine[]>(() => {
41
+ const history = logManager.getAllLines();
42
+ return history.slice(-MAX_LINES).map(l => ({
43
+ id: l.id, stream: l.stream, text: l.line,
44
+ timestamp: l.timestamp, kind: 'log' as const,
45
+ }));
46
+ });
47
+
48
+ // Scroll offset: 0 = show tail (auto-scroll), positive = locked at offset from tail
49
+ const [scrollOffset, setScrollOffset] = useState(0);
50
+ const pendingLines = useRef<LogLine[]>([]);
51
+ const flushPending = useRef(false);
52
+
53
+ const pushLine = (line: LogLine): void => {
54
+ pendingLines.current.push(line);
55
+ if (!flushPending.current) {
56
+ flushPending.current = true;
57
+ setImmediate(() => {
58
+ flushPending.current = false;
59
+ setLines(prev => {
60
+ const next = [...prev, ...pendingLines.current];
61
+ pendingLines.current = [];
62
+ return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
63
+ });
64
+ });
65
+ }
66
+ };
67
+
68
+ useEffect(() => {
69
+ const unsubLine = bus.on('log:line', ({ id, stream, line, timestamp }) => {
70
+ pushLine({ id, stream, text: line, timestamp, kind: 'log' });
71
+ });
72
+ const unsubStarted = bus.on('managed:started', ({ id, pid }) => {
73
+ pushLine({ id, stream: 'stdout', text: `── ${id} started (pid ${pid}) ──`, timestamp: Date.now(), kind: 'system', systemColor: 'gray' });
74
+ });
75
+ const unsubCrashed = bus.on('managed:crashed', ({ id, exitCode, restarts }) => {
76
+ pushLine({ id, stream: 'stderr', text: `── ${id} crashed (exit ${exitCode ?? '?'}) — restart #${restarts} scheduled ──`, timestamp: Date.now(), kind: 'system', systemColor: 'red' });
77
+ });
78
+ const unsubRestarted = bus.on('managed:restarted', ({ id, pid, restarts }) => {
79
+ pushLine({ id, stream: 'stdout', text: `── ${id} restarted (pid ${pid}, #${restarts}) ──`, timestamp: Date.now(), kind: 'system', systemColor: 'yellow' });
80
+ });
81
+ const unsubStopped = bus.on('managed:stopped', ({ id }) => {
82
+ pushLine({ id, stream: 'stdout', text: `── ${id} stopped ──`, timestamp: Date.now(), kind: 'system', systemColor: 'gray' });
83
+ });
84
+
85
+ return () => {
86
+ unsubLine(); unsubStarted(); unsubCrashed(); unsubRestarted(); unsubStopped();
87
+ };
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
89
+ }, []);
90
+
91
+ // How many rows are available for log lines (rough estimate)
92
+ const visibleRows = Math.max(4, Math.floor((stdout.rows ?? 24) * 0.26) - 4);
93
+
94
+ // Determine what slice to show
95
+ const total = lines.length;
96
+ const tailStart = Math.max(0, total - visibleRows - scrollOffset);
97
+ const visible = lines.slice(tailStart, tailStart + visibleRows);
98
+
99
+ return (
100
+ <Box
101
+ borderStyle="single"
102
+ borderColor="green"
103
+ flexDirection="column"
104
+ flexGrow={1}
105
+ overflow="hidden"
106
+ >
107
+ <Text color="green" bold> Logs [↑↓=scroll when focused]{isFocused ? ' — FOCUSED' : ''} </Text>
108
+ {lines.length === 0 ? (
109
+ <Text color="gray"> Waiting for output from managed processes...</Text>
110
+ ) : (
111
+ visible.map((line, i) => {
112
+ if (line.kind === 'system') {
113
+ return <Text key={i} color={line.systemColor ?? 'gray'}>{fmtTime(line.timestamp)} {line.text}</Text>;
114
+ }
115
+ return (
116
+ <Box key={i}>
117
+ <Text color="gray">{fmtTime(line.timestamp)} </Text>
118
+ {showPrefix && <Text color="cyan">[{line.id}] </Text>}
119
+ <Text color={line.stream === 'stderr' ? 'red' : undefined}>{line.text}</Text>
120
+ </Box>
121
+ );
122
+ })
123
+ )}
124
+ </Box>
125
+ );
126
+ }
@@ -0,0 +1,60 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Memory Widget — ink/React
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import React, { useState, useEffect } from 'react';
6
+ import { Box, Text, useStdout } from 'ink';
7
+ import { bus } from '../../core/event-bus.ts';
8
+ import { getMemoryMetrics } from '../../core/state-manager.ts';
9
+ import type { MemoryMetrics } from '../../types/metrics.types.ts';
10
+ import { formatBytes, formatPercent, colorPercent } from '../../utils/formatters.ts';
11
+
12
+ export function MemoryPanel(): React.ReactElement {
13
+ const { stdout } = useStdout();
14
+ const [metrics, setMetrics] = useState<MemoryMetrics | null>(getMemoryMetrics);
15
+
16
+ useEffect(() => {
17
+ const unsub = bus.on('metrics:memory:updated', setMetrics);
18
+ return unsub;
19
+ }, []);
20
+
21
+ const barWidth = Math.max(10, Math.floor((stdout.columns ?? 80) * 0.33) - 22);
22
+
23
+ const renderBar = (pct: number): React.ReactElement => {
24
+ const filled = Math.round((Math.min(100, Math.max(0, pct)) / 100) * barWidth);
25
+ const empty = barWidth - filled;
26
+ const color = colorPercent(pct);
27
+ return (
28
+ <Box flexDirection="row">
29
+ <Text> {'RAM'.padEnd(8)} </Text>
30
+ <Text color={color}>{'█'.repeat(filled)}</Text><Text color="gray">{'░'.repeat(empty)}</Text>
31
+ <Text> </Text>
32
+ <Text color={color}>{formatPercent(pct)}</Text>
33
+ </Box>
34
+ );
35
+ };
36
+
37
+ return (
38
+ <Box
39
+ borderStyle="single"
40
+ borderColor="magenta"
41
+ flexDirection="column"
42
+ flexGrow={1}
43
+ paddingX={1}
44
+ >
45
+ <Text color="magenta" bold> Memory </Text>
46
+ {metrics === null ? (
47
+ <Text color="gray"> Collecting…</Text>
48
+ ) : (
49
+ <>
50
+ {renderBar(metrics.percent)}
51
+ <Text> </Text>
52
+ <Text> <Text bold>Used </Text> : {formatBytes(metrics.used)}</Text>
53
+ <Text> <Text bold>Free </Text> : {formatBytes(metrics.free)}</Text>
54
+ <Text> <Text bold>Cached</Text> : {formatBytes(metrics.cached)}</Text>
55
+ <Text> <Text bold>Total </Text> : {formatBytes(metrics.total)}</Text>
56
+ </>
57
+ )}
58
+ </Box>
59
+ );
60
+ }