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
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
+
}
|