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
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// CPU Widget
|
|
3
|
-
//
|
|
4
|
-
// Renders per-core CPU usage as stacked horizontal gauges plus an overall
|
|
5
|
-
// usage bar. Subscribes to 'metrics:cpu:updated' events and re-renders.
|
|
6
|
-
//
|
|
7
|
-
// Layout:
|
|
8
|
-
// ┌─ CPU ──────────────────────────────────┐
|
|
9
|
-
// │ Overall ████████░░░░░░░░ 45.3% │
|
|
10
|
-
// │ Core 0 ██████░░░░░░░░░░ 38.1% │
|
|
11
|
-
// │ Core 1 █████████░░░░░░░ 52.4% │
|
|
12
|
-
// │ ... │
|
|
13
|
-
// └─────────────────────────────────────────┘
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
import blessed from 'blessed';
|
|
17
|
-
import type { BlessedScreen, Box } from 'blessed';
|
|
18
|
-
import { bus } from '../../core/event-bus.ts';
|
|
19
|
-
import { getCpuMetrics } from '../../core/state-manager.ts';
|
|
20
|
-
import type { CpuMetrics } from '../../types/metrics.types.ts';
|
|
21
|
-
import { formatPercent, colorPercent } from '../../utils/formatters.ts';
|
|
22
|
-
|
|
23
|
-
interface CpuWidgetOptions {
|
|
24
|
-
screen: BlessedScreen;
|
|
25
|
-
top: number | string;
|
|
26
|
-
left: number | string;
|
|
27
|
-
width: number | string;
|
|
28
|
-
height: number | string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface CpuWidgetHandle {
|
|
32
|
-
box: Box;
|
|
33
|
-
destroy: () => void;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function createCpuWidget(options: CpuWidgetOptions): CpuWidgetHandle {
|
|
37
|
-
const { screen, top, left, width, height } = options;
|
|
38
|
-
|
|
39
|
-
const box = blessed.box({
|
|
40
|
-
top,
|
|
41
|
-
left,
|
|
42
|
-
width,
|
|
43
|
-
height,
|
|
44
|
-
label: ' CPU ',
|
|
45
|
-
tags: true,
|
|
46
|
-
border: { type: 'line' },
|
|
47
|
-
style: {
|
|
48
|
-
border: { fg: 'cyan' },
|
|
49
|
-
label: { fg: 'cyan', bold: true },
|
|
50
|
-
},
|
|
51
|
-
padding: { top: 0, left: 1, right: 1, bottom: 0 },
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
screen.append(box as unknown as import('blessed').BlessedElement);
|
|
55
|
-
|
|
56
|
-
function renderMetrics(metrics: CpuMetrics): void {
|
|
57
|
-
const innerWidth = (typeof width === 'string' ? box.width : width as number) - 4;
|
|
58
|
-
const barWidth = Math.max(10, innerWidth - 14); // reserve space for label + percent
|
|
59
|
-
|
|
60
|
-
function makeLine(label: string, usage: number): string {
|
|
61
|
-
const filled = Math.round((Math.min(100, usage) / 100) * barWidth);
|
|
62
|
-
const empty = barWidth - filled;
|
|
63
|
-
const barColor = usage >= 85 ? '{red-fg}' : usage >= 60 ? '{yellow-fg}' : '{green-fg}';
|
|
64
|
-
const bar = `${barColor}${'█'.repeat(filled)}{/}${'░'.repeat(empty)}`;
|
|
65
|
-
const pct = colorPercent(usage);
|
|
66
|
-
return ` ${label.padEnd(8)} ${bar} ${pct}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const lines: string[] = [
|
|
70
|
-
makeLine('Overall', metrics.usage),
|
|
71
|
-
'',
|
|
72
|
-
...metrics.cores.slice(0, 8).map(c => makeLine(`Core ${c.index}`, c.usage)),
|
|
73
|
-
];
|
|
74
|
-
|
|
75
|
-
if (metrics.cores.length > 8) {
|
|
76
|
-
lines.push(` {gray-fg} ... +${metrics.cores.length - 8} cores{/gray-fg}`);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
lines.push('');
|
|
80
|
-
lines.push(` {gray-fg}Model: ${metrics.model}{/gray-fg}`);
|
|
81
|
-
|
|
82
|
-
box.setContent(lines.join('\n'));
|
|
83
|
-
screen.render();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Paint immediately if state is already populated (re-mount case)
|
|
87
|
-
const initial = getCpuMetrics();
|
|
88
|
-
if (initial) renderMetrics(initial);
|
|
89
|
-
|
|
90
|
-
const unsub = bus.on('metrics:cpu:updated', renderMetrics);
|
|
91
|
-
|
|
92
|
-
function destroy(): void {
|
|
93
|
-
unsub();
|
|
94
|
-
box.destroy();
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return { box: box as unknown as Box, destroy };
|
|
98
|
-
}
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// Disk Widget
|
|
3
|
-
//
|
|
4
|
-
// Displays per-mount disk usage and aggregate IO rates.
|
|
5
|
-
//
|
|
6
|
-
// Layout (inside a bordered box):
|
|
7
|
-
// Row 0: IO summary → Read: 12.3 MB/s Write: 4.5 MB/s
|
|
8
|
-
// Row 1+: Per mount → [bar] /dev/sda1 / (ext4) 45.2 GB / 200 GB 22.6%
|
|
9
|
-
//
|
|
10
|
-
// Toggle key: d (handled in layout.ts, not here)
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
import blessed from 'blessed';
|
|
14
|
-
import type { BlessedScreen, BlessedElement } from 'blessed';
|
|
15
|
-
import { bus } from '../../core/event-bus.ts';
|
|
16
|
-
import { getDiskMetrics } from '../../core/state-manager.ts';
|
|
17
|
-
import type { DiskMetrics, DiskMount } from '../../types/metrics.types.ts';
|
|
18
|
-
import { formatBytes, bar, colorPercent, truncate } from '../../utils/formatters.ts';
|
|
19
|
-
|
|
20
|
-
interface DiskWidgetOptions {
|
|
21
|
-
screen: BlessedScreen;
|
|
22
|
-
top: number | string;
|
|
23
|
-
left: number | string;
|
|
24
|
-
width: number | string;
|
|
25
|
-
height: number | string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface DiskWidgetHandle {
|
|
29
|
-
box: BlessedElement;
|
|
30
|
-
destroy: () => void;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ── Formatters ────────────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
function formatRate(bps: number): string {
|
|
36
|
-
if (bps < 1024) return `${bps.toFixed(0)} B/s`;
|
|
37
|
-
if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
|
|
38
|
-
return `${(bps / (1024 * 1024)).toFixed(1)} MB/s`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function mountLine(m: DiskMount, innerWidth: number): string {
|
|
42
|
-
const barWidth = 12;
|
|
43
|
-
const usageBar = bar(m.percent, barWidth);
|
|
44
|
-
const barColored = m.percent >= 90
|
|
45
|
-
? `{red-fg}${usageBar}{/red-fg}`
|
|
46
|
-
: m.percent >= 75
|
|
47
|
-
? `{yellow-fg}${usageBar}{/yellow-fg}`
|
|
48
|
-
: `{green-fg}${usageBar}{/green-fg}`;
|
|
49
|
-
|
|
50
|
-
const pct = colorPercent(m.percent);
|
|
51
|
-
const space = `${formatBytes(m.used, 0)} / ${formatBytes(m.total, 0)}`;
|
|
52
|
-
const fs = truncate(m.fs, 14);
|
|
53
|
-
const mnt = truncate(m.mount, 12);
|
|
54
|
-
const fstype = truncate(m.type, 6);
|
|
55
|
-
|
|
56
|
-
// Right-align the space/pct section
|
|
57
|
-
const right = `${space} ${pct}`;
|
|
58
|
-
const leftPart = `[${barColored}] ${fs} ${mnt} ${fstype}`;
|
|
59
|
-
|
|
60
|
-
return ` ${leftPart.padEnd(innerWidth - right.length - 2)}${right}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ── Widget factory ────────────────────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
export function createDiskWidget(options: DiskWidgetOptions): DiskWidgetHandle {
|
|
66
|
-
const { screen, top, left, width, height } = options;
|
|
67
|
-
|
|
68
|
-
const box = blessed.box({
|
|
69
|
-
top,
|
|
70
|
-
left,
|
|
71
|
-
width,
|
|
72
|
-
height,
|
|
73
|
-
label: ' Disk [d=toggle] ',
|
|
74
|
-
tags: true,
|
|
75
|
-
border: { type: 'line' },
|
|
76
|
-
scrollable: true,
|
|
77
|
-
alwaysScroll: true,
|
|
78
|
-
keys: false,
|
|
79
|
-
mouse: true,
|
|
80
|
-
style: {
|
|
81
|
-
border: { fg: 'blue' },
|
|
82
|
-
label: { fg: 'blue', bold: true },
|
|
83
|
-
},
|
|
84
|
-
padding: { top: 0, left: 1, right: 1, bottom: 0 },
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
screen.append(box);
|
|
88
|
-
|
|
89
|
-
function render(metrics: DiskMetrics): void {
|
|
90
|
-
const lines: string[] = [];
|
|
91
|
-
|
|
92
|
-
// IO summary row
|
|
93
|
-
const rStr = formatRate(metrics.totalReadBytesPerSec);
|
|
94
|
-
const wStr = formatRate(metrics.totalWriteBytesPerSec);
|
|
95
|
-
lines.push(
|
|
96
|
-
` {bold}IO:{/bold} {green-fg}↓ ${rStr.padEnd(12)}{/green-fg} {red-fg}↑ ${wStr}{/red-fg}`
|
|
97
|
-
);
|
|
98
|
-
lines.push('');
|
|
99
|
-
|
|
100
|
-
if (metrics.mounts.length === 0) {
|
|
101
|
-
lines.push('{gray-fg} No mounted filesystems detected.{/gray-fg}');
|
|
102
|
-
} else {
|
|
103
|
-
// Header
|
|
104
|
-
lines.push(
|
|
105
|
-
' {bold}{cyan-fg}' +
|
|
106
|
-
'DEVICE MOUNT TYPE USED / TOTAL USE%' +
|
|
107
|
-
'{/cyan-fg}{/bold}'
|
|
108
|
-
);
|
|
109
|
-
for (const m of metrics.mounts) {
|
|
110
|
-
lines.push(mountLine(m, 72));
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
box.setContent(lines.join('\n'));
|
|
115
|
-
screen.render();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ── Initial render ────────────────────────────────────────────────────────
|
|
119
|
-
const initial = getDiskMetrics();
|
|
120
|
-
if (initial) {
|
|
121
|
-
render(initial);
|
|
122
|
-
} else {
|
|
123
|
-
box.setContent('{gray-fg} Collecting disk metrics…{/gray-fg}');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const unsub = bus.on('metrics:disk:updated', render);
|
|
127
|
-
|
|
128
|
-
function destroy(): void {
|
|
129
|
-
unsub();
|
|
130
|
-
box.destroy();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return { box, destroy };
|
|
134
|
-
}
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// Logs Widget
|
|
3
|
-
//
|
|
4
|
-
// Displays realtime stdout/stderr from managed processes.
|
|
5
|
-
//
|
|
6
|
-
// stdout lines → white
|
|
7
|
-
// stderr lines → {red-fg}
|
|
8
|
-
// prefix → [{id}] shown when more than one process is managed
|
|
9
|
-
//
|
|
10
|
-
// On mount: reads existing lines from LogManager (history).
|
|
11
|
-
// Live: subscribes to 'log:line' events on the bus.
|
|
12
|
-
//
|
|
13
|
-
// Focus key 'l' → this box scrolls; 'Escape' returns focus to screen.
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
import blessed from 'blessed';
|
|
17
|
-
import type { BlessedScreen, BlessedElement } from 'blessed';
|
|
18
|
-
import { bus } from '../../core/event-bus.ts';
|
|
19
|
-
import type { LogManagerHandle } from '../../core/log-manager.ts';
|
|
20
|
-
|
|
21
|
-
interface LogsWidgetOptions {
|
|
22
|
-
screen: BlessedScreen;
|
|
23
|
-
logManager: LogManagerHandle;
|
|
24
|
-
/** How many distinct managed process IDs exist — used to decide prefix display */
|
|
25
|
-
processCount: number;
|
|
26
|
-
top: number | string;
|
|
27
|
-
left: number | string;
|
|
28
|
-
width: number | string;
|
|
29
|
-
height: number | string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface LogsWidgetHandle {
|
|
33
|
-
box: BlessedElement;
|
|
34
|
-
destroy: () => void;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function createLogsWidget(options: LogsWidgetOptions): LogsWidgetHandle {
|
|
38
|
-
const { screen, logManager, processCount, top, left, width, height } = options;
|
|
39
|
-
|
|
40
|
-
const showPrefix = processCount > 1;
|
|
41
|
-
|
|
42
|
-
const box = blessed.box({
|
|
43
|
-
top,
|
|
44
|
-
left,
|
|
45
|
-
width,
|
|
46
|
-
height,
|
|
47
|
-
label: ' Logs [l=focus ↑↓=scroll] ',
|
|
48
|
-
tags: true,
|
|
49
|
-
border: { type: 'line' },
|
|
50
|
-
scrollable: true,
|
|
51
|
-
alwaysScroll: true,
|
|
52
|
-
keys: true,
|
|
53
|
-
vi: false,
|
|
54
|
-
mouse: true,
|
|
55
|
-
scrollbar: { ch: '│', style: { fg: 'green' } } as never,
|
|
56
|
-
style: {
|
|
57
|
-
border: { fg: 'green' },
|
|
58
|
-
label: { fg: 'green', bold: true },
|
|
59
|
-
scrollbar: { fg: 'green' },
|
|
60
|
-
},
|
|
61
|
-
padding: { top: 0, left: 1, right: 1, bottom: 0 },
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
screen.append(box);
|
|
65
|
-
|
|
66
|
-
// ── Line formatter ────────────────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
function formatLine(
|
|
69
|
-
id: string,
|
|
70
|
-
stream: 'stdout' | 'stderr',
|
|
71
|
-
line: string,
|
|
72
|
-
timestamp: number
|
|
73
|
-
): string {
|
|
74
|
-
const time = new Date(timestamp).toLocaleTimeString('en', { hour12: false });
|
|
75
|
-
const prefix = showPrefix ? `{cyan-fg}[${id}]{/cyan-fg} ` : '';
|
|
76
|
-
const ts = `{gray-fg}${time}{/gray-fg} `;
|
|
77
|
-
const text = stream === 'stderr'
|
|
78
|
-
? `{red-fg}${line}{/red-fg}`
|
|
79
|
-
: line;
|
|
80
|
-
return `${ts}${prefix}${text}`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ── Render history on mount ────────────────────────────────────────────────
|
|
84
|
-
|
|
85
|
-
const history = logManager.getAllLines();
|
|
86
|
-
const historyLines = history.map(l => formatLine(l.id, l.stream, l.line, l.timestamp));
|
|
87
|
-
if (historyLines.length > 0) {
|
|
88
|
-
box.setContent(historyLines.join('\n'));
|
|
89
|
-
// Scroll to bottom after setting content
|
|
90
|
-
(box as unknown as { setScrollPerc: (n: number) => void }).setScrollPerc(100);
|
|
91
|
-
} else {
|
|
92
|
-
box.setContent(
|
|
93
|
-
'{gray-fg}Waiting for output from managed processes...{/gray-fg}'
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ── Live subscription ─────────────────────────────────────────────────────
|
|
98
|
-
|
|
99
|
-
// Maintain a local line buffer for efficient append
|
|
100
|
-
const lines: string[] = [...historyLines];
|
|
101
|
-
|
|
102
|
-
const unsubLine = bus.on('log:line', ({ id, stream, line, timestamp }) => {
|
|
103
|
-
// Replace placeholder on first real line
|
|
104
|
-
if (lines.length === 0 || (lines.length === 1 && lines[0]?.includes('Waiting'))) {
|
|
105
|
-
lines.length = 0;
|
|
106
|
-
}
|
|
107
|
-
lines.push(formatLine(id, stream, line, timestamp));
|
|
108
|
-
box.setContent(lines.join('\n'));
|
|
109
|
-
(box as unknown as { setScrollPerc: (n: number) => void }).setScrollPerc(100);
|
|
110
|
-
screen.render();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const unsubStarted = bus.on('managed:started', ({ id, pid }) => {
|
|
114
|
-
lines.push(`{gray-fg}── ${id} started (pid ${pid}) ──{/gray-fg}`);
|
|
115
|
-
box.setContent(lines.join('\n'));
|
|
116
|
-
(box as unknown as { setScrollPerc: (n: number) => void }).setScrollPerc(100);
|
|
117
|
-
screen.render();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
const unsubCrashed = bus.on('managed:crashed', ({ id, exitCode, restarts }) => {
|
|
121
|
-
lines.push(
|
|
122
|
-
`{red-fg}── ${id} crashed (exit ${exitCode ?? '?'}) — restart #${restarts} scheduled ──{/red-fg}`
|
|
123
|
-
);
|
|
124
|
-
box.setContent(lines.join('\n'));
|
|
125
|
-
(box as unknown as { setScrollPerc: (n: number) => void }).setScrollPerc(100);
|
|
126
|
-
screen.render();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
const unsubRestarted = bus.on('managed:restarted', ({ id, pid, restarts }) => {
|
|
130
|
-
lines.push(`{yellow-fg}── ${id} restarted (pid ${pid}, #${restarts}) ──{/yellow-fg}`);
|
|
131
|
-
box.setContent(lines.join('\n'));
|
|
132
|
-
(box as unknown as { setScrollPerc: (n: number) => void }).setScrollPerc(100);
|
|
133
|
-
screen.render();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const unsubStopped = bus.on('managed:stopped', ({ id }) => {
|
|
137
|
-
lines.push(`{gray-fg}── ${id} stopped ──{/gray-fg}`);
|
|
138
|
-
box.setContent(lines.join('\n'));
|
|
139
|
-
(box as unknown as { setScrollPerc: (n: number) => void }).setScrollPerc(100);
|
|
140
|
-
screen.render();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// ── Focus key ─────────────────────────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
screen.key(['l'], () => {
|
|
146
|
-
box.focus();
|
|
147
|
-
screen.render();
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
box.key(['escape'], () => {
|
|
151
|
-
// Return focus to the screen root so other key bindings take over again
|
|
152
|
-
(screen as unknown as { focusPop: () => void }).focusPop?.();
|
|
153
|
-
screen.render();
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
// ── Destroy ───────────────────────────────────────────────────────────────
|
|
157
|
-
|
|
158
|
-
function destroy(): void {
|
|
159
|
-
unsubLine();
|
|
160
|
-
unsubStarted();
|
|
161
|
-
unsubCrashed();
|
|
162
|
-
unsubRestarted();
|
|
163
|
-
unsubStopped();
|
|
164
|
-
box.destroy();
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return { box, destroy };
|
|
168
|
-
}
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// Memory Widget
|
|
3
|
-
//
|
|
4
|
-
// Renders RAM usage as a visual bar + breakdown of used/free/cached.
|
|
5
|
-
// Subscribes to 'metrics:memory:updated' events.
|
|
6
|
-
//
|
|
7
|
-
// Layout:
|
|
8
|
-
// ┌─ Memory ─────────────────────────────────┐
|
|
9
|
-
// │ Used ████████████░░░░░░░░ 62.4% │
|
|
10
|
-
// │ │
|
|
11
|
-
// │ Used : 9.8 GB │
|
|
12
|
-
// │ Free : 5.9 GB │
|
|
13
|
-
// │ Cached : 1.2 GB │
|
|
14
|
-
// │ Total : 15.7 GB │
|
|
15
|
-
// └───────────────────────────────────────────┘
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
import blessed from 'blessed';
|
|
19
|
-
import type { BlessedScreen, Box } from 'blessed';
|
|
20
|
-
import { bus } from '../../core/event-bus.ts';
|
|
21
|
-
import { getMemoryMetrics } from '../../core/state-manager.ts';
|
|
22
|
-
import type { MemoryMetrics } from '../../types/metrics.types.ts';
|
|
23
|
-
import { formatBytes, colorPercent } from '../../utils/formatters.ts';
|
|
24
|
-
|
|
25
|
-
interface MemoryWidgetOptions {
|
|
26
|
-
screen: BlessedScreen;
|
|
27
|
-
top: number | string;
|
|
28
|
-
left: number | string;
|
|
29
|
-
width: number | string;
|
|
30
|
-
height: number | string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface MemoryWidgetHandle {
|
|
34
|
-
box: Box;
|
|
35
|
-
destroy: () => void;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function createMemoryWidget(options: MemoryWidgetOptions): MemoryWidgetHandle {
|
|
39
|
-
const { screen, top, left, width, height } = options;
|
|
40
|
-
|
|
41
|
-
const box = blessed.box({
|
|
42
|
-
top,
|
|
43
|
-
left,
|
|
44
|
-
width,
|
|
45
|
-
height,
|
|
46
|
-
label: ' Memory ',
|
|
47
|
-
tags: true,
|
|
48
|
-
border: { type: 'line' },
|
|
49
|
-
style: {
|
|
50
|
-
border: { fg: 'magenta' },
|
|
51
|
-
label: { fg: 'magenta', bold: true },
|
|
52
|
-
},
|
|
53
|
-
padding: { top: 0, left: 1, right: 1, bottom: 0 },
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
screen.append(box as unknown as import('blessed').BlessedElement);
|
|
57
|
-
|
|
58
|
-
function renderMetrics(metrics: MemoryMetrics): void {
|
|
59
|
-
const innerWidth = (typeof width === 'string' ? box.width : width as number) - 4;
|
|
60
|
-
const barWidth = Math.max(10, innerWidth - 14);
|
|
61
|
-
|
|
62
|
-
const filled = Math.round((Math.min(100, metrics.percent) / 100) * barWidth);
|
|
63
|
-
const empty = barWidth - filled;
|
|
64
|
-
const barColor = metrics.percent >= 85
|
|
65
|
-
? '{red-fg}'
|
|
66
|
-
: metrics.percent >= 60 ? '{yellow-fg}' : '{green-fg}';
|
|
67
|
-
const bar = `${barColor}${'█'.repeat(filled)}{/}${'░'.repeat(empty)}`;
|
|
68
|
-
const pct = colorPercent(metrics.percent);
|
|
69
|
-
|
|
70
|
-
const lines: string[] = [
|
|
71
|
-
` ${'RAM'.padEnd(8)} ${bar} ${pct}`,
|
|
72
|
-
'',
|
|
73
|
-
` {bold}Used {/bold} : ${formatBytes(metrics.used)}`,
|
|
74
|
-
` {bold}Free {/bold} : ${formatBytes(metrics.free)}`,
|
|
75
|
-
` {bold}Cached{/bold} : ${formatBytes(metrics.cached)}`,
|
|
76
|
-
` {bold}Total {/bold} : ${formatBytes(metrics.total)}`,
|
|
77
|
-
];
|
|
78
|
-
|
|
79
|
-
box.setContent(lines.join('\n'));
|
|
80
|
-
screen.render();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const initial = getMemoryMetrics();
|
|
84
|
-
if (initial) renderMetrics(initial);
|
|
85
|
-
|
|
86
|
-
const unsub = bus.on('metrics:memory:updated', renderMetrics);
|
|
87
|
-
|
|
88
|
-
function destroy(): void {
|
|
89
|
-
unsub();
|
|
90
|
-
box.destroy();
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return { box: box as unknown as Box, destroy };
|
|
94
|
-
}
|
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// Network Widget
|
|
3
|
-
//
|
|
4
|
-
// Layout (side by side inside a borderless container):
|
|
5
|
-
// Left ~28%: Interfaces panel (total summary + per-interface rows)
|
|
6
|
-
// Right ~72%: Throughput line chart (rx green / tx red, rolling 60s)
|
|
7
|
-
//
|
|
8
|
-
// Toggle key: n (handled in layout.ts)
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
import blessed from 'blessed';
|
|
12
|
-
import contrib from 'blessed-contrib';
|
|
13
|
-
import type { BlessedScreen, BlessedElement } from 'blessed';
|
|
14
|
-
import { bus } from '../../core/event-bus.ts';
|
|
15
|
-
import { getNetworkMetrics } from '../../core/state-manager.ts';
|
|
16
|
-
import type { NetworkMetrics, NetworkInterface } from '../../types/metrics.types.ts';
|
|
17
|
-
import { truncate } from '../../utils/formatters.ts';
|
|
18
|
-
|
|
19
|
-
interface NetworkWidgetOptions {
|
|
20
|
-
screen: BlessedScreen;
|
|
21
|
-
top: number | string;
|
|
22
|
-
left: number | string;
|
|
23
|
-
width: number | string;
|
|
24
|
-
height: number | string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface NetworkWidgetHandle {
|
|
28
|
-
box: BlessedElement;
|
|
29
|
-
destroy: () => void;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
const HISTORY_LEN = 30;
|
|
35
|
-
|
|
36
|
-
// ── Formatters ────────────────────────────────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
function fmtRate(bps: number): string {
|
|
39
|
-
if (bps < 1024) return `${bps.toFixed(0)} B/s`;
|
|
40
|
-
if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
|
|
41
|
-
if (bps < 1024 ** 3) return `${(bps / (1024 * 1024)).toFixed(1)} MB/s`;
|
|
42
|
-
return `${(bps / (1024 ** 3)).toFixed(1)} GB/s`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function stateTag(s: NetworkInterface['operstate']): string {
|
|
46
|
-
if (s === 'up') return '{green-fg}▲{/green-fg}';
|
|
47
|
-
if (s === 'down') return '{red-fg}▼{/red-fg}';
|
|
48
|
-
return '{gray-fg}?{/gray-fg}';
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// X-axis labels: real time strings at every position so blessed-contrib's
|
|
52
|
-
// internal showNthLabel recalc never skips them. We show seconds ago from right to left.
|
|
53
|
-
function makeXLabels(): string[] {
|
|
54
|
-
return Array.from({ length: HISTORY_LEN }, (_, i) => {
|
|
55
|
-
const age = HISTORY_LEN - 1 - i;
|
|
56
|
-
return `-${age}s`;
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// ── Widget factory ────────────────────────────────────────────────────────────
|
|
61
|
-
|
|
62
|
-
export function createNetworkWidget(options: NetworkWidgetOptions): NetworkWidgetHandle {
|
|
63
|
-
const { screen, top, left, width, height } = options;
|
|
64
|
-
|
|
65
|
-
// Outer container — no border; children carry their own borders.
|
|
66
|
-
// NOTE: appended to screen AFTER all children are parented so that
|
|
67
|
-
// contrib.line's 'attach' event fires with real dimensions.
|
|
68
|
-
const box = blessed.box({ top, left, width, height, tags: false });
|
|
69
|
-
|
|
70
|
-
// ── Left: Interfaces panel ────────────────────────────────────────────────
|
|
71
|
-
|
|
72
|
-
const statsBox = blessed.box({
|
|
73
|
-
parent: box as unknown as BlessedElement,
|
|
74
|
-
top: 0,
|
|
75
|
-
left: 0,
|
|
76
|
-
width: '28%',
|
|
77
|
-
height: '100%',
|
|
78
|
-
label: ' Interfaces ',
|
|
79
|
-
tags: true,
|
|
80
|
-
border: { type: 'line' },
|
|
81
|
-
scrollable: true,
|
|
82
|
-
alwaysScroll: true,
|
|
83
|
-
mouse: true,
|
|
84
|
-
style: {
|
|
85
|
-
border: { fg: 'magenta' },
|
|
86
|
-
label: { fg: 'magenta', bold: true },
|
|
87
|
-
},
|
|
88
|
-
padding: { top: 0, left: 1, right: 1, bottom: 0 },
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
// ── Right: Throughput line chart ──────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
const rxHistory: number[] = new Array(HISTORY_LEN).fill(0);
|
|
94
|
-
const txHistory: number[] = new Array(HISTORY_LEN).fill(0);
|
|
95
|
-
|
|
96
|
-
const chart = new contrib.line({
|
|
97
|
-
parent: box as unknown as BlessedElement,
|
|
98
|
-
top: 0,
|
|
99
|
-
left: '28%',
|
|
100
|
-
width: '72%',
|
|
101
|
-
height: '100%',
|
|
102
|
-
label: ' Throughput MB/s [n=toggle] ',
|
|
103
|
-
showLegend: true,
|
|
104
|
-
legend: { width: 10 },
|
|
105
|
-
xLabelPadding: 1,
|
|
106
|
-
xPadding: 2,
|
|
107
|
-
numYLabels: 3,
|
|
108
|
-
showNthLabel: 5,
|
|
109
|
-
minY: 0,
|
|
110
|
-
style: {
|
|
111
|
-
line: ['green', 'red'],
|
|
112
|
-
text: 'white',
|
|
113
|
-
baseline: 'black',
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// Append box AFTER all children are parented → 'attach' fires on chart with real dims
|
|
118
|
-
screen.append(box);
|
|
119
|
-
|
|
120
|
-
// ── Render ────────────────────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
function render(metrics: NetworkMetrics): void {
|
|
123
|
-
// ── Update chart history ─────────────────────────────────────────────
|
|
124
|
-
rxHistory.push(metrics.totalRxBytesPerSec);
|
|
125
|
-
rxHistory.shift();
|
|
126
|
-
txHistory.push(metrics.totalTxBytesPerSec);
|
|
127
|
-
txHistory.shift();
|
|
128
|
-
|
|
129
|
-
const MB = 1024 * 1024;
|
|
130
|
-
const maxVal = Math.max(...rxHistory, ...txHistory, 1024);
|
|
131
|
-
const xLabels = makeXLabels();
|
|
132
|
-
|
|
133
|
-
(chart as unknown as { options: { maxY: number } }).options.maxY = (maxVal / MB) * 1.2;
|
|
134
|
-
(chart as unknown as { setData: (d: unknown) => void }).setData([
|
|
135
|
-
{ title: '↓ RX', x: xLabels, y: rxHistory.map(v => v / MB), style: { line: 'green' } },
|
|
136
|
-
{ title: '↑ TX', x: xLabels, y: txHistory.map(v => v / MB), style: { line: 'red' } },
|
|
137
|
-
]);
|
|
138
|
-
|
|
139
|
-
// ── Update interfaces panel ──────────────────────────────────────────
|
|
140
|
-
const lines: string[] = [];
|
|
141
|
-
|
|
142
|
-
lines.push(
|
|
143
|
-
`{bold}Total{/bold}`,
|
|
144
|
-
`{green-fg}↓ ${fmtRate(metrics.totalRxBytesPerSec)}{/green-fg}`,
|
|
145
|
-
`{red-fg}↑ ${fmtRate(metrics.totalTxBytesPerSec)}{/red-fg}`,
|
|
146
|
-
'',
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
if (metrics.interfaces.length === 0) {
|
|
150
|
-
lines.push('{gray-fg}No interfaces{/gray-fg}');
|
|
151
|
-
} else {
|
|
152
|
-
for (const iface of metrics.interfaces) {
|
|
153
|
-
const state = stateTag(iface.operstate);
|
|
154
|
-
const name = truncate(iface.iface, 12);
|
|
155
|
-
const ip = truncate(iface.ip4 || iface.ip6 || '—', 15);
|
|
156
|
-
const rx = `{green-fg}↓ ${fmtRate(iface.rxBytesPerSec)}{/green-fg}`;
|
|
157
|
-
const tx = `{red-fg}↑ ${fmtRate(iface.txBytesPerSec)}{/red-fg}`;
|
|
158
|
-
const err = iface.rxErrors + iface.txErrors > 0
|
|
159
|
-
? `\n {red-fg}err:${iface.rxErrors + iface.txErrors}{/red-fg}`
|
|
160
|
-
: '';
|
|
161
|
-
lines.push(`${state} {bold}${name}{/bold}`, ` ${ip}`, ` ${rx}`, ` ${tx}${err}`, '');
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
statsBox.setContent(lines.join('\n'));
|
|
166
|
-
screen.render();
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Initial paint
|
|
170
|
-
const initial = getNetworkMetrics();
|
|
171
|
-
if (initial) {
|
|
172
|
-
render(initial);
|
|
173
|
-
} else {
|
|
174
|
-
statsBox.setContent('{gray-fg}Collecting…{/gray-fg}');
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const unsub = bus.on('metrics:network:updated', render);
|
|
178
|
-
|
|
179
|
-
function destroy(): void {
|
|
180
|
-
unsub();
|
|
181
|
-
box.destroy();
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return { box, destroy };
|
|
185
|
-
}
|