pyre-cli 3.3.0 → 3.5.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/dist/index.js +395 -187
- package/package.json +1 -1
- package/src/formatters/formatters.md +164 -0
- package/src/formatters/output.ts +15 -9
- package/src/formatters/render.ts +68 -36
- package/src/history.ts +6 -0
- package/src/index.ts +2 -0
- package/src/live/input.ts +80 -57
- package/src/live/live.md +282 -0
- package/src/live/render.ts +182 -68
- package/src/live/state.ts +22 -1
- package/src/live/types.ts +1 -0
- package/src/monitors/collectors.ts +126 -97
- package/src/monitors/monitors.md +165 -0
- package/src/monitors/run.ts +7 -3
- package/src/monitors/smc.ts +1 -1
- package/src/p2p/p2p.md +284 -0
- package/src/rest.md +129 -0
- package/src/sparkline.ts +8 -2
- package/src/ux.ts +62 -0
package/src/live/render.ts
CHANGED
|
@@ -8,22 +8,67 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import { formatTable, formatGraphs, gridColumns, THEMES, type ThemeName, panel } from '../formatters/index.js';
|
|
11
|
-
import { state, setStatus, SIGNAL_OPTIONS } from './state.js';
|
|
11
|
+
import { state, setStatus, SIGNAL_OPTIONS, getToggleKey } from './state.js';
|
|
12
12
|
import type { StatsData } from '../monitors/index.js';
|
|
13
13
|
|
|
14
|
+
// --- render caching -----------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
let _cachedTable = '';
|
|
17
|
+
let _cachedTableData: StatsData | null = null;
|
|
18
|
+
let _cachedTableParams = '';
|
|
19
|
+
let _tableDataDirty = false;
|
|
20
|
+
|
|
21
|
+
function invalidateTableCache() {
|
|
22
|
+
_tableDataDirty = true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let _cachedGraphs = '';
|
|
26
|
+
let _cachedGraphsVersion = -1;
|
|
27
|
+
let _cachedGraphsParams = '';
|
|
28
|
+
|
|
29
|
+
function tableCacheParams(): string {
|
|
30
|
+
const limit = processRowBudget();
|
|
31
|
+
return [
|
|
32
|
+
state.sortMode,
|
|
33
|
+
state.processFilter || '',
|
|
34
|
+
limit,
|
|
35
|
+
state.currentTheme,
|
|
36
|
+
state.activePanel,
|
|
37
|
+
state.treeView ? '1' : '0',
|
|
38
|
+
JSON.stringify(state.visiblePanels),
|
|
39
|
+
].join('|');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function graphsCacheParams(): string {
|
|
43
|
+
return [
|
|
44
|
+
state.termWidth,
|
|
45
|
+
state.currentTheme,
|
|
46
|
+
state.graphMode,
|
|
47
|
+
].join('|');
|
|
48
|
+
}
|
|
49
|
+
|
|
14
50
|
/**
|
|
15
51
|
* How many process rows we can realistically fit given
|
|
16
52
|
* current terminal height. Computed from the number of
|
|
17
53
|
* card columns and the reserved space for the header,
|
|
18
54
|
* cards, graphs, and footer.
|
|
19
55
|
*/
|
|
20
|
-
function processRowBudget(): number {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
56
|
+
function processRowBudget(): number {
|
|
57
|
+
let overlayRows = 0;
|
|
58
|
+
if (state.inputMode === 'customizer') overlayRows = state.CUSTOMIZER_OPTIONS.length + 3;
|
|
59
|
+
else if (state.inputMode === 'signal') overlayRows = 6;
|
|
60
|
+
else if (state.inputMode === 'filter' || state.inputMode === 'kill' || state.inputMode === 'p2p') overlayRows = 3;
|
|
61
|
+
|
|
62
|
+
if (state.activePanel === 'process') {
|
|
63
|
+
return Math.max(10, state.termHeight - 12 - overlayRows);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const columns = gridColumns(state.termWidth);
|
|
67
|
+
const visibleCards = Object.values(state.visiblePanels).filter(v => v !== false).length;
|
|
68
|
+
const cardRows = Math.ceil(visibleCards / columns);
|
|
69
|
+
const reserved = 4 + cardRows * 8 + 8 + (state.showGraphs ? 11 : 0) + 5 + overlayRows;
|
|
70
|
+
return Math.max(5, state.termHeight - reserved);
|
|
71
|
+
}
|
|
27
72
|
|
|
28
73
|
function p2pPanelLines(): { title: string; body: string[] } {
|
|
29
74
|
const events = state.p2pServer ? state.p2pServer.peerEventHistory : state.p2pEvents;
|
|
@@ -66,106 +111,175 @@ function renderCustomizerOverlay(): string {
|
|
|
66
111
|
lines.push(`${prefix}${opt}: ${chalk.bold.green(state.currentTheme)} [${themesList.join(', ')}]`);
|
|
67
112
|
} else if (opt === 'Graph Mode') {
|
|
68
113
|
lines.push(`${prefix}${opt}: ${chalk.bold.green(state.graphMode)} [spark, bar]`);
|
|
69
|
-
} else
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const isVisible = state.visiblePanels[key as keyof typeof state.visiblePanels] !== false;
|
|
76
|
-
const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
|
|
77
|
-
lines.push(`${prefix}${opt}: ${status}`);
|
|
78
|
-
}
|
|
114
|
+
} else {
|
|
115
|
+
const toggleKey = getToggleKey(opt);
|
|
116
|
+
const isVisible = toggleKey ? (toggleKey === 'tree' ? state.treeView : state.visiblePanels[toggleKey] !== false) : true;
|
|
117
|
+
const status = isVisible ? chalk.green('[VISIBLE]') : chalk.red('[HIDDEN]');
|
|
118
|
+
lines.push(`${prefix}${opt}: ${status}`);
|
|
119
|
+
}
|
|
79
120
|
});
|
|
80
121
|
|
|
81
122
|
return lines.map(l => ` ${l}`).join('\n');
|
|
82
123
|
}
|
|
83
124
|
|
|
125
|
+
// --- frame diffing -------------------------------------------------------
|
|
126
|
+
// Instead of erasing the whole screen (`\x1b[2J`) and repainting everything
|
|
127
|
+
// on every keypress/tick, we keep the previous frame's lines around and
|
|
128
|
+
// only move the cursor + rewrite the lines that actually changed. A full
|
|
129
|
+
// erase forces a scrollback/display invalidation in many terminals
|
|
130
|
+
// (iTerm2, Windows Terminal, tmux, various SSH clients), which is what
|
|
131
|
+
// made menu navigation and tab switches feel like they took a second or
|
|
132
|
+
// two even though the underlying data was already cached.
|
|
133
|
+
let _prevFrameLines: string[] = [];
|
|
134
|
+
let _frameDirty = true; // force full paint on first render / resize
|
|
135
|
+
|
|
136
|
+
function invalidateFrame() {
|
|
137
|
+
_frameDirty = true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function writeFrame(lines: string[]) {
|
|
141
|
+
if (_frameDirty || _prevFrameLines.length === 0 || lines.length !== _prevFrameLines.length) {
|
|
142
|
+
process.stdout.write('\x1b[?25l\x1b[H' + lines.map(l => `\x1b[2K${l}`).join('\r\n') + '\x1b[0J');
|
|
143
|
+
_prevFrameLines = lines;
|
|
144
|
+
_frameDirty = false;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Incremental repaint: move to each changed line and rewrite only
|
|
149
|
+
// that line, clearing to end-of-line first so shorter replacement
|
|
150
|
+
// text doesn't leave stale characters behind.
|
|
151
|
+
let out = '';
|
|
152
|
+
for (let i = 0; i < lines.length; i++) {
|
|
153
|
+
if (lines[i] !== _prevFrameLines[i]) {
|
|
154
|
+
out += `\x1b[${i + 1};1H\x1b[2K${lines[i]}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (out) process.stdout.write(out);
|
|
158
|
+
_prevFrameLines = lines;
|
|
159
|
+
}
|
|
160
|
+
|
|
84
161
|
function render() {
|
|
85
162
|
if (!state.lastData) return;
|
|
86
163
|
const lines: string[] = [];
|
|
87
164
|
const theme = THEMES[state.currentTheme] || THEMES.default;
|
|
88
165
|
|
|
166
|
+
const params = tableCacheParams();
|
|
167
|
+
const tableStr = _tableDataDirty || state.lastData !== _cachedTableData || params !== _cachedTableParams
|
|
168
|
+
? (() => {
|
|
169
|
+
const out = formatTable(state.lastData, {
|
|
170
|
+
width: state.termWidth,
|
|
171
|
+
sortBy: state.sortMode,
|
|
172
|
+
filter: state.processFilter || undefined,
|
|
173
|
+
processLimit: processRowBudget(),
|
|
174
|
+
theme: state.currentTheme,
|
|
175
|
+
visible: state.visiblePanels,
|
|
176
|
+
treeView: state.treeView,
|
|
177
|
+
activePanel: state.activePanel,
|
|
178
|
+
});
|
|
179
|
+
_cachedTableData = state.lastData;
|
|
180
|
+
_cachedTableParams = params;
|
|
181
|
+
_cachedTable = out;
|
|
182
|
+
_tableDataDirty = false;
|
|
183
|
+
return out;
|
|
184
|
+
})()
|
|
185
|
+
: _cachedTable;
|
|
186
|
+
|
|
187
|
+
const bodyLines: string[] = [];
|
|
89
188
|
if (state.activePanel === 'p2p') {
|
|
90
189
|
const panelLines = p2pPanelLines();
|
|
91
|
-
|
|
190
|
+
bodyLines.push(...panel(panelLines.title, panelLines.body, state.termWidth, theme.process, theme.border));
|
|
92
191
|
} else {
|
|
93
|
-
|
|
94
|
-
formatTable(state.lastData, {
|
|
95
|
-
width: state.termWidth,
|
|
96
|
-
sortBy: state.sortMode,
|
|
97
|
-
filter: state.processFilter || undefined,
|
|
98
|
-
processLimit: processRowBudget(),
|
|
99
|
-
theme: state.currentTheme,
|
|
100
|
-
visible: state.visiblePanels,
|
|
101
|
-
treeView: state.treeView,
|
|
102
|
-
activePanel: state.activePanel,
|
|
103
|
-
})
|
|
104
|
-
);
|
|
192
|
+
bodyLines.push(...tableStr.split('\n'));
|
|
105
193
|
}
|
|
106
194
|
|
|
195
|
+
const graphsParams = graphsCacheParams();
|
|
196
|
+
const graphsStr =
|
|
197
|
+
state.history.version !== _cachedGraphsVersion || graphsParams !== _cachedGraphsParams
|
|
198
|
+
? (() => {
|
|
199
|
+
const out = formatGraphs(state.history, state.termWidth, state.currentTheme, state.graphMode);
|
|
200
|
+
_cachedGraphsVersion = state.history.version;
|
|
201
|
+
_cachedGraphsParams = graphsParams;
|
|
202
|
+
_cachedGraphs = out;
|
|
203
|
+
return out;
|
|
204
|
+
})()
|
|
205
|
+
: _cachedGraphs;
|
|
206
|
+
|
|
107
207
|
if (state.showGraphs && state.activePanel !== 'p2p') {
|
|
108
|
-
|
|
109
|
-
|
|
208
|
+
bodyLines.push('');
|
|
209
|
+
bodyLines.push(...graphsStr.split('\n'));
|
|
110
210
|
}
|
|
111
211
|
|
|
112
|
-
|
|
113
|
-
|
|
212
|
+
const footerLines: string[] = [];
|
|
213
|
+
footerLines.push('');
|
|
214
|
+
footerLines.push(...footerLine().split('\n'));
|
|
114
215
|
|
|
115
216
|
if (state.inputMode === 'customizer') {
|
|
116
|
-
|
|
217
|
+
footerLines.push(...renderCustomizerOverlay().split('\n'));
|
|
117
218
|
} else if (state.inputMode === 'filter') {
|
|
118
|
-
|
|
219
|
+
footerLines.push(chalk.cyan(` Filter processes: ${state.inputBuffer}_`));
|
|
119
220
|
} else if (state.inputMode === 'kill') {
|
|
120
|
-
|
|
221
|
+
footerLines.push(chalk.cyan(` Kill PID: ${state.inputBuffer}_ (enter to confirm, esc to cancel)`));
|
|
121
222
|
} else if (state.inputMode === 'signal') {
|
|
122
223
|
const sigIdx = state.SIGNAL_OPTIONS.indexOf(state.inputBuffer as typeof state.SIGNAL_OPTIONS[number]);
|
|
123
224
|
const sigList = state.SIGNAL_OPTIONS.map((s, i) => i === sigIdx ? chalk.yellow(s) : chalk.dim(s)).join(' ');
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
225
|
+
footerLines.push(chalk.cyan(` Signal: ${state.inputBuffer}_`));
|
|
226
|
+
footerLines.push(chalk.dim(` ${sigList}`));
|
|
227
|
+
footerLines.push(chalk.dim(' ↑/↓ to select, Enter to confirm, Esc to cancel'));
|
|
127
228
|
} else if (state.inputMode === 'p2p') {
|
|
128
|
-
|
|
229
|
+
footerLines.push(chalk.cyan(` P2P password (${state.p2pPort}): ${state.inputBuffer}_ (enter to start, esc to cancel)`));
|
|
129
230
|
} else if (state.statusMessage) {
|
|
130
|
-
|
|
131
|
-
|
|
231
|
+
footerLines.push(` ${state.statusMessage}`);
|
|
232
|
+
} else {
|
|
233
|
+
footerLines.push('');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const maxBodyLines = Math.max(1, state.termHeight - footerLines.length);
|
|
237
|
+
if (bodyLines.length > maxBodyLines) {
|
|
238
|
+
bodyLines.length = maxBodyLines;
|
|
239
|
+
}
|
|
132
240
|
|
|
133
|
-
|
|
134
|
-
|
|
241
|
+
const frameLines = [...bodyLines, ...footerLines];
|
|
242
|
+
if (frameLines.length > state.termHeight) {
|
|
243
|
+
frameLines.length = state.termHeight;
|
|
244
|
+
}
|
|
245
|
+
writeFrame(frameLines);
|
|
135
246
|
}
|
|
136
247
|
|
|
137
248
|
function footerLine(): string {
|
|
138
|
-
const
|
|
249
|
+
const row1: [string, string][] = [
|
|
139
250
|
['q', 'quit'],
|
|
140
251
|
['c', 'customize UI'],
|
|
252
|
+
['←/→', 'tab'],
|
|
253
|
+
['1-9', 'panels'],
|
|
254
|
+
['g', state.showGraphs ? 'hide graphs' : 'show graphs'],
|
|
255
|
+
['b', `graph:${state.graphMode}`],
|
|
256
|
+
['t', state.treeView ? 'flat' : 'tree'],
|
|
257
|
+
['d', state.detailed ? 'basic' : 'detailed'],
|
|
258
|
+
['p', state.paused ? 'resume' : 'pause'],
|
|
259
|
+
];
|
|
260
|
+
const row2: [string, string][] = [
|
|
261
|
+
['s', `sort:${state.sortMode}`],
|
|
262
|
+
['/', 'filter'],
|
|
263
|
+
['k', 'kill'],
|
|
264
|
+
['S', 'signal'],
|
|
265
|
+
['e', 'export'],
|
|
266
|
+
['l', state.logging ? 'stop log' : 'log'],
|
|
267
|
+
['f', state.exportFormat],
|
|
268
|
+
['+/-', `${state.interval}s`],
|
|
141
269
|
['r', state.p2pServerRunning ? 'stop p2p' : 'start p2p'],
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
['←/→', 'tab'],
|
|
147
|
-
['1-9', 'panels'],
|
|
148
|
-
['s', `sort:${state.sortMode}`],
|
|
149
|
-
['/', 'filter'],
|
|
150
|
-
['k', 'kill'],
|
|
151
|
-
['S', 'signal'],
|
|
152
|
-
['t', state.treeView ? 'flat' : 'tree'],
|
|
153
|
-
['e', 'export'],
|
|
154
|
-
['l', state.logging ? 'stop log' : 'log'],
|
|
155
|
-
['f', state.exportFormat],
|
|
156
|
-
['+/-', `${state.interval}s`],
|
|
157
|
-
];
|
|
158
|
-
const keyStr = keys.map(([k, label]) => `${chalk.hex('#50fa7b').bold(k)} ${chalk.dim(label)}`).join(chalk.dim(' | '));
|
|
159
|
-
|
|
270
|
+
];
|
|
271
|
+
const fmt = (list: [string, string][]) => list.map(([k, label]) => `${chalk.hex('#50fa7b').bold(k)} ${chalk.dim(label)}`).join(chalk.dim(' | '));
|
|
272
|
+
const str1 = fmt(row1);
|
|
273
|
+
const str2 = fmt(row2);
|
|
160
274
|
|
|
161
275
|
const badges: string[] = [];
|
|
162
276
|
if (state.paused) badges.push(chalk.yellow.bold('⏸ PAUSED'));
|
|
163
277
|
if (state.logging) badges.push(chalk.red.bold('● REC'));
|
|
164
278
|
if (state.activePanel !== 'grid') badges.push(chalk.cyan.bold(`◉ ${state.activePanel.toUpperCase()}`));
|
|
165
|
-
|
|
279
|
+
if (state.p2pServerRunning) badges.push(chalk.green.bold(`P2P ${state.p2pBind || '0.0.0.0'}:${state.p2pPort}`));
|
|
166
280
|
const badgeStr = badges.join(' ');
|
|
167
281
|
|
|
168
|
-
return badgeStr ? `${
|
|
282
|
+
return badgeStr ? `${str1}\n${str2} ${badgeStr}` : `${str1}\n${str2}`;
|
|
169
283
|
}
|
|
170
284
|
|
|
171
285
|
function checkAlerts(data: StatsData) {
|
|
@@ -181,4 +295,4 @@ function checkAlerts(data: StatsData) {
|
|
|
181
295
|
}
|
|
182
296
|
}
|
|
183
297
|
|
|
184
|
-
export { render, footerLine, renderCustomizerOverlay, checkAlerts, processRowBudget };
|
|
298
|
+
export { render, footerLine, renderCustomizerOverlay, checkAlerts, processRowBudget, invalidateTableCache, invalidateFrame };
|
package/src/live/state.ts
CHANGED
|
@@ -16,6 +16,7 @@ export const SIGNAL_OPTIONS = ['SIGTERM', 'SIGKILL', 'SIGINT', 'SIGHUP', 'SIGSTO
|
|
|
16
16
|
|
|
17
17
|
const state = {
|
|
18
18
|
intervalHandle: null as NodeJS.Timeout | null,
|
|
19
|
+
uiIntervalHandle: null as NodeJS.Timeout | null,
|
|
19
20
|
running: false,
|
|
20
21
|
paused: false,
|
|
21
22
|
detailed: false,
|
|
@@ -53,6 +54,8 @@ const state = {
|
|
|
53
54
|
processFilter: '',
|
|
54
55
|
inputMode: null as InputMode,
|
|
55
56
|
inputBuffer: '',
|
|
57
|
+
targetPid: '',
|
|
58
|
+
selectedSignal: 'SIGTERM' as typeof SIGNAL_OPTIONS[number],
|
|
56
59
|
treeView: false,
|
|
57
60
|
SIGNAL_OPTIONS,
|
|
58
61
|
customizerIndex: 0,
|
|
@@ -107,4 +110,22 @@ function setStatus(msg: string, ms = 3000) {
|
|
|
107
110
|
}, ms);
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
|
|
113
|
+
function getToggleKey(opt: string): keyof VisibleItems | null {
|
|
114
|
+
const map: Record<string, keyof VisibleItems> = {
|
|
115
|
+
'Toggle CPU': 'cpu',
|
|
116
|
+
'Toggle Memory': 'mem',
|
|
117
|
+
'Toggle GPU': 'gpu',
|
|
118
|
+
'Toggle Power': 'power',
|
|
119
|
+
'Toggle Battery': 'battery',
|
|
120
|
+
'Toggle Thermal': 'thermal',
|
|
121
|
+
'Toggle Network': 'network',
|
|
122
|
+
'Toggle Packets': 'packets',
|
|
123
|
+
'Toggle Tasks': 'tasks',
|
|
124
|
+
'Toggle Disk': 'disk',
|
|
125
|
+
'Toggle Processes': 'process',
|
|
126
|
+
'Toggle Tree View': 'tree',
|
|
127
|
+
};
|
|
128
|
+
return map[opt] ?? null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { state, setStatus, getToggleKey };
|
package/src/live/types.ts
CHANGED
|
@@ -16,6 +16,52 @@ import { run } from './run.js';
|
|
|
16
16
|
import { getSmcMetrics, parseSuffix } from './smc.js';
|
|
17
17
|
import type { StatsData, CpuData, MemoryData, ThermalData, BatteryData, PowerData, DiskData, NetworkData, ProcessData, GpuData, PacketData, NetworkProcess } from './types.js';
|
|
18
18
|
|
|
19
|
+
const SP_TTL_MS = 10_000;
|
|
20
|
+
const NETSTAT_TTL_MS = 1000;
|
|
21
|
+
const ROUTE_TTL_MS = 5_000;
|
|
22
|
+
const SYSCTL_TTL_MS = 60_000;
|
|
23
|
+
|
|
24
|
+
let spGpuCache: { raw: string; ts: number } | null = null;
|
|
25
|
+
let spBatteryCache: { raw: string; ts: number } | null = null;
|
|
26
|
+
let netstatIbCache: { raw: string; ts: number } | null = null;
|
|
27
|
+
let routeCache: { iface: string; ip: string; ts: number } | null = null;
|
|
28
|
+
const sysctlCache = new Map<string, { value: string; ts: number }>();
|
|
29
|
+
|
|
30
|
+
async function cachedSysctl(key: string, fallback: string): Promise<string> {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const hit = sysctlCache.get(key);
|
|
33
|
+
if (hit && now - hit.ts < SYSCTL_TTL_MS) return hit.value;
|
|
34
|
+
const value = await run(`sysctl -n ${key}`, fallback);
|
|
35
|
+
sysctlCache.set(key, { value, ts: now });
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function cachedNetstatIb(): Promise<string> {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (netstatIbCache && now - netstatIbCache.ts < NETSTAT_TTL_MS) return netstatIbCache.raw;
|
|
42
|
+
const raw = await run('netstat -ib');
|
|
43
|
+
netstatIbCache = { raw, ts: now };
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function cachedRouteIface(): Promise<{ iface: string; ip: string }> {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
if (routeCache && now - routeCache.ts < ROUTE_TTL_MS) return routeCache;
|
|
50
|
+
const primary = (await run('route -n get default')).trim();
|
|
51
|
+
const ifaceMatch = primary.match(/interface:\s+(\S+)/);
|
|
52
|
+
const iface = ifaceMatch?.[1] || 'en0';
|
|
53
|
+
let ip = '0.0.0.0';
|
|
54
|
+
try {
|
|
55
|
+
const ifconfig = (await run(`ifconfig ${iface}`)).trim();
|
|
56
|
+
const ipMatch = ifconfig.match(/inet\s+([\d.]+)\s+netmask/);
|
|
57
|
+
if (ipMatch) ip = ipMatch[1];
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore
|
|
60
|
+
}
|
|
61
|
+
routeCache = { iface, ip, ts: now };
|
|
62
|
+
return routeCache;
|
|
63
|
+
}
|
|
64
|
+
|
|
19
65
|
// Retain samples over a *time* window rather than a fixed tick count. At the
|
|
20
66
|
// default 2s refresh interval the old 10-sample window only spanned ~20s,
|
|
21
67
|
// but battery level only moves in whole-percent steps that typically take
|
|
@@ -178,58 +224,61 @@ export async function collectPower(): Promise<PowerData | null> {
|
|
|
178
224
|
* Returns null when GPU data is unavailable (e.g. on machines
|
|
179
225
|
* without a discrete GPU or when powermetrics is inaccessible).
|
|
180
226
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
227
|
+
export async function collectGpu(detailed?: boolean): Promise<GpuData | null> {
|
|
228
|
+
let spRaw = '';
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
if (spGpuCache && now - spGpuCache.ts < SP_TTL_MS) {
|
|
231
|
+
spRaw = spGpuCache.raw;
|
|
232
|
+
} else {
|
|
233
|
+
spRaw = (await run('system_profiler SPDisplaysDataType 2>/dev/null')).trim();
|
|
234
|
+
spGpuCache = { raw: spRaw, ts: now };
|
|
235
|
+
}
|
|
236
|
+
if (!spRaw) return null;
|
|
237
|
+
|
|
238
|
+
const modelMatch = spRaw.match(/Chipset Model:\s*(.+)/i);
|
|
239
|
+
const model = modelMatch ? modelMatch[1].trim() : 'Unknown';
|
|
240
|
+
|
|
241
|
+
const memMatch = spRaw.match(/VRAM \(Total\):\s*(.+)/i);
|
|
242
|
+
let memory = 0;
|
|
243
|
+
if (memMatch) {
|
|
244
|
+
const memStr = memMatch[1].trim();
|
|
245
|
+
const memNum = parseFloat(memStr);
|
|
246
|
+
if (memStr.includes('GB')) memory = memNum * 1024 * 1024 * 1024;
|
|
247
|
+
else if (memStr.includes('MB')) memory = memNum * 1024 * 1024;
|
|
248
|
+
}
|
|
197
249
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
250
|
+
let utilization = 0;
|
|
251
|
+
let temperature: number | undefined;
|
|
252
|
+
let gpuProcesses = 0;
|
|
201
253
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
254
|
+
if (detailed) {
|
|
255
|
+
try {
|
|
256
|
+
const pm = (await run('sudo -n powermetrics --samplers gpu_power -n 1 --format text 2>/dev/null', '')).trim();
|
|
257
|
+
const gpuPowerMatch = pm.match(/GPU Power:\s*([\d.]+)\s*mW/i);
|
|
258
|
+
if (gpuPowerMatch) {
|
|
259
|
+
utilization = Math.min(100, Math.round(parseFloat(gpuPowerMatch[1]) / 10));
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
// ignore
|
|
263
|
+
}
|
|
212
264
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
265
|
+
try {
|
|
266
|
+
const { temps } = await getSmcMetrics();
|
|
267
|
+
if (temps.gpu_die !== undefined) temperature = temps.gpu_die;
|
|
268
|
+
} catch {
|
|
269
|
+
// ignore
|
|
270
|
+
}
|
|
219
271
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
272
|
+
try {
|
|
273
|
+
const psRaw = (await run('ps aux | grep -i "[g]pu" | wc -l')).trim();
|
|
274
|
+
gpuProcesses = parseInt(psRaw) || 0;
|
|
275
|
+
} catch {
|
|
276
|
+
// ignore
|
|
277
|
+
}
|
|
278
|
+
}
|
|
227
279
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
280
|
+
return { model, memory, utilization, temperature, processes: gpuProcesses };
|
|
281
|
+
}
|
|
233
282
|
|
|
234
283
|
async function collectSystem(): Promise<{ hostname: string; os: string; uptime: string }> {
|
|
235
284
|
const hostname = (await run('hostname', 'Mac')).trim();
|
|
@@ -244,28 +293,21 @@ async function collectSystem(): Promise<{ hostname: string; os: string; uptime:
|
|
|
244
293
|
}
|
|
245
294
|
|
|
246
295
|
export async function collectCpu(): Promise<CpuData> {
|
|
247
|
-
const brand = (await
|
|
248
|
-
const cores = parseInt(
|
|
249
|
-
const physicalCores = parseInt(
|
|
250
|
-
const frequencyHz = parseInt(
|
|
296
|
+
const brand = (await cachedSysctl('machdep.cpu.brand_string', 'Unknown CPU')).trim();
|
|
297
|
+
const cores = parseInt(await cachedSysctl('hw.ncpu', '1')) || 1;
|
|
298
|
+
const physicalCores = parseInt(await cachedSysctl('hw.physicalcpu', String(cores))) || cores;
|
|
299
|
+
const frequencyHz = parseInt(await cachedSysctl('hw.cpufrequency', '0'));
|
|
251
300
|
let frequency = frequencyHz > 0 ? Math.round(frequencyHz / 1_000_000) : 0;
|
|
252
301
|
|
|
253
302
|
if (frequency === 0) {
|
|
254
|
-
// hw.cpufrequency is an Intel-era sysctl. On Apple Silicon it's either
|
|
255
|
-
// absent or reads back 0, so frequency was silently stuck at 0 MHz on
|
|
256
|
-
// every M-series Mac. powermetrics reports the actual active clock speed
|
|
257
|
-
// per core cluster (E/P-cluster) instead — reuse the same cached call
|
|
258
|
-
// already used for temperature/power rather than shelling out again.
|
|
259
303
|
try {
|
|
260
304
|
const { freq } = await getSmcMetrics();
|
|
261
305
|
const values = Object.values(freq);
|
|
262
306
|
if (values.length) {
|
|
263
|
-
// powermetrics reports in Hz, system profiler reports in MHz.
|
|
264
|
-
// We need MHz for the display.
|
|
265
307
|
frequency = Math.round(Math.max(...values) / 1_000_000);
|
|
266
308
|
}
|
|
267
309
|
} catch {
|
|
268
|
-
// ignore — frequency stays 0
|
|
310
|
+
// ignore — frequency stays 0
|
|
269
311
|
}
|
|
270
312
|
}
|
|
271
313
|
|
|
@@ -300,8 +342,8 @@ export async function collectCpu(): Promise<CpuData> {
|
|
|
300
342
|
}
|
|
301
343
|
|
|
302
344
|
export async function collectMemory(): Promise<MemoryData> {
|
|
303
|
-
const totalBytes = parseInt(
|
|
304
|
-
const pageSize = parseInt(
|
|
345
|
+
const totalBytes = parseInt(await cachedSysctl('hw.memsize', '0')) || 0;
|
|
346
|
+
const pageSize = parseInt(await cachedSysctl('hw.pagesize', '4096')) || 4096;
|
|
305
347
|
|
|
306
348
|
const vmStat = (await run('vm_stat')).trim();
|
|
307
349
|
const getPageCount = (label: string) => {
|
|
@@ -390,7 +432,14 @@ export async function collectBattery(): Promise<BatteryData | null> {
|
|
|
390
432
|
let maxCapacityPercent: number | undefined;
|
|
391
433
|
|
|
392
434
|
try {
|
|
393
|
-
const
|
|
435
|
+
const now = Date.now();
|
|
436
|
+
let battInfo: string;
|
|
437
|
+
if (spBatteryCache && now - spBatteryCache.ts < SP_TTL_MS) {
|
|
438
|
+
battInfo = spBatteryCache.raw;
|
|
439
|
+
} else {
|
|
440
|
+
battInfo = (await run('system_profiler SPPowerDataType 2>/dev/null')).trim();
|
|
441
|
+
spBatteryCache = { raw: battInfo, ts: now };
|
|
442
|
+
}
|
|
394
443
|
|
|
395
444
|
const cycleMatch = battInfo.match(/Cycle Count:\s+(\d+)/);
|
|
396
445
|
if (cycleMatch) cycles = parseInt(cycleMatch[1]);
|
|
@@ -530,20 +579,8 @@ export async function collectThermal(detailed?: boolean): Promise<ThermalData> {
|
|
|
530
579
|
|
|
531
580
|
export async function collectNetwork(): Promise<NetworkData> {
|
|
532
581
|
try {
|
|
533
|
-
const
|
|
534
|
-
const
|
|
535
|
-
const iface = ifaceMatch?.[1] || 'en0';
|
|
536
|
-
|
|
537
|
-
let ip = '0.0.0.0';
|
|
538
|
-
try {
|
|
539
|
-
const ifconfig = (await run(`ifconfig ${iface}`)).trim();
|
|
540
|
-
const ipMatch = ifconfig.match(/inet\s+([\d.]+)\s+netmask/);
|
|
541
|
-
if (ipMatch) ip = ipMatch[1];
|
|
542
|
-
} catch {
|
|
543
|
-
// ignore
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
const netStat = (await run('netstat -ib')).trim();
|
|
582
|
+
const { iface, ip } = await cachedRouteIface();
|
|
583
|
+
const netStat = await cachedNetstatIb();
|
|
547
584
|
let rxBytes = 0;
|
|
548
585
|
let txBytes = 0;
|
|
549
586
|
let rxPackets = 0;
|
|
@@ -590,7 +627,7 @@ export async function collectProcesses(limit?: number): Promise<ProcessData[]> {
|
|
|
590
627
|
|
|
591
628
|
export async function collectPackets(): Promise<PacketData | null> {
|
|
592
629
|
try {
|
|
593
|
-
const netStat =
|
|
630
|
+
const netStat = await cachedNetstatIb();
|
|
594
631
|
const lines = netStat.split('\n').slice(1);
|
|
595
632
|
let totalRxPackets = 0;
|
|
596
633
|
let totalTxPackets = 0;
|
|
@@ -626,26 +663,18 @@ export async function collectPackets(): Promise<PacketData | null> {
|
|
|
626
663
|
|
|
627
664
|
let topProcesses: { pid: number; command: string; rxBytes: number; txBytes: number }[] = [];
|
|
628
665
|
let allProcesses: { pid: number; command: string; rxBytes: number; txBytes: number; rxPackets?: number; txPackets?: number }[] = [];
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
const allLsofLines = allLsof.split('\n');
|
|
642
|
-
allProcesses = allLsofLines.map(line => {
|
|
643
|
-
const parts = line.trim().split(/\s+/);
|
|
644
|
-
if (parts.length >= 3) {
|
|
645
|
-
return { pid: parseInt(parts[2]) || 0, command: parts[1], rxBytes: 0, txBytes: parseInt(parts[0]) || 0 };
|
|
646
|
-
}
|
|
647
|
-
return { pid: 0, command: '', rxBytes: 0, txBytes: 0 };
|
|
648
|
-
}).filter(p => p.pid > 0);
|
|
666
|
+
try {
|
|
667
|
+
const lsof = (await run('lsof -i -n -P 2>/dev/null | tail -n +2 | awk \'{print $1, $2}\' | sort | uniq -c | sort -rn', '')).trim();
|
|
668
|
+
const lsofLines = lsof.split('\n');
|
|
669
|
+
topProcesses = lsofLines.map(line => {
|
|
670
|
+
const parts = line.trim().split(/\s+/);
|
|
671
|
+
if (parts.length >= 3) {
|
|
672
|
+
return { pid: parseInt(parts[2]) || 0, command: parts[1], rxBytes: 0, txBytes: parseInt(parts[0]) || 0 };
|
|
673
|
+
}
|
|
674
|
+
return { pid: 0, command: '', rxBytes: 0, txBytes: 0 };
|
|
675
|
+
}).filter(p => p.pid > 0);
|
|
676
|
+
|
|
677
|
+
allProcesses = topProcesses;
|
|
649
678
|
} catch {
|
|
650
679
|
topProcesses = [];
|
|
651
680
|
allProcesses = [];
|