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.
@@ -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 &lt;file&gt;` 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
+ }
@@ -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 = Math.floor(Math.log(bytes) / Math.log(1024));
21
+ const i = Math.floor(Math.log(bytes) / LOG_1024);
18
22
  const capped = Math.min(i, BYTE_UNITS.length - 1);
19
- const value = bytes / Math.pow(1024, capped);
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 <= maxLen) return str.padEnd(maxLen);
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
- * Color-code a percentage as a blessed tag string.
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): string {
70
- const formatted = formatPercent(percent);
71
- if (percent >= 85) return `{red-fg}${formatted}{/red-fg}`;
72
- if (percent >= 60) return `{yellow-fg}${formatted}{/yellow-fg}`;
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
  }