@appkit/llamacpp-cli 1.5.0 → 1.6.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/CHANGELOG.md +13 -0
- package/MONITORING-ACCURACY-FIX.md +199 -0
- package/PER-PROCESS-METRICS.md +190 -0
- package/README.md +57 -8
- package/dist/cli.js +9 -6
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +12 -3
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/monitor.d.ts.map +1 -1
- package/dist/commands/monitor.js +51 -1
- package/dist/commands/monitor.js.map +1 -1
- package/dist/commands/ps.d.ts +3 -1
- package/dist/commands/ps.d.ts.map +1 -1
- package/dist/commands/ps.js +75 -5
- package/dist/commands/ps.js.map +1 -1
- package/dist/commands/server-show.d.ts.map +1 -1
- package/dist/commands/server-show.js +10 -3
- package/dist/commands/server-show.js.map +1 -1
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +14 -2
- package/dist/commands/start.js.map +1 -1
- package/dist/lib/history-manager.d.ts +46 -0
- package/dist/lib/history-manager.d.ts.map +1 -0
- package/dist/lib/history-manager.js +157 -0
- package/dist/lib/history-manager.js.map +1 -0
- package/dist/lib/metrics-aggregator.d.ts +2 -1
- package/dist/lib/metrics-aggregator.d.ts.map +1 -1
- package/dist/lib/metrics-aggregator.js +15 -4
- package/dist/lib/metrics-aggregator.js.map +1 -1
- package/dist/lib/system-collector.d.ts +9 -4
- package/dist/lib/system-collector.d.ts.map +1 -1
- package/dist/lib/system-collector.js +29 -28
- package/dist/lib/system-collector.js.map +1 -1
- package/dist/tui/HistoricalMonitorApp.d.ts +5 -0
- package/dist/tui/HistoricalMonitorApp.d.ts.map +1 -0
- package/dist/tui/HistoricalMonitorApp.js +490 -0
- package/dist/tui/HistoricalMonitorApp.js.map +1 -0
- package/dist/tui/MonitorApp.d.ts.map +1 -1
- package/dist/tui/MonitorApp.js +84 -62
- package/dist/tui/MonitorApp.js.map +1 -1
- package/dist/tui/MultiServerMonitorApp.d.ts +1 -1
- package/dist/tui/MultiServerMonitorApp.d.ts.map +1 -1
- package/dist/tui/MultiServerMonitorApp.js +293 -77
- package/dist/tui/MultiServerMonitorApp.js.map +1 -1
- package/dist/types/history-types.d.ts +30 -0
- package/dist/types/history-types.d.ts.map +1 -0
- package/dist/types/history-types.js +11 -0
- package/dist/types/history-types.js.map +1 -0
- package/dist/types/monitor-types.d.ts +1 -0
- package/dist/types/monitor-types.d.ts.map +1 -1
- package/dist/types/server-config.d.ts +1 -0
- package/dist/types/server-config.d.ts.map +1 -1
- package/dist/types/server-config.js.map +1 -1
- package/dist/utils/downsample-utils.d.ts +35 -0
- package/dist/utils/downsample-utils.d.ts.map +1 -0
- package/dist/utils/downsample-utils.js +107 -0
- package/dist/utils/downsample-utils.js.map +1 -0
- package/dist/utils/file-utils.d.ts +6 -0
- package/dist/utils/file-utils.d.ts.map +1 -1
- package/dist/utils/file-utils.js +38 -0
- package/dist/utils/file-utils.js.map +1 -1
- package/dist/utils/process-utils.d.ts +19 -1
- package/dist/utils/process-utils.d.ts.map +1 -1
- package/dist/utils/process-utils.js +79 -1
- package/dist/utils/process-utils.js.map +1 -1
- package/docs/images/.gitkeep +1 -0
- package/package.json +3 -1
- package/src/cli.ts +9 -6
- package/src/commands/create.ts +14 -4
- package/src/commands/monitor.ts +21 -1
- package/src/commands/ps.ts +88 -5
- package/src/commands/server-show.ts +10 -3
- package/src/commands/start.ts +15 -2
- package/src/lib/history-manager.ts +172 -0
- package/src/lib/metrics-aggregator.ts +18 -5
- package/src/lib/system-collector.ts +31 -28
- package/src/tui/HistoricalMonitorApp.ts +548 -0
- package/src/tui/MonitorApp.ts +89 -64
- package/src/tui/MultiServerMonitorApp.ts +348 -103
- package/src/types/history-types.ts +39 -0
- package/src/types/monitor-types.ts +1 -0
- package/src/types/server-config.ts +1 -0
- package/src/utils/downsample-utils.ts +128 -0
- package/src/utils/file-utils.ts +40 -0
- package/src/utils/process-utils.ts +85 -1
- package/test-load.sh +100 -0
- package/dist/tui/components/ErrorState.d.ts +0 -8
- package/dist/tui/components/ErrorState.d.ts.map +0 -1
- package/dist/tui/components/ErrorState.js +0 -22
- package/dist/tui/components/ErrorState.js.map +0 -1
- package/dist/tui/components/LoadingState.d.ts +0 -8
- package/dist/tui/components/LoadingState.d.ts.map +0 -1
- package/dist/tui/components/LoadingState.js +0 -21
- package/dist/tui/components/LoadingState.js.map +0 -1
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import blessed from 'blessed';
|
|
2
|
+
import * as asciichart from 'asciichart';
|
|
3
|
+
import { ServerConfig } from '../types/server-config.js';
|
|
4
|
+
import { HistoryManager } from '../lib/history-manager.js';
|
|
5
|
+
import { HistorySnapshot } from '../types/history-types.js';
|
|
6
|
+
import {
|
|
7
|
+
downsampleMaxTimeWithFullHour,
|
|
8
|
+
downsampleMeanTimeWithFullHour,
|
|
9
|
+
getDownsampleRatio,
|
|
10
|
+
TimeSeriesPoint
|
|
11
|
+
} from '../utils/downsample-utils.js';
|
|
12
|
+
|
|
13
|
+
type ViewMode = 'recent' | 'hour';
|
|
14
|
+
|
|
15
|
+
interface ChartStats {
|
|
16
|
+
avg: number;
|
|
17
|
+
max: number;
|
|
18
|
+
min: number;
|
|
19
|
+
stddev: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ChartConfig {
|
|
23
|
+
title: string;
|
|
24
|
+
color: typeof asciichart.cyan;
|
|
25
|
+
formatValue: (x: number) => string;
|
|
26
|
+
isPercentage: boolean;
|
|
27
|
+
noDataMessage: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Calculate statistics for a set of values.
|
|
32
|
+
*/
|
|
33
|
+
function calculateStats(values: number[]): ChartStats {
|
|
34
|
+
if (values.length === 0) {
|
|
35
|
+
return { avg: 0, max: 0, min: 0, stddev: 0 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
|
39
|
+
const max = Math.max(...values);
|
|
40
|
+
const min = Math.min(...values);
|
|
41
|
+
const variance = values.reduce((sum, val) => sum + Math.pow(val - avg, 2), 0) / values.length;
|
|
42
|
+
const stddev = Math.sqrt(variance);
|
|
43
|
+
|
|
44
|
+
return { avg, max, min, stddev };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Calculate expanded range for chart y-axis to prevent duplicate labels.
|
|
49
|
+
*/
|
|
50
|
+
function getExpandedRange(data: number[], isPercentage: boolean): { min: number; max: number } {
|
|
51
|
+
if (isPercentage) return { min: 0, max: 100 };
|
|
52
|
+
if (data.length === 0) return { min: 0, max: 10 };
|
|
53
|
+
|
|
54
|
+
const dataMin = Math.min(...data);
|
|
55
|
+
const dataMax = Math.max(...data);
|
|
56
|
+
const range = dataMax - dataMin;
|
|
57
|
+
const padding = Math.max(range * 0.3, 5);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
min: Math.max(0, Math.floor(dataMin - padding)),
|
|
61
|
+
max: Math.ceil(dataMax + padding)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a scrollable content box for historical charts.
|
|
67
|
+
*/
|
|
68
|
+
function createContentBox(): blessed.Widgets.BoxElement {
|
|
69
|
+
return blessed.box({
|
|
70
|
+
top: 0,
|
|
71
|
+
left: 0,
|
|
72
|
+
width: '100%',
|
|
73
|
+
height: '100%',
|
|
74
|
+
tags: true,
|
|
75
|
+
scrollable: true,
|
|
76
|
+
alwaysScroll: true,
|
|
77
|
+
keys: true,
|
|
78
|
+
vi: true,
|
|
79
|
+
mouse: true,
|
|
80
|
+
scrollbar: {
|
|
81
|
+
ch: '\u2588',
|
|
82
|
+
style: { fg: 'blue' },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Render a single chart with statistics.
|
|
89
|
+
*/
|
|
90
|
+
function renderChart(
|
|
91
|
+
values: number[],
|
|
92
|
+
rawValues: TimeSeriesPoint[],
|
|
93
|
+
config: ChartConfig,
|
|
94
|
+
chartHeight: number
|
|
95
|
+
): string {
|
|
96
|
+
let content = `{bold}${config.title}{/bold}\n`;
|
|
97
|
+
const validValues = values.filter(v => !isNaN(v) && v > 0);
|
|
98
|
+
const plotData = values.map(v => isNaN(v) ? 0 : v);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
if (validValues.length >= 2) {
|
|
102
|
+
const range = getExpandedRange(validValues, config.isPercentage);
|
|
103
|
+
content += asciichart.plot(plotData, {
|
|
104
|
+
height: chartHeight,
|
|
105
|
+
colors: [config.color],
|
|
106
|
+
format: config.formatValue,
|
|
107
|
+
min: range.min,
|
|
108
|
+
max: range.max,
|
|
109
|
+
});
|
|
110
|
+
content += '\n';
|
|
111
|
+
|
|
112
|
+
const stats = calculateStats(validValues);
|
|
113
|
+
const lastValue = rawValues[rawValues.length - 1]?.value ?? 0;
|
|
114
|
+
|
|
115
|
+
if (config.title.includes('GB')) {
|
|
116
|
+
content += ` Avg: ${stats.avg.toFixed(2)} GB (\u00b1${stats.stddev.toFixed(2)}) `;
|
|
117
|
+
content += `Max: ${stats.max.toFixed(2)} GB `;
|
|
118
|
+
content += `Min: ${stats.min.toFixed(2)} GB `;
|
|
119
|
+
content += `Last: ${lastValue.toFixed(2)} GB\n\n`;
|
|
120
|
+
} else if (config.isPercentage) {
|
|
121
|
+
content += ` Avg: ${stats.avg.toFixed(1)}% (\u00b1${stats.stddev.toFixed(1)}) `;
|
|
122
|
+
content += `Max: ${stats.max.toFixed(1)}% `;
|
|
123
|
+
content += `Min: ${stats.min.toFixed(1)}% `;
|
|
124
|
+
content += `Last: ${lastValue.toFixed(1)}%\n\n`;
|
|
125
|
+
} else {
|
|
126
|
+
content += ` Avg: ${stats.avg.toFixed(1)} tok/s (\u00b1${stats.stddev.toFixed(1)}) `;
|
|
127
|
+
content += `Max: ${stats.max.toFixed(1)} tok/s `;
|
|
128
|
+
content += `Last: ${lastValue.toFixed(1)} tok/s\n\n`;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
const defaultRange = config.isPercentage ? { min: 0, max: 100 } : { min: 0, max: 10 };
|
|
132
|
+
content += asciichart.plot(plotData, {
|
|
133
|
+
height: chartHeight,
|
|
134
|
+
colors: [config.color],
|
|
135
|
+
format: config.formatValue,
|
|
136
|
+
min: defaultRange.min,
|
|
137
|
+
max: defaultRange.max,
|
|
138
|
+
});
|
|
139
|
+
content += `\n{gray-fg} ${config.noDataMessage}{/gray-fg}\n\n`;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
content += '{red-fg} Error rendering chart{/red-fg}\n\n';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return content;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function createHistoricalUI(
|
|
149
|
+
screen: blessed.Widgets.Screen,
|
|
150
|
+
server: ServerConfig,
|
|
151
|
+
onBack: () => void
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const historyManager = new HistoryManager(server.id);
|
|
154
|
+
let refreshIntervalId: NodeJS.Timeout | null = null;
|
|
155
|
+
const REFRESH_INTERVAL = 1000;
|
|
156
|
+
let lastGoodRender: string | null = null;
|
|
157
|
+
let consecutiveErrors = 0;
|
|
158
|
+
let viewMode: ViewMode = 'recent';
|
|
159
|
+
|
|
160
|
+
const contentBox = createContentBox();
|
|
161
|
+
screen.append(contentBox);
|
|
162
|
+
|
|
163
|
+
// Chart configurations
|
|
164
|
+
const chartConfigs: Record<string, ChartConfig> = {
|
|
165
|
+
tokenSpeed: {
|
|
166
|
+
title: 'Model Token Generation Speed (tok/s)',
|
|
167
|
+
color: asciichart.cyan,
|
|
168
|
+
formatValue: (x: number) => Math.round(x).toFixed(0).padStart(6, ' '),
|
|
169
|
+
isPercentage: false,
|
|
170
|
+
noDataMessage: 'No generation activity in this time window',
|
|
171
|
+
},
|
|
172
|
+
cpu: {
|
|
173
|
+
title: 'Model CPU Usage (%)',
|
|
174
|
+
color: asciichart.blue,
|
|
175
|
+
formatValue: (x: number) => Math.round(x).toFixed(0).padStart(6, ' '),
|
|
176
|
+
isPercentage: true,
|
|
177
|
+
noDataMessage: 'No CPU data in this time window',
|
|
178
|
+
},
|
|
179
|
+
memory: {
|
|
180
|
+
title: 'Model Memory Usage (GB)',
|
|
181
|
+
color: asciichart.magenta,
|
|
182
|
+
formatValue: (x: number) => x.toFixed(2).padStart(6, ' '),
|
|
183
|
+
isPercentage: false,
|
|
184
|
+
noDataMessage: 'No memory data in this time window',
|
|
185
|
+
},
|
|
186
|
+
gpu: {
|
|
187
|
+
title: 'System GPU Usage (%)',
|
|
188
|
+
color: asciichart.green,
|
|
189
|
+
formatValue: (x: number) => Math.round(x).toFixed(0).padStart(6, ' '),
|
|
190
|
+
isPercentage: true,
|
|
191
|
+
noDataMessage: 'No GPU data in this time window',
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
async function render(): Promise<void> {
|
|
196
|
+
try {
|
|
197
|
+
const termWidth = (screen.width as number) || 80;
|
|
198
|
+
const divider = '\u2500'.repeat(termWidth - 2);
|
|
199
|
+
const chartHeight = 5;
|
|
200
|
+
const chartWidth = Math.min(Math.max(termWidth - 20, 40), 80);
|
|
201
|
+
|
|
202
|
+
if (chartWidth <= 0 || !Number.isFinite(chartWidth)) {
|
|
203
|
+
contentBox.setContent('{red-fg}Error: Invalid chart width{/red-fg}\n');
|
|
204
|
+
screen.render();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Header
|
|
209
|
+
const modeLabel = viewMode === 'recent' ? 'Minute' : 'Hour';
|
|
210
|
+
const modeColor = viewMode === 'recent' ? 'cyan' : 'magenta';
|
|
211
|
+
let content = `{bold}{blue-fg}\u2550\u2550\u2550 ${server.modelName} (${server.port}) {/blue-fg} `;
|
|
212
|
+
content += `{${modeColor}-fg}[${modeLabel}]{/${modeColor}-fg}{/bold}\n\n`;
|
|
213
|
+
|
|
214
|
+
const snapshots = await historyManager.loadHistoryByWindow('1h');
|
|
215
|
+
|
|
216
|
+
if (snapshots.length === 0) {
|
|
217
|
+
content += '{yellow-fg}No historical data available.{/yellow-fg}\n\n';
|
|
218
|
+
content += 'Historical data is collected when you run the monitor command.\n';
|
|
219
|
+
content += 'Start monitoring to begin collecting history.\n\n';
|
|
220
|
+
content += divider + '\n';
|
|
221
|
+
content += '{gray-fg}ESC = Back Q = Quit{/gray-fg}';
|
|
222
|
+
contentBox.setContent(content);
|
|
223
|
+
screen.render();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const maxChartPoints = Math.min(chartWidth, 80);
|
|
228
|
+
const displaySnapshots = viewMode === 'recent' && snapshots.length > maxChartPoints
|
|
229
|
+
? snapshots.slice(-maxChartPoints)
|
|
230
|
+
: snapshots;
|
|
231
|
+
|
|
232
|
+
// Extract time-series data
|
|
233
|
+
const rawData = {
|
|
234
|
+
tokenSpeed: [] as TimeSeriesPoint[],
|
|
235
|
+
gpu: [] as TimeSeriesPoint[],
|
|
236
|
+
cpu: [] as TimeSeriesPoint[],
|
|
237
|
+
memory: [] as TimeSeriesPoint[],
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
for (const snapshot of displaySnapshots) {
|
|
241
|
+
const ts = snapshot.timestamp;
|
|
242
|
+
rawData.tokenSpeed.push({ timestamp: ts, value: snapshot.server.avgGenerateSpeed || 0 });
|
|
243
|
+
rawData.gpu.push({ timestamp: ts, value: snapshot.system?.gpuUsage || 0 });
|
|
244
|
+
rawData.cpu.push({ timestamp: ts, value: snapshot.server.processCpuUsage || 0 });
|
|
245
|
+
rawData.memory.push({
|
|
246
|
+
timestamp: ts,
|
|
247
|
+
value: snapshot.server.processMemory ? snapshot.server.processMemory / (1024 ** 3) : 0
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Apply downsampling based on view mode
|
|
252
|
+
const useDownsampling = viewMode === 'hour';
|
|
253
|
+
const values = {
|
|
254
|
+
tokenSpeed: useDownsampling
|
|
255
|
+
? downsampleMaxTimeWithFullHour(rawData.tokenSpeed, maxChartPoints)
|
|
256
|
+
: rawData.tokenSpeed.map(p => p.value),
|
|
257
|
+
cpu: useDownsampling
|
|
258
|
+
? downsampleMaxTimeWithFullHour(rawData.cpu, maxChartPoints)
|
|
259
|
+
: rawData.cpu.map(p => p.value),
|
|
260
|
+
memory: useDownsampling
|
|
261
|
+
? downsampleMeanTimeWithFullHour(rawData.memory, maxChartPoints)
|
|
262
|
+
: rawData.memory.map(p => p.value),
|
|
263
|
+
gpu: useDownsampling
|
|
264
|
+
? downsampleMaxTimeWithFullHour(rawData.gpu, maxChartPoints)
|
|
265
|
+
: rawData.gpu.map(p => p.value),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Render all charts
|
|
269
|
+
content += renderChart(values.tokenSpeed, rawData.tokenSpeed, chartConfigs.tokenSpeed, chartHeight);
|
|
270
|
+
content += renderChart(values.cpu, rawData.cpu, chartConfigs.cpu, chartHeight);
|
|
271
|
+
content += renderChart(values.memory, rawData.memory, chartConfigs.memory, chartHeight);
|
|
272
|
+
content += renderChart(values.gpu, rawData.gpu, chartConfigs.gpu, chartHeight);
|
|
273
|
+
|
|
274
|
+
// Footer
|
|
275
|
+
content += divider + '\n';
|
|
276
|
+
content += `{gray-fg}Updated: ${new Date().toLocaleTimeString()} | H = Toggle Hour View ESC = Back Q = Quit{/gray-fg}`;
|
|
277
|
+
|
|
278
|
+
contentBox.setContent(content);
|
|
279
|
+
screen.render();
|
|
280
|
+
|
|
281
|
+
lastGoodRender = content;
|
|
282
|
+
consecutiveErrors = 0;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
consecutiveErrors++;
|
|
285
|
+
if (lastGoodRender && consecutiveErrors < 5) {
|
|
286
|
+
contentBox.setContent(lastGoodRender);
|
|
287
|
+
} else {
|
|
288
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
289
|
+
contentBox.setContent(
|
|
290
|
+
'{bold}{red-fg}Render Error{/red-fg}{/bold}\n\n' +
|
|
291
|
+
`{red-fg}${errorMsg}{/red-fg}\n\n` +
|
|
292
|
+
`Consecutive errors: ${consecutiveErrors}\n\n` +
|
|
293
|
+
'{gray-fg}ESC = Back Q = Quit{/gray-fg}'
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
screen.render();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function cleanup(): void {
|
|
301
|
+
if (refreshIntervalId) {
|
|
302
|
+
clearInterval(refreshIntervalId);
|
|
303
|
+
refreshIntervalId = null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
screen.key(['h', 'H'], () => {
|
|
308
|
+
viewMode = viewMode === 'recent' ? 'hour' : 'recent';
|
|
309
|
+
render();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
screen.key(['escape'], () => {
|
|
313
|
+
cleanup();
|
|
314
|
+
screen.remove(contentBox);
|
|
315
|
+
onBack();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
screen.key(['q', 'Q', 'C-c'], () => {
|
|
319
|
+
cleanup();
|
|
320
|
+
screen.destroy();
|
|
321
|
+
process.exit(0);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
contentBox.setContent('{cyan-fg}\u23f3 Loading historical data...{/cyan-fg}');
|
|
325
|
+
screen.render();
|
|
326
|
+
await render();
|
|
327
|
+
|
|
328
|
+
refreshIntervalId = setInterval(render, REFRESH_INTERVAL);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Multi-server historical view
|
|
332
|
+
export async function createMultiServerHistoricalUI(
|
|
333
|
+
screen: blessed.Widgets.Screen,
|
|
334
|
+
servers: ServerConfig[],
|
|
335
|
+
_selectedIndex: number,
|
|
336
|
+
onBack: () => void
|
|
337
|
+
): Promise<void> {
|
|
338
|
+
let refreshIntervalId: NodeJS.Timeout | null = null;
|
|
339
|
+
const REFRESH_INTERVAL = 3000;
|
|
340
|
+
let lastGoodRender: string | null = null;
|
|
341
|
+
let consecutiveErrors = 0;
|
|
342
|
+
let viewMode: ViewMode = 'recent';
|
|
343
|
+
|
|
344
|
+
const contentBox = createContentBox();
|
|
345
|
+
screen.append(contentBox);
|
|
346
|
+
|
|
347
|
+
// Chart configurations for multi-server view
|
|
348
|
+
const chartConfigs: Record<string, ChartConfig> = {
|
|
349
|
+
tokenSpeed: {
|
|
350
|
+
title: 'Total Model Token Generation Speed (tok/s)',
|
|
351
|
+
color: asciichart.cyan,
|
|
352
|
+
formatValue: (x: number) => Math.round(x).toFixed(0).padStart(6, ' '),
|
|
353
|
+
isPercentage: false,
|
|
354
|
+
noDataMessage: 'No generation activity in this time window',
|
|
355
|
+
},
|
|
356
|
+
cpu: {
|
|
357
|
+
title: 'Total Model CPU Usage (%)',
|
|
358
|
+
color: asciichart.blue,
|
|
359
|
+
formatValue: (x: number) => Math.round(x).toFixed(0).padStart(6, ' '),
|
|
360
|
+
isPercentage: true,
|
|
361
|
+
noDataMessage: 'No CPU data in this time window',
|
|
362
|
+
},
|
|
363
|
+
memory: {
|
|
364
|
+
title: 'Total Model Memory Usage (GB)',
|
|
365
|
+
color: asciichart.magenta,
|
|
366
|
+
formatValue: (x: number) => x.toFixed(2).padStart(6, ' '),
|
|
367
|
+
isPercentage: false,
|
|
368
|
+
noDataMessage: 'No memory data in this time window',
|
|
369
|
+
},
|
|
370
|
+
gpu: {
|
|
371
|
+
title: 'System GPU Usage (%)',
|
|
372
|
+
color: asciichart.green,
|
|
373
|
+
formatValue: (x: number) => Math.round(x).toFixed(0).padStart(6, ' '),
|
|
374
|
+
isPercentage: true,
|
|
375
|
+
noDataMessage: 'No GPU data in this time window',
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
async function render(): Promise<void> {
|
|
380
|
+
try {
|
|
381
|
+
const termWidth = (screen.width as number) || 80;
|
|
382
|
+
const divider = '\u2500'.repeat(termWidth - 2);
|
|
383
|
+
const chartWidth = Math.min(Math.max(40, termWidth - 20), 80);
|
|
384
|
+
const chartHeight = 5;
|
|
385
|
+
|
|
386
|
+
// Header
|
|
387
|
+
const modeLabel = viewMode === 'recent' ? 'Minute' : 'Hour';
|
|
388
|
+
const modeColor = viewMode === 'recent' ? 'cyan' : 'magenta';
|
|
389
|
+
let content = `{bold}{blue-fg}\u2550\u2550\u2550 All servers (${servers.length}){/blue-fg} `;
|
|
390
|
+
content += `{${modeColor}-fg}[${modeLabel}]{/${modeColor}-fg}{/bold}\n\n`;
|
|
391
|
+
|
|
392
|
+
// Load and aggregate history for all servers
|
|
393
|
+
const serverHistories = await Promise.all(
|
|
394
|
+
servers.map(async (server) => {
|
|
395
|
+
const manager = new HistoryManager(server.id);
|
|
396
|
+
return manager.loadHistoryByWindow('1h');
|
|
397
|
+
})
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// Aggregate data across all servers at each timestamp
|
|
401
|
+
const ALIGNMENT_INTERVAL = 2000;
|
|
402
|
+
const timestampMap = new Map<number, {
|
|
403
|
+
tokensPerSec: number[];
|
|
404
|
+
gpuUsage: number[];
|
|
405
|
+
cpuUsage: number[];
|
|
406
|
+
memoryGB: number[];
|
|
407
|
+
}>();
|
|
408
|
+
|
|
409
|
+
for (const snapshots of serverHistories) {
|
|
410
|
+
for (const snapshot of snapshots) {
|
|
411
|
+
const timestamp = Math.round(snapshot.timestamp / ALIGNMENT_INTERVAL) * ALIGNMENT_INTERVAL;
|
|
412
|
+
|
|
413
|
+
if (!timestampMap.has(timestamp)) {
|
|
414
|
+
timestampMap.set(timestamp, {
|
|
415
|
+
tokensPerSec: [],
|
|
416
|
+
gpuUsage: [],
|
|
417
|
+
cpuUsage: [],
|
|
418
|
+
memoryGB: [],
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const data = timestampMap.get(timestamp)!;
|
|
423
|
+
|
|
424
|
+
if (snapshot.server.avgGenerateSpeed && snapshot.server.avgGenerateSpeed > 0) {
|
|
425
|
+
data.tokensPerSec.push(snapshot.server.avgGenerateSpeed);
|
|
426
|
+
}
|
|
427
|
+
if (snapshot.system?.gpuUsage !== undefined) {
|
|
428
|
+
data.gpuUsage.push(snapshot.system.gpuUsage);
|
|
429
|
+
}
|
|
430
|
+
if (snapshot.server.processCpuUsage !== undefined) {
|
|
431
|
+
data.cpuUsage.push(snapshot.server.processCpuUsage);
|
|
432
|
+
}
|
|
433
|
+
if (snapshot.server.processMemory) {
|
|
434
|
+
data.memoryGB.push(snapshot.server.processMemory / (1024 ** 3));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Sort timestamps and aggregate
|
|
440
|
+
const timestamps = Array.from(timestampMap.keys()).sort((a, b) => a - b);
|
|
441
|
+
const aggregatedData = timestamps.map(ts => {
|
|
442
|
+
const data = timestampMap.get(ts)!;
|
|
443
|
+
return {
|
|
444
|
+
timestamp: ts,
|
|
445
|
+
totalTokS: data.tokensPerSec.reduce((a, b) => a + b, 0),
|
|
446
|
+
avgGpu: data.gpuUsage.length > 0
|
|
447
|
+
? data.gpuUsage.reduce((a, b) => a + b, 0) / data.gpuUsage.length
|
|
448
|
+
: 0,
|
|
449
|
+
totalCpu: data.cpuUsage.reduce((a, b) => a + b, 0),
|
|
450
|
+
totalMemoryGB: data.memoryGB.reduce((a, b) => a + b, 0),
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
if (aggregatedData.length > 0) {
|
|
455
|
+
const maxPoints = Math.min(chartWidth, 80);
|
|
456
|
+
const displayData = viewMode === 'recent' && aggregatedData.length > maxPoints
|
|
457
|
+
? aggregatedData.slice(-maxPoints)
|
|
458
|
+
: aggregatedData;
|
|
459
|
+
|
|
460
|
+
const useDownsampling = viewMode === 'hour';
|
|
461
|
+
|
|
462
|
+
// Extract time-series data
|
|
463
|
+
const rawData = {
|
|
464
|
+
tokenSpeed: displayData.map(d => ({ timestamp: d.timestamp, value: d.totalTokS })),
|
|
465
|
+
cpu: displayData.map(d => ({ timestamp: d.timestamp, value: d.totalCpu })),
|
|
466
|
+
memory: displayData.map(d => ({ timestamp: d.timestamp, value: d.totalMemoryGB })),
|
|
467
|
+
gpu: displayData.map(d => ({ timestamp: d.timestamp, value: d.avgGpu })),
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Apply downsampling
|
|
471
|
+
const values = {
|
|
472
|
+
tokenSpeed: useDownsampling
|
|
473
|
+
? downsampleMaxTimeWithFullHour(rawData.tokenSpeed, chartWidth)
|
|
474
|
+
: rawData.tokenSpeed.map(d => d.value),
|
|
475
|
+
cpu: useDownsampling
|
|
476
|
+
? downsampleMaxTimeWithFullHour(rawData.cpu, chartWidth)
|
|
477
|
+
: rawData.cpu.map(d => d.value),
|
|
478
|
+
memory: useDownsampling
|
|
479
|
+
? downsampleMeanTimeWithFullHour(rawData.memory, chartWidth)
|
|
480
|
+
: rawData.memory.map(d => d.value),
|
|
481
|
+
gpu: useDownsampling
|
|
482
|
+
? downsampleMaxTimeWithFullHour(rawData.gpu, chartWidth)
|
|
483
|
+
: rawData.gpu.map(d => d.value),
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// Render all charts
|
|
487
|
+
content += renderChart(values.tokenSpeed, rawData.tokenSpeed, chartConfigs.tokenSpeed, chartHeight);
|
|
488
|
+
content += renderChart(values.cpu, rawData.cpu, chartConfigs.cpu, chartHeight);
|
|
489
|
+
content += renderChart(values.memory, rawData.memory, chartConfigs.memory, chartHeight);
|
|
490
|
+
content += renderChart(values.gpu, rawData.gpu, chartConfigs.gpu, chartHeight);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Footer
|
|
494
|
+
content += divider + '\n';
|
|
495
|
+
content += `{gray-fg}Updated: ${new Date().toLocaleTimeString()} | H = Toggle Hour View ESC = Back Q = Quit{/gray-fg}`;
|
|
496
|
+
|
|
497
|
+
contentBox.setContent(content);
|
|
498
|
+
screen.render();
|
|
499
|
+
|
|
500
|
+
lastGoodRender = content;
|
|
501
|
+
consecutiveErrors = 0;
|
|
502
|
+
} catch (error) {
|
|
503
|
+
consecutiveErrors++;
|
|
504
|
+
if (lastGoodRender && consecutiveErrors < 5) {
|
|
505
|
+
contentBox.setContent(lastGoodRender);
|
|
506
|
+
} else {
|
|
507
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
508
|
+
contentBox.setContent(
|
|
509
|
+
'{bold}{red-fg}Render Error{/red-fg}{/bold}\n\n' +
|
|
510
|
+
`{red-fg}${errorMsg}{/red-fg}\n\n` +
|
|
511
|
+
`Consecutive errors: ${consecutiveErrors}\n\n` +
|
|
512
|
+
'{gray-fg}ESC = Back Q = Quit{/gray-fg}'
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
screen.render();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function cleanup(): void {
|
|
520
|
+
if (refreshIntervalId) {
|
|
521
|
+
clearInterval(refreshIntervalId);
|
|
522
|
+
refreshIntervalId = null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
screen.key(['h', 'H'], () => {
|
|
527
|
+
viewMode = viewMode === 'recent' ? 'hour' : 'recent';
|
|
528
|
+
render();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
screen.key(['escape'], () => {
|
|
532
|
+
cleanup();
|
|
533
|
+
screen.remove(contentBox);
|
|
534
|
+
onBack();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
screen.key(['q', 'Q', 'C-c'], () => {
|
|
538
|
+
cleanup();
|
|
539
|
+
screen.destroy();
|
|
540
|
+
process.exit(0);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
contentBox.setContent('{cyan-fg}\u23f3 Loading historical data...{/cyan-fg}');
|
|
544
|
+
screen.render();
|
|
545
|
+
await render();
|
|
546
|
+
|
|
547
|
+
refreshIntervalId = setInterval(render, REFRESH_INTERVAL);
|
|
548
|
+
}
|