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/README.md +6 -6
- package/index.ts +63 -65
- package/package.json +5 -5
- package/src/core/process-manager.ts +10 -6
- package/src/core/runtime-manager.ts +16 -6
- package/src/services/network.service.ts +37 -23
- package/src/services/process.service.ts +41 -5
- package/src/ui/App.tsx +165 -0
- package/src/ui/screen.ts +10 -39
- package/src/ui/widgets/cpu.widget.tsx +71 -0
- package/src/ui/widgets/disk.widget.tsx +85 -0
- package/src/ui/widgets/logs.widget.tsx +126 -0
- package/src/ui/widgets/memory.widget.tsx +60 -0
- package/src/ui/widgets/network.widget.tsx +125 -0
- package/src/ui/widgets/process-table.widget.tsx +262 -0
- package/src/ui/widgets/runtime.widget.tsx +82 -0
- package/src/ui/widgets/sparkline.tsx +107 -0
- package/src/utils/formatters.ts +13 -9
- package/src/types/blessed.d.ts +0 -332
- package/src/ui/layout.ts +0 -318
- package/src/ui/widgets/cpu.widget.ts +0 -98
- package/src/ui/widgets/disk.widget.ts +0 -134
- package/src/ui/widgets/logs.widget.ts +0 -168
- package/src/ui/widgets/memory.widget.ts +0 -94
- package/src/ui/widgets/network.widget.ts +0 -185
- package/src/ui/widgets/process-table.widget.ts +0 -293
- package/src/ui/widgets/runtime.widget.ts +0 -119
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Network 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 { getNetworkMetrics } from '../../core/state-manager.ts';
|
|
9
|
+
import type { NetworkMetrics, NetworkInterface } from '../../types/metrics.types.ts';
|
|
10
|
+
import { truncate } from '../../utils/formatters.ts';
|
|
11
|
+
import { Sparkline, RingBuffer } from './sparkline.tsx';
|
|
12
|
+
|
|
13
|
+
const HISTORY_LEN = 60;
|
|
14
|
+
|
|
15
|
+
function fmtRate(bps: number): string {
|
|
16
|
+
if (bps < 1024) return `${bps.toFixed(0)} B/s`;
|
|
17
|
+
if (bps < 1_048_576) return `${(bps / 1024).toFixed(1)} KB/s`;
|
|
18
|
+
if (bps < 1_073_741_824) return `${(bps / 1_048_576).toFixed(1)} MB/s`;
|
|
19
|
+
return `${(bps / 1_073_741_824).toFixed(1)} GB/s`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function IfaceRow({ iface }: { iface: NetworkInterface }): React.ReactElement {
|
|
23
|
+
const stateColor = iface.operstate === 'up' ? 'green' : iface.operstate === 'down' ? 'red' : 'gray';
|
|
24
|
+
const stateChar = iface.operstate === 'up' ? '▲' : iface.operstate === 'down' ? '▼' : '?';
|
|
25
|
+
const errors = iface.rxErrors + iface.txErrors;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
29
|
+
<Box flexDirection="row">
|
|
30
|
+
<Text color={stateColor}>{stateChar} </Text>
|
|
31
|
+
<Text bold>{truncate(iface.iface, 12)}</Text>
|
|
32
|
+
</Box>
|
|
33
|
+
<Text> {truncate(iface.ip4 || iface.ip6 || '—', 15)}</Text>
|
|
34
|
+
<Box flexDirection="row">
|
|
35
|
+
<Text> </Text><Text color="green">↓ {fmtRate(iface.rxBytesPerSec)}</Text>
|
|
36
|
+
</Box>
|
|
37
|
+
<Box flexDirection="row">
|
|
38
|
+
<Text> </Text><Text color="red">↑ {fmtRate(iface.txBytesPerSec)}</Text>
|
|
39
|
+
</Box>
|
|
40
|
+
{errors > 0 && (
|
|
41
|
+
<Box flexDirection="row"><Text> </Text><Text color="red">err:{errors}</Text></Box>
|
|
42
|
+
)}
|
|
43
|
+
</Box>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function NetworkPanel(): React.ReactElement {
|
|
48
|
+
const { stdout } = useStdout();
|
|
49
|
+
const [metrics, setMetrics] = useState<NetworkMetrics | null>(getNetworkMetrics);
|
|
50
|
+
|
|
51
|
+
// Ring buffers persist across re-renders via refs
|
|
52
|
+
const rxHistory = useRef(new RingBuffer(HISTORY_LEN));
|
|
53
|
+
const txHistory = useRef(new RingBuffer(HISTORY_LEN));
|
|
54
|
+
|
|
55
|
+
// Arrays derived from ring buffers — updated on each metrics tick
|
|
56
|
+
const [rxSeries, setRxSeries] = useState<number[]>(() => rxHistory.current.toArray());
|
|
57
|
+
const [txSeries, setTxSeries] = useState<number[]>(() => txHistory.current.toArray());
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const unsub = bus.on('metrics:network:updated', (m) => {
|
|
61
|
+
setMetrics(m);
|
|
62
|
+
rxHistory.current.push(m.totalRxBytesPerSec);
|
|
63
|
+
txHistory.current.push(m.totalTxBytesPerSec);
|
|
64
|
+
setRxSeries(rxHistory.current.toArray());
|
|
65
|
+
setTxSeries(txHistory.current.toArray());
|
|
66
|
+
});
|
|
67
|
+
return unsub;
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
// Chart takes 72% of the network row; subtract borders, padding, Y-label space
|
|
71
|
+
const totalCols = stdout.columns ?? 80;
|
|
72
|
+
const chartCols = Math.max(20, Math.floor(totalCols * 0.55 * 0.72) - 4);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
76
|
+
{/* Left: interfaces panel */}
|
|
77
|
+
<Box
|
|
78
|
+
borderStyle="single"
|
|
79
|
+
borderColor="magenta"
|
|
80
|
+
flexDirection="column"
|
|
81
|
+
width="28%"
|
|
82
|
+
paddingX={1}
|
|
83
|
+
overflow="hidden"
|
|
84
|
+
>
|
|
85
|
+
<Text color="magenta" bold> Interfaces </Text>
|
|
86
|
+
{metrics === null ? (
|
|
87
|
+
<Text color="gray">Collecting…</Text>
|
|
88
|
+
) : (
|
|
89
|
+
<>
|
|
90
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
91
|
+
<Text bold>Total</Text>
|
|
92
|
+
<Box flexDirection="row">
|
|
93
|
+
<Text color="green">↓ {fmtRate(metrics.totalRxBytesPerSec)}</Text>
|
|
94
|
+
</Box>
|
|
95
|
+
<Box flexDirection="row">
|
|
96
|
+
<Text color="red">↑ {fmtRate(metrics.totalTxBytesPerSec)}</Text>
|
|
97
|
+
</Box>
|
|
98
|
+
</Box>
|
|
99
|
+
{metrics.interfaces.length === 0 ? (
|
|
100
|
+
<Text color="gray">No interfaces</Text>
|
|
101
|
+
) : (
|
|
102
|
+
metrics.interfaces.map((iface, i) => <IfaceRow key={i} iface={iface} />)
|
|
103
|
+
)}
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</Box>
|
|
107
|
+
|
|
108
|
+
{/* Right: sparkline throughput chart */}
|
|
109
|
+
<Box
|
|
110
|
+
borderStyle="single"
|
|
111
|
+
borderColor="magenta"
|
|
112
|
+
flexDirection="column"
|
|
113
|
+
flexGrow={1}
|
|
114
|
+
paddingX={1}
|
|
115
|
+
>
|
|
116
|
+
<Sparkline
|
|
117
|
+
rxSeries={rxSeries}
|
|
118
|
+
txSeries={txSeries}
|
|
119
|
+
width={chartCols}
|
|
120
|
+
label="Throughput [n=toggle]"
|
|
121
|
+
/>
|
|
122
|
+
</Box>
|
|
123
|
+
</Box>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Process Table Widget — ink/React
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
6
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
7
|
+
import { bus } from '../../core/event-bus.ts';
|
|
8
|
+
import { getProcesses } from '../../core/state-manager.ts';
|
|
9
|
+
import type { ProcessInfo, ProcessList, ProcessViewMode, ProcessSortKey } from '../../types/process.types.ts';
|
|
10
|
+
import type { ResolvedConfig } from '../../types/config.types.ts';
|
|
11
|
+
import type { ManagedProcess } from '../../types/managed-process.types.ts';
|
|
12
|
+
import { formatBytes, formatPercent, formatUptime, truncate } from '../../utils/formatters.ts';
|
|
13
|
+
|
|
14
|
+
interface ProcessTableProps {
|
|
15
|
+
config: ResolvedConfig;
|
|
16
|
+
getManagedById: (id: string) => ManagedProcess | undefined;
|
|
17
|
+
managedNames: Set<string>;
|
|
18
|
+
isFocused: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STATUS_BADGE: Record<string, string> = {
|
|
22
|
+
running: '● ',
|
|
23
|
+
restarting: '↻ ',
|
|
24
|
+
crashed: '✕ ',
|
|
25
|
+
stopped: '■ ',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function uptime(p: ProcessInfo): string {
|
|
29
|
+
if (!p.startedAt) return '—';
|
|
30
|
+
return formatUptime(Math.floor((Date.now() - p.startedAt) / 1000));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const COL_WIDTHS = [7, 22, 7, 10, 6, 10, 5, 11] as const;
|
|
34
|
+
const HEADERS = ['PID', 'NAME', 'CPU%', 'MEM', 'MEM%', 'USER', 'THR', 'STATUS'];
|
|
35
|
+
|
|
36
|
+
function ProcessRow({ p, managed, isManaged, selected }: {
|
|
37
|
+
p: ProcessInfo;
|
|
38
|
+
managed?: ManagedProcess;
|
|
39
|
+
isManaged: boolean;
|
|
40
|
+
selected: boolean;
|
|
41
|
+
}): React.ReactElement {
|
|
42
|
+
const badge = managed ? (STATUS_BADGE[managed.status] ?? '') : ' ';
|
|
43
|
+
const name = badge + truncate(p.name, 19);
|
|
44
|
+
const status = managed
|
|
45
|
+
? `${managed.status}${managed.restarts > 0 ? ` ×${managed.restarts}` : ''}`
|
|
46
|
+
: p.status;
|
|
47
|
+
|
|
48
|
+
const cells = [
|
|
49
|
+
String(p.pid).padEnd(COL_WIDTHS[0]),
|
|
50
|
+
name.padEnd(COL_WIDTHS[1]),
|
|
51
|
+
formatPercent(p.cpu).padEnd(COL_WIDTHS[2]),
|
|
52
|
+
formatBytes(p.memory).padEnd(COL_WIDTHS[3]),
|
|
53
|
+
formatPercent(p.memoryPercent).padEnd(COL_WIDTHS[4]),
|
|
54
|
+
truncate(p.user || '—', 10).padEnd(COL_WIDTHS[5]),
|
|
55
|
+
String(p.threads || 1).padEnd(COL_WIDTHS[4]),
|
|
56
|
+
status.padEnd(COL_WIDTHS[7]),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const line = ' ' + cells.join(' ');
|
|
60
|
+
|
|
61
|
+
let color: string | undefined;
|
|
62
|
+
if (isManaged) color = 'cyan';
|
|
63
|
+
else if (p.cpu >= 50) color = 'red';
|
|
64
|
+
else if (p.cpu >= 20) color = 'yellow';
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Text inverse={selected} color={color}>{line}</Text>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ProcessTablePanel({ config, getManagedById, managedNames, isFocused }: ProcessTableProps): React.ReactElement {
|
|
72
|
+
const [viewMode, setViewMode] = useState<ProcessViewMode>('all');
|
|
73
|
+
const [sortKey, setSortKey] = useState<ProcessSortKey>('cpu');
|
|
74
|
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
75
|
+
const [list, setList] = useState<ProcessList>([]);
|
|
76
|
+
const [flash, setFlash] = useState<string | null>(null);
|
|
77
|
+
const [killTarget, setKillTarget] = useState<ProcessInfo | null>(null);
|
|
78
|
+
|
|
79
|
+
// Patterns (rebuilt when managedNames changes)
|
|
80
|
+
const watchedPatterns = config.watchedProcesses.map(w => w.name.toLowerCase());
|
|
81
|
+
const managedPatterns = [...managedNames].map(n => n.toLowerCase());
|
|
82
|
+
|
|
83
|
+
const buildList = useCallback((raw: ProcessList, mode: ProcessViewMode, sort: ProcessSortKey): ProcessList => {
|
|
84
|
+
let filtered = mode === 'all' ? raw : raw.filter(p => {
|
|
85
|
+
const name = p.name.toLowerCase();
|
|
86
|
+
return watchedPatterns.some(pat => name.includes(pat))
|
|
87
|
+
|| managedPatterns.some(m => name.includes(m));
|
|
88
|
+
});
|
|
89
|
+
return [...filtered].sort((a, b) => {
|
|
90
|
+
switch (sort) {
|
|
91
|
+
case 'cpu': return b.cpu - a.cpu;
|
|
92
|
+
case 'memory': return b.memory - a.memory;
|
|
93
|
+
case 'name': return a.name.localeCompare(b.name);
|
|
94
|
+
case 'pid': return a.pid - b.pid;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
|
+
}, [viewMode, sortKey, managedNames]);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const refresh = (raw: ProcessList): void => {
|
|
102
|
+
const built = buildList(raw, viewMode, sortKey);
|
|
103
|
+
setList(built);
|
|
104
|
+
setSelectedIdx(i => Math.min(i, Math.max(0, built.length - 1)));
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const unsubProc = bus.on('processes:updated', refresh);
|
|
108
|
+
const unsubStarted = bus.on('managed:started', () => refresh(getProcesses()));
|
|
109
|
+
const unsubStopped = bus.on('managed:stopped', () => refresh(getProcesses()));
|
|
110
|
+
const unsubCrashed = bus.on('managed:crashed', () => refresh(getProcesses()));
|
|
111
|
+
const unsubRestart = bus.on('managed:restarted',() => refresh(getProcesses()));
|
|
112
|
+
const unsubKill = bus.on('process:kill:result', ({ pid, success, error }) => {
|
|
113
|
+
const msg = success
|
|
114
|
+
? `Process ${pid} terminated.`
|
|
115
|
+
: `Kill failed for ${pid}: ${error ?? 'unknown'}`;
|
|
116
|
+
showFlash(msg, success ? 'green' : 'red');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Initial fill
|
|
120
|
+
refresh(getProcesses());
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
unsubProc(); unsubStarted(); unsubStopped();
|
|
124
|
+
unsubCrashed(); unsubRestart(); unsubKill();
|
|
125
|
+
};
|
|
126
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
127
|
+
}, [viewMode, sortKey, managedNames]);
|
|
128
|
+
|
|
129
|
+
function showFlash(msg: string, _color?: string): void {
|
|
130
|
+
setFlash(msg);
|
|
131
|
+
setTimeout(() => setFlash(null), 2500);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
useInput((input, key) => {
|
|
135
|
+
if (!isFocused) return;
|
|
136
|
+
|
|
137
|
+
// Kill confirm modal intercepts all input
|
|
138
|
+
if (killTarget !== null) {
|
|
139
|
+
if (input.toLowerCase() === 'y') {
|
|
140
|
+
bus.emit('process:kill:requested', { pid: killTarget.pid });
|
|
141
|
+
setKillTarget(null);
|
|
142
|
+
} else if (input.toLowerCase() === 'n' || key.escape) {
|
|
143
|
+
setKillTarget(null);
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (input === 'a') { setViewMode('all'); return; }
|
|
149
|
+
if (input === 'f') { setViewMode('watched'); return; }
|
|
150
|
+
if (input === 'c') { setSortKey('cpu'); return; }
|
|
151
|
+
if (input === 'm') { setSortKey('memory'); return; }
|
|
152
|
+
|
|
153
|
+
if (key.upArrow || input === 'k') {
|
|
154
|
+
setSelectedIdx(i => Math.max(0, i - 1));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (key.downArrow || input === 'j') {
|
|
158
|
+
setSelectedIdx(i => Math.min(list.length - 1, i + 1));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (input === 'K') {
|
|
163
|
+
const proc = list[selectedIdx];
|
|
164
|
+
if (proc) setKillTarget(proc);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (input === 'r') {
|
|
169
|
+
const proc = list[selectedIdx];
|
|
170
|
+
if (proc && managedNames.has(proc.name)) {
|
|
171
|
+
bus.emit('managed:restart:requested', { id: proc.name });
|
|
172
|
+
showFlash(`Restarting ${proc.name}…`);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (input === 's') {
|
|
178
|
+
const proc = list[selectedIdx];
|
|
179
|
+
if (proc && managedNames.has(proc.name)) {
|
|
180
|
+
bus.emit('managed:stop:requested', { id: proc.name });
|
|
181
|
+
showFlash(`Stopping ${proc.name}…`);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const { stdout } = useStdout();
|
|
188
|
+
|
|
189
|
+
// Cap rows to what fits in the process panel (~30% of terminal height minus chrome rows)
|
|
190
|
+
const panelRows = Math.max(4, Math.floor((stdout.rows ?? 24) * 0.30) - 4);
|
|
191
|
+
const visibleList = list.slice(0, panelRows);
|
|
192
|
+
|
|
193
|
+
const hasManagedKeys = managedNames.size > 0;
|
|
194
|
+
const labelHint = hasManagedKeys
|
|
195
|
+
? ' Processes [a/f c/m ↑↓ K=kill r=restart s=stop] '
|
|
196
|
+
: ' Processes [a/f c/m ↑↓ K=kill] ';
|
|
197
|
+
const title = flash !== null ? ` ${flash} ` : labelHint;
|
|
198
|
+
|
|
199
|
+
const headerLine = ' ' + HEADERS.map((h, i) => h.padEnd(COL_WIDTHS[i] ?? 10)).join(' ');
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<Box
|
|
203
|
+
borderStyle="single"
|
|
204
|
+
borderColor="yellow"
|
|
205
|
+
flexDirection="column"
|
|
206
|
+
flexGrow={1}
|
|
207
|
+
overflow="hidden"
|
|
208
|
+
>
|
|
209
|
+
{/* Title / flash */}
|
|
210
|
+
<Text color="yellow" bold>{title}</Text>
|
|
211
|
+
|
|
212
|
+
{/* Mode bar */}
|
|
213
|
+
<Box>
|
|
214
|
+
<Text>{' '}</Text>
|
|
215
|
+
<Text
|
|
216
|
+
inverse={viewMode === 'all'}
|
|
217
|
+
color={viewMode === 'all' ? 'cyan' : 'gray'}
|
|
218
|
+
> ALL </Text>
|
|
219
|
+
<Text> </Text>
|
|
220
|
+
<Text
|
|
221
|
+
inverse={viewMode === 'watched'}
|
|
222
|
+
color={viewMode === 'watched' ? 'cyan' : 'gray'}
|
|
223
|
+
> WATCH </Text>
|
|
224
|
+
<Text color="gray"> sort:{sortKey}</Text>
|
|
225
|
+
{hasManagedKeys && <Text color="gray"> [r]=restart [s]=stop managed</Text>}
|
|
226
|
+
</Box>
|
|
227
|
+
|
|
228
|
+
{/* Header */}
|
|
229
|
+
<Text color="cyan" bold>{headerLine}</Text>
|
|
230
|
+
|
|
231
|
+
{/* Rows */}
|
|
232
|
+
{list.length === 0 ? (
|
|
233
|
+
<Text color="gray"> No processes found.</Text>
|
|
234
|
+
) : (
|
|
235
|
+
visibleList.map((p, i) => (
|
|
236
|
+
<ProcessRow
|
|
237
|
+
key={p.pid}
|
|
238
|
+
p={p}
|
|
239
|
+
managed={getManagedById(p.name)}
|
|
240
|
+
isManaged={managedNames.has(p.name)}
|
|
241
|
+
selected={i === selectedIdx}
|
|
242
|
+
/>
|
|
243
|
+
))
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{/* Kill confirm modal */}
|
|
247
|
+
{killTarget !== null && (
|
|
248
|
+
<Box
|
|
249
|
+
borderStyle="single"
|
|
250
|
+
borderColor="red"
|
|
251
|
+
flexDirection="column"
|
|
252
|
+
paddingX={2}
|
|
253
|
+
paddingY={1}
|
|
254
|
+
>
|
|
255
|
+
<Text color="red" bold> Confirm Kill </Text>
|
|
256
|
+
<Text>Kill "{killTarget.name}" (PID {killTarget.pid})?</Text>
|
|
257
|
+
<Text color="gray">Press <Text color="white" bold>y</Text> to confirm, <Text color="white" bold>n</Text> to cancel</Text>
|
|
258
|
+
</Box>
|
|
259
|
+
)}
|
|
260
|
+
</Box>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Runtime 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 { getAllRuntimeMetrics } from '../../core/state-manager.ts';
|
|
9
|
+
import type { RuntimeMetrics } from '../../types/metrics.types.ts';
|
|
10
|
+
import { formatBytes, formatUptime, colorPercent } from '../../utils/formatters.ts';
|
|
11
|
+
|
|
12
|
+
function lagColor(ms: number): 'red' | 'yellow' | 'green' {
|
|
13
|
+
if (ms >= 100) return 'red';
|
|
14
|
+
if (ms >= 20) return 'yellow';
|
|
15
|
+
return 'green';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ProcessRuntime({ m }: { m: RuntimeMetrics }): React.ReactElement {
|
|
19
|
+
const heapPct = m.heapTotal > 0 ? (m.heapUsed / m.heapTotal) * 100 : 0;
|
|
20
|
+
const barWidth = 16;
|
|
21
|
+
const filled = Math.round((Math.min(100, heapPct) / 100) * barWidth);
|
|
22
|
+
const empty = barWidth - filled;
|
|
23
|
+
const barColor = colorPercent(heapPct);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
27
|
+
<Box>
|
|
28
|
+
<Text color="cyan" bold>◈ {m.managedId}</Text>
|
|
29
|
+
<Text color="gray"> pid:{m.pid} uptime:{formatUptime(m.uptime)}</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
<Box>
|
|
32
|
+
<Text> Heap [</Text>
|
|
33
|
+
<Text color={barColor}>{'█'.repeat(filled)}</Text>
|
|
34
|
+
<Text color="gray">{'░'.repeat(empty)}</Text>
|
|
35
|
+
<Text>] {formatBytes(m.heapUsed)}/{formatBytes(m.heapTotal)} </Text>
|
|
36
|
+
<Text color={barColor}>{heapPct.toFixed(1)}%</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
<Text> RSS: {formatBytes(m.rss)} External: {formatBytes(m.external)} ArrayBuf: {formatBytes(m.arrayBuffers)}</Text>
|
|
39
|
+
<Box>
|
|
40
|
+
<Text> EventLoop Lag: </Text>
|
|
41
|
+
<Text color={lagColor(m.eventLoopLag)}>{m.eventLoopLag.toFixed(1)}ms</Text>
|
|
42
|
+
<Text> Handles: {m.activeHandles} Requests: {m.activeRequests}</Text>
|
|
43
|
+
</Box>
|
|
44
|
+
<Text>
|
|
45
|
+
{' GC: '}{m.gc.count} events Total: {m.gc.totalPauseMs.toFixed(0)}ms
|
|
46
|
+
{m.gc.lastPauseMs !== null ? ` Last: ${m.gc.lastPauseMs.toFixed(1)}ms` : ''}
|
|
47
|
+
</Text>
|
|
48
|
+
</Box>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function RuntimePanel(): React.ReactElement {
|
|
53
|
+
const [all, setAll] = useState<RuntimeMetrics[]>(getAllRuntimeMetrics);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const unsub = bus.on('metrics:runtime:updated', () => {
|
|
57
|
+
setAll(getAllRuntimeMetrics());
|
|
58
|
+
});
|
|
59
|
+
return unsub;
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Box
|
|
64
|
+
borderStyle="single"
|
|
65
|
+
borderColor="cyan"
|
|
66
|
+
flexDirection="column"
|
|
67
|
+
flexGrow={1}
|
|
68
|
+
paddingX={1}
|
|
69
|
+
overflow="hidden"
|
|
70
|
+
>
|
|
71
|
+
<Text color="cyan" bold> Runtime [R=toggle] </Text>
|
|
72
|
+
{all.length === 0 ? (
|
|
73
|
+
<>
|
|
74
|
+
<Text color="gray"> No runtime metrics yet.</Text>
|
|
75
|
+
<Text color="gray"> Launch a managed process with `mw start <file>` to see Node/Bun internals.</Text>
|
|
76
|
+
</>
|
|
77
|
+
) : (
|
|
78
|
+
all.map(m => <ProcessRuntime key={m.managedId} m={m} />)
|
|
79
|
+
)}
|
|
80
|
+
</Box>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Sparkline — simple two-row ASCII throughput chart
|
|
3
|
+
//
|
|
4
|
+
// Renders two lines of block characters (▁▂▃▄▅▆▇█):
|
|
5
|
+
// Row 1: RX (green)
|
|
6
|
+
// Row 2: TX (red)
|
|
7
|
+
//
|
|
8
|
+
// Each column = one time sample (oldest left → newest right).
|
|
9
|
+
// Height of each character = sample value / maxVal mapped to 8 levels.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { Box, Text } from 'ink';
|
|
14
|
+
|
|
15
|
+
// 8-level block chars: index 0 = empty, index 8 = full block
|
|
16
|
+
const BLOCKS = ['▁', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const;
|
|
17
|
+
|
|
18
|
+
function formatRate(bps: number): string {
|
|
19
|
+
if (bps < 1024) return `${bps.toFixed(0)} B/s`;
|
|
20
|
+
if (bps < 1_048_576) return `${(bps / 1024).toFixed(1)} KB/s`;
|
|
21
|
+
if (bps < 1_073_741_824) return `${(bps / 1_048_576).toFixed(1)} MB/s`;
|
|
22
|
+
return `${(bps / 1_073_741_824).toFixed(1)} GB/s`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SparklineProps {
|
|
26
|
+
/** RX series in bytes/sec (oldest → newest) */
|
|
27
|
+
rxSeries: number[];
|
|
28
|
+
/** TX series in bytes/sec (oldest → newest) */
|
|
29
|
+
txSeries: number[];
|
|
30
|
+
/** Number of columns to display */
|
|
31
|
+
width: number;
|
|
32
|
+
label?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toChars(series: number[], maxVal: number, width: number): string {
|
|
36
|
+
// Pad or trim to exactly `width` samples
|
|
37
|
+
const padded = series.length >= width
|
|
38
|
+
? series.slice(-width)
|
|
39
|
+
: [...new Array(width - series.length).fill(0) as number[], ...series];
|
|
40
|
+
|
|
41
|
+
return padded
|
|
42
|
+
.map(v => {
|
|
43
|
+
if (maxVal <= 0) return BLOCKS[0];
|
|
44
|
+
const idx = Math.min(8, Math.round((v / maxVal) * 8));
|
|
45
|
+
return BLOCKS[idx] ?? BLOCKS[8];
|
|
46
|
+
})
|
|
47
|
+
.join('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function Sparkline({ rxSeries, txSeries, width, label }: SparklineProps): React.ReactElement {
|
|
51
|
+
const maxVal = Math.max(...rxSeries, ...txSeries, 1);
|
|
52
|
+
const rxChars = toChars(rxSeries, maxVal, width);
|
|
53
|
+
const txChars = toChars(txSeries, maxVal, width);
|
|
54
|
+
|
|
55
|
+
const currentRx = rxSeries.at(-1) ?? 0;
|
|
56
|
+
const currentTx = txSeries.at(-1) ?? 0;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
60
|
+
{label !== undefined && <Text color="magenta" bold> {label} </Text>}
|
|
61
|
+
<Box flexDirection="row" gap={1}>
|
|
62
|
+
<Text color="green" bold>↓RX</Text>
|
|
63
|
+
<Text color="green">{formatRate(currentRx)}</Text>
|
|
64
|
+
<Text color="red" bold>↑TX</Text>
|
|
65
|
+
<Text color="red">{formatRate(currentTx)}</Text>
|
|
66
|
+
<Text color="gray">max:{formatRate(maxVal)}</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
<Box flexGrow={1} />
|
|
69
|
+
<Text color="green">{rxChars}</Text>
|
|
70
|
+
<Text color="red">{txChars}</Text>
|
|
71
|
+
<Text color="gray">{'─'.repeat(width)}</Text>
|
|
72
|
+
</Box>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── RingBuffer ────────────────────────────────────────────────────────────────
|
|
77
|
+
// Re-exported for use in network.widget.tsx
|
|
78
|
+
|
|
79
|
+
export class RingBuffer {
|
|
80
|
+
private readonly buf: number[];
|
|
81
|
+
private head = 0;
|
|
82
|
+
readonly length: number;
|
|
83
|
+
|
|
84
|
+
constructor(size: number) {
|
|
85
|
+
this.length = size;
|
|
86
|
+
this.buf = new Array(size).fill(0) as number[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
push(value: number): void {
|
|
90
|
+
this.buf[this.head] = value;
|
|
91
|
+
this.head = (this.head + 1) % this.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
toArray(): number[] {
|
|
95
|
+
const out = new Array(this.length) as number[];
|
|
96
|
+
for (let i = 0; i < this.length; i++) {
|
|
97
|
+
out[i] = this.buf[(this.head + i) % this.length]!;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
max(): number {
|
|
103
|
+
let m = 0;
|
|
104
|
+
for (const v of this.buf) { if (v > m) m = v; }
|
|
105
|
+
return m;
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/utils/formatters.ts
CHANGED
|
@@ -8,15 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
|
10
10
|
|
|
11
|
+
// Hoisted constants — avoid recomputing inside hot-path functions.
|
|
12
|
+
const LOG_1024 = Math.log(1024);
|
|
13
|
+
const BYTE_DIVS = [1, 1024, 1_048_576, 1_073_741_824, 1_099_511_627_776] as const;
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
16
|
* Format bytes into the most appropriate human-readable unit.
|
|
13
17
|
* @example formatBytes(1536) → "1.5 KB"
|
|
14
18
|
*/
|
|
15
19
|
export function formatBytes(bytes: number, decimals = 1): string {
|
|
16
20
|
if (bytes <= 0) return '0 B';
|
|
17
|
-
const i
|
|
21
|
+
const i = Math.floor(Math.log(bytes) / LOG_1024);
|
|
18
22
|
const capped = Math.min(i, BYTE_UNITS.length - 1);
|
|
19
|
-
const value
|
|
23
|
+
const value = bytes / BYTE_DIVS[capped]!;
|
|
20
24
|
return `${value.toFixed(decimals)} ${BYTE_UNITS[capped]}`;
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -49,7 +53,8 @@ export function formatUptime(seconds: number): string {
|
|
|
49
53
|
* @example truncate('hello world', 8) → "hello..."
|
|
50
54
|
*/
|
|
51
55
|
export function truncate(str: string, maxLen: number): string {
|
|
52
|
-
if (str.length
|
|
56
|
+
if (str.length === maxLen) return str; // already exact — no allocation
|
|
57
|
+
if (str.length < maxLen) return str.padEnd(maxLen);
|
|
53
58
|
return str.slice(0, maxLen - 3) + '...';
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -63,12 +68,11 @@ export function bar(percent: number, width = 20): string {
|
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
/**
|
|
66
|
-
*
|
|
71
|
+
* Return an ink/React color string for a percentage value.
|
|
67
72
|
* green < 60, yellow < 85, red >= 85
|
|
68
73
|
*/
|
|
69
|
-
export function colorPercent(percent: number):
|
|
70
|
-
|
|
71
|
-
if (percent >=
|
|
72
|
-
|
|
73
|
-
return `{green-fg}${formatted}{/green-fg}`;
|
|
74
|
+
export function colorPercent(percent: number): 'red' | 'yellow' | 'green' {
|
|
75
|
+
if (percent >= 85) return 'red';
|
|
76
|
+
if (percent >= 60) return 'yellow';
|
|
77
|
+
return 'green';
|
|
74
78
|
}
|