@appkit/llamacpp-cli 1.8.0 → 1.10.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 +58 -0
- package/README.md +249 -40
- package/dist/cli.js +154 -10
- package/dist/cli.js.map +1 -1
- package/dist/commands/completion.d.ts +9 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +83 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/monitor.js +1 -1
- package/dist/commands/monitor.js.map +1 -1
- package/dist/commands/ps.d.ts +1 -3
- package/dist/commands/ps.d.ts.map +1 -1
- package/dist/commands/ps.js +36 -115
- package/dist/commands/ps.js.map +1 -1
- package/dist/commands/router/config.d.ts +11 -0
- package/dist/commands/router/config.d.ts.map +1 -0
- package/dist/commands/router/config.js +100 -0
- package/dist/commands/router/config.js.map +1 -0
- package/dist/commands/router/logs.d.ts +12 -0
- package/dist/commands/router/logs.d.ts.map +1 -0
- package/dist/commands/router/logs.js +238 -0
- package/dist/commands/router/logs.js.map +1 -0
- package/dist/commands/router/restart.d.ts +2 -0
- package/dist/commands/router/restart.d.ts.map +1 -0
- package/dist/commands/router/restart.js +39 -0
- package/dist/commands/router/restart.js.map +1 -0
- package/dist/commands/router/start.d.ts +2 -0
- package/dist/commands/router/start.d.ts.map +1 -0
- package/dist/commands/router/start.js +60 -0
- package/dist/commands/router/start.js.map +1 -0
- package/dist/commands/router/status.d.ts +2 -0
- package/dist/commands/router/status.d.ts.map +1 -0
- package/dist/commands/router/status.js +116 -0
- package/dist/commands/router/status.js.map +1 -0
- package/dist/commands/router/stop.d.ts +2 -0
- package/dist/commands/router/stop.d.ts.map +1 -0
- package/dist/commands/router/stop.js +36 -0
- package/dist/commands/router/stop.js.map +1 -0
- package/dist/commands/tui.d.ts +2 -0
- package/dist/commands/tui.d.ts.map +1 -0
- package/dist/commands/tui.js +27 -0
- package/dist/commands/tui.js.map +1 -0
- package/dist/lib/completion.d.ts +5 -0
- package/dist/lib/completion.d.ts.map +1 -0
- package/dist/lib/completion.js +195 -0
- package/dist/lib/completion.js.map +1 -0
- package/dist/lib/model-downloader.d.ts +5 -1
- package/dist/lib/model-downloader.d.ts.map +1 -1
- package/dist/lib/model-downloader.js +53 -20
- package/dist/lib/model-downloader.js.map +1 -1
- package/dist/lib/router-logger.d.ts +61 -0
- package/dist/lib/router-logger.d.ts.map +1 -0
- package/dist/lib/router-logger.js +200 -0
- package/dist/lib/router-logger.js.map +1 -0
- package/dist/lib/router-manager.d.ts +103 -0
- package/dist/lib/router-manager.d.ts.map +1 -0
- package/dist/lib/router-manager.js +394 -0
- package/dist/lib/router-manager.js.map +1 -0
- package/dist/lib/router-server.d.ts +61 -0
- package/dist/lib/router-server.d.ts.map +1 -0
- package/dist/lib/router-server.js +485 -0
- package/dist/lib/router-server.js.map +1 -0
- package/dist/tui/ConfigApp.d.ts +7 -0
- package/dist/tui/ConfigApp.d.ts.map +1 -0
- package/dist/tui/ConfigApp.js +1002 -0
- package/dist/tui/ConfigApp.js.map +1 -0
- package/dist/tui/HistoricalMonitorApp.d.ts.map +1 -1
- package/dist/tui/HistoricalMonitorApp.js +85 -49
- package/dist/tui/HistoricalMonitorApp.js.map +1 -1
- package/dist/tui/ModelsApp.d.ts +7 -0
- package/dist/tui/ModelsApp.d.ts.map +1 -0
- package/dist/tui/ModelsApp.js +362 -0
- package/dist/tui/ModelsApp.js.map +1 -0
- package/dist/tui/MultiServerMonitorApp.d.ts +6 -1
- package/dist/tui/MultiServerMonitorApp.d.ts.map +1 -1
- package/dist/tui/MultiServerMonitorApp.js +1038 -122
- package/dist/tui/MultiServerMonitorApp.js.map +1 -1
- package/dist/tui/RootNavigator.d.ts +7 -0
- package/dist/tui/RootNavigator.d.ts.map +1 -0
- package/dist/tui/RootNavigator.js +55 -0
- package/dist/tui/RootNavigator.js.map +1 -0
- package/dist/tui/SearchApp.d.ts +6 -0
- package/dist/tui/SearchApp.d.ts.map +1 -0
- package/dist/tui/SearchApp.js +451 -0
- package/dist/tui/SearchApp.js.map +1 -0
- package/dist/tui/SplashScreen.d.ts +16 -0
- package/dist/tui/SplashScreen.d.ts.map +1 -0
- package/dist/tui/SplashScreen.js +129 -0
- package/dist/tui/SplashScreen.js.map +1 -0
- package/dist/types/router-config.d.ts +19 -0
- package/dist/types/router-config.d.ts.map +1 -0
- package/dist/types/router-config.js +3 -0
- package/dist/types/router-config.js.map +1 -0
- package/package.json +1 -1
- package/src/cli.ts +121 -10
- package/src/commands/monitor.ts +1 -1
- package/src/commands/ps.ts +44 -133
- package/src/commands/router/config.ts +116 -0
- package/src/commands/router/logs.ts +256 -0
- package/src/commands/router/restart.ts +36 -0
- package/src/commands/router/start.ts +60 -0
- package/src/commands/router/status.ts +119 -0
- package/src/commands/router/stop.ts +33 -0
- package/src/commands/tui.ts +25 -0
- package/src/lib/model-downloader.ts +57 -20
- package/src/lib/router-logger.ts +201 -0
- package/src/lib/router-manager.ts +414 -0
- package/src/lib/router-server.ts +538 -0
- package/src/tui/ConfigApp.ts +1085 -0
- package/src/tui/HistoricalMonitorApp.ts +88 -49
- package/src/tui/ModelsApp.ts +368 -0
- package/src/tui/MultiServerMonitorApp.ts +1163 -122
- package/src/tui/RootNavigator.ts +74 -0
- package/src/tui/SearchApp.ts +511 -0
- package/src/tui/SplashScreen.ts +149 -0
- package/src/types/router-config.ts +25 -0
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import blessed from 'blessed';
|
|
2
|
-
import
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
import { ServerConfig, sanitizeModelName } from '../types/server-config.js';
|
|
3
5
|
import { MetricsAggregator } from '../lib/metrics-aggregator.js';
|
|
4
6
|
import { SystemCollector } from '../lib/system-collector.js';
|
|
5
7
|
import { MonitorData, SystemMetrics } from '../types/monitor-types.js';
|
|
6
8
|
import { HistoryManager } from '../lib/history-manager.js';
|
|
7
9
|
import { createHistoricalUI, createMultiServerHistoricalUI } from './HistoricalMonitorApp.js';
|
|
10
|
+
import { createConfigUI } from './ConfigApp.js';
|
|
11
|
+
import { stateManager } from '../lib/state-manager.js';
|
|
12
|
+
import { launchctlManager } from '../lib/launchctl-manager.js';
|
|
13
|
+
import { statusChecker } from '../lib/status-checker.js';
|
|
14
|
+
import { modelScanner } from '../lib/model-scanner.js';
|
|
15
|
+
import { portManager } from '../lib/port-manager.js';
|
|
16
|
+
import { configGenerator, ServerOptions } from '../lib/config-generator.js';
|
|
17
|
+
import { ModelInfo } from '../types/model-info.js';
|
|
18
|
+
import { getLogsDir, getLaunchAgentsDir, ensureDir, parseMetalMemoryFromLog } from '../utils/file-utils.js';
|
|
19
|
+
import { formatBytes } from '../utils/format-utils.js';
|
|
20
|
+
import { isPortInUse } from '../utils/process-utils.js';
|
|
8
21
|
|
|
9
22
|
type ViewMode = 'list' | 'detail';
|
|
10
23
|
|
|
@@ -14,12 +27,20 @@ interface ServerMonitorData {
|
|
|
14
27
|
error: string | null;
|
|
15
28
|
}
|
|
16
29
|
|
|
30
|
+
export interface MonitorUIControls {
|
|
31
|
+
pause: () => void;
|
|
32
|
+
resume: () => void;
|
|
33
|
+
getServers: () => ServerConfig[];
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
export async function createMultiServerMonitorUI(
|
|
18
37
|
screen: blessed.Widgets.Screen,
|
|
19
38
|
servers: ServerConfig[],
|
|
20
|
-
|
|
21
|
-
directJumpIndex?: number
|
|
22
|
-
|
|
39
|
+
skipConnectingMessage: boolean = false,
|
|
40
|
+
directJumpIndex?: number,
|
|
41
|
+
onModels?: (controls: MonitorUIControls) => void,
|
|
42
|
+
onFirstRender?: () => void
|
|
43
|
+
): Promise<MonitorUIControls> {
|
|
23
44
|
let updateInterval = 2000;
|
|
24
45
|
let intervalId: NodeJS.Timeout | null = null;
|
|
25
46
|
let viewMode: ViewMode = directJumpIndex !== undefined ? 'detail' : 'list';
|
|
@@ -29,6 +50,7 @@ export async function createMultiServerMonitorUI(
|
|
|
29
50
|
let lastSystemMetrics: SystemMetrics | null = null;
|
|
30
51
|
let cameFromDirectJump = directJumpIndex !== undefined; // Track if we entered via ps <id>
|
|
31
52
|
let inHistoricalView = false; // Track whether we're in historical view to prevent key conflicts
|
|
53
|
+
let hasCalledFirstRender = false; // Track if we've called onFirstRender callback
|
|
32
54
|
|
|
33
55
|
// Spinner animation
|
|
34
56
|
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
@@ -133,7 +155,7 @@ export async function createMultiServerMonitorUI(
|
|
|
133
155
|
function renderAggregateModelResources(): string {
|
|
134
156
|
let content = '';
|
|
135
157
|
|
|
136
|
-
content += '{bold}
|
|
158
|
+
content += '{bold}Server Resources{/bold}\n';
|
|
137
159
|
const termWidth = (screen.width as number) || 80;
|
|
138
160
|
const divider = '─'.repeat(termWidth - 2);
|
|
139
161
|
content += divider + '\n';
|
|
@@ -181,7 +203,7 @@ export async function createMultiServerMonitorUI(
|
|
|
181
203
|
function renderModelResources(data: MonitorData): string {
|
|
182
204
|
let content = '';
|
|
183
205
|
|
|
184
|
-
content += '{bold}
|
|
206
|
+
content += '{bold}Server Resources{/bold}\n';
|
|
185
207
|
const termWidth = (screen.width as number) || 80;
|
|
186
208
|
const divider = '─'.repeat(termWidth - 2);
|
|
187
209
|
content += divider + '\n';
|
|
@@ -386,7 +408,7 @@ export async function createMultiServerMonitorUI(
|
|
|
386
408
|
|
|
387
409
|
// Footer
|
|
388
410
|
content += '\n' + divider + '\n';
|
|
389
|
-
content += `{gray-fg}Updated: ${new Date().toLocaleTimeString()} | [H]istory [Q]uit{/gray-fg}`;
|
|
411
|
+
content += `{gray-fg}Updated: ${new Date().toLocaleTimeString()} | [N]ew [M]odels [H]istory [Q]uit{/gray-fg}`;
|
|
390
412
|
|
|
391
413
|
return content;
|
|
392
414
|
}
|
|
@@ -404,43 +426,17 @@ export async function createMultiServerMonitorUI(
|
|
|
404
426
|
|
|
405
427
|
// Check if server is stopped
|
|
406
428
|
if (server.status !== 'running') {
|
|
407
|
-
// Show stopped server
|
|
429
|
+
// Show minimal stopped server info
|
|
408
430
|
content += '{bold}Server Information{/bold}\n';
|
|
409
431
|
content += divider + '\n';
|
|
410
432
|
content += `Status: {gray-fg}○ STOPPED{/gray-fg}\n`;
|
|
411
433
|
content += `Model: ${server.modelName}\n`;
|
|
412
434
|
const displayHost = server.host || '127.0.0.1';
|
|
413
435
|
content += `Endpoint: http://${displayHost}:${server.port}\n`;
|
|
414
|
-
content += '\n';
|
|
415
|
-
|
|
416
|
-
content += '{bold}Configuration{/bold}\n';
|
|
417
|
-
content += divider + '\n';
|
|
418
|
-
content += `Threads: ${server.threads}\n`;
|
|
419
|
-
content += `Context: ${server.ctxSize} tokens\n`;
|
|
420
|
-
content += `GPU Layers: ${server.gpuLayers}\n`;
|
|
421
|
-
if (server.verbose) {
|
|
422
|
-
content += `Verbose: Enabled\n`;
|
|
423
|
-
}
|
|
424
|
-
if (server.customFlags && server.customFlags.length > 0) {
|
|
425
|
-
content += `Flags: ${server.customFlags.join(', ')}\n`;
|
|
426
|
-
}
|
|
427
|
-
content += '\n';
|
|
428
436
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
content += `Started: ${new Date(server.lastStarted).toLocaleString()}\n`;
|
|
433
|
-
if (server.lastStopped) {
|
|
434
|
-
content += `Stopped: ${new Date(server.lastStopped).toLocaleString()}\n`;
|
|
435
|
-
}
|
|
436
|
-
content += '\n';
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
content += '{bold}Quick Actions{/bold}\n';
|
|
440
|
-
content += divider + '\n';
|
|
441
|
-
content += `{dim}Start server: llamacpp server start ${server.port}{/dim}\n`;
|
|
442
|
-
content += `{dim}Update config: llamacpp server config ${server.port} [options]{/dim}\n`;
|
|
443
|
-
content += `{dim}View logs: llamacpp server logs ${server.port}{/dim}\n`;
|
|
437
|
+
// Footer - show [S]tart for stopped servers
|
|
438
|
+
content += '\n' + divider + '\n';
|
|
439
|
+
content += `{gray-fg}[S]tart [C]onfig [R]emove [H]istory [ESC] Back [Q]uit{/gray-fg}`;
|
|
444
440
|
|
|
445
441
|
return content;
|
|
446
442
|
}
|
|
@@ -530,9 +526,9 @@ export async function createMultiServerMonitorUI(
|
|
|
530
526
|
}
|
|
531
527
|
}
|
|
532
528
|
|
|
533
|
-
// Footer
|
|
529
|
+
// Footer - show [S]top for running servers
|
|
534
530
|
content += divider + '\n';
|
|
535
|
-
content += `{gray-fg}
|
|
531
|
+
content += `{gray-fg}[S]top [C]onfig [R]emove [H]istory [ESC] Back [Q]uit{/gray-fg}`;
|
|
536
532
|
|
|
537
533
|
return content;
|
|
538
534
|
}
|
|
@@ -643,6 +639,13 @@ export async function createMultiServerMonitorUI(
|
|
|
643
639
|
}
|
|
644
640
|
|
|
645
641
|
contentBox.setContent(content);
|
|
642
|
+
|
|
643
|
+
// Call onFirstRender callback before first render (to clean up splash screen)
|
|
644
|
+
if (!hasCalledFirstRender && onFirstRender) {
|
|
645
|
+
hasCalledFirstRender = true;
|
|
646
|
+
onFirstRender();
|
|
647
|
+
}
|
|
648
|
+
|
|
646
649
|
screen.render();
|
|
647
650
|
|
|
648
651
|
// Clear loading state
|
|
@@ -669,117 +672,1153 @@ export async function createMultiServerMonitorUI(
|
|
|
669
672
|
intervalId = setInterval(fetchData, updateInterval);
|
|
670
673
|
}
|
|
671
674
|
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
675
|
+
// Parse context size with k/K suffix support (e.g., "4k" -> 4096, "64K" -> 65536)
|
|
676
|
+
function parseContextSize(input: string): number | null {
|
|
677
|
+
const trimmed = input.trim().toLowerCase();
|
|
678
|
+
const match = trimmed.match(/^(\d+(?:\.\d+)?)(k)?$/);
|
|
679
|
+
if (!match) return null;
|
|
680
|
+
|
|
681
|
+
const num = parseFloat(match[1]);
|
|
682
|
+
const hasK = match[2] === 'k';
|
|
683
|
+
|
|
684
|
+
if (isNaN(num) || num <= 0) return null;
|
|
685
|
+
|
|
686
|
+
return hasK ? Math.round(num * 1024) : Math.round(num);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Format context size for display (e.g., 4096 -> "4k", 65536 -> "64k")
|
|
690
|
+
function formatContextSize(value: number): string {
|
|
691
|
+
if (value >= 1024 && value % 1024 === 0) {
|
|
692
|
+
return `${value / 1024}k`;
|
|
693
|
+
}
|
|
694
|
+
return value.toLocaleString();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Helper to create modal boxes
|
|
698
|
+
function createModal(title: string, height: number | string = 'shrink', borderColor: string = 'cyan'): blessed.Widgets.BoxElement {
|
|
699
|
+
return blessed.box({
|
|
700
|
+
parent: screen,
|
|
701
|
+
top: 'center',
|
|
702
|
+
left: 'center',
|
|
703
|
+
width: '70%',
|
|
704
|
+
height,
|
|
705
|
+
border: { type: 'line' },
|
|
706
|
+
style: {
|
|
707
|
+
border: { fg: borderColor },
|
|
708
|
+
fg: 'white',
|
|
709
|
+
},
|
|
710
|
+
tags: true,
|
|
711
|
+
label: ` ${title} `,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Show progress modal
|
|
716
|
+
function showProgressModal(message: string): blessed.Widgets.BoxElement {
|
|
717
|
+
const modal = createModal('Working', 6);
|
|
718
|
+
modal.setContent(`\n {cyan-fg}${message}{/cyan-fg}`);
|
|
719
|
+
screen.render();
|
|
720
|
+
return modal;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Show error modal
|
|
724
|
+
async function showErrorModal(message: string): Promise<void> {
|
|
725
|
+
return new Promise((resolve) => {
|
|
726
|
+
const modal = createModal('Error', 8, 'red');
|
|
727
|
+
modal.setContent(`\n {red-fg}❌ ${message}{/red-fg}\n\n {gray-fg}[Enter] Close{/gray-fg}`);
|
|
728
|
+
screen.render();
|
|
729
|
+
modal.focus();
|
|
730
|
+
modal.key(['enter', 'escape'], () => {
|
|
731
|
+
screen.remove(modal);
|
|
732
|
+
resolve();
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Remove server dialog
|
|
738
|
+
async function showRemoveServerDialog(server: ServerConfig): Promise<void> {
|
|
739
|
+
// Pause the monitor
|
|
740
|
+
if (intervalId) clearInterval(intervalId);
|
|
741
|
+
if (spinnerIntervalId) clearInterval(spinnerIntervalId);
|
|
742
|
+
unregisterHandlers();
|
|
743
|
+
|
|
744
|
+
// Check if other servers use the same model
|
|
745
|
+
const allServers = await stateManager.getAllServers();
|
|
746
|
+
const otherServersWithSameModel = allServers.filter(
|
|
747
|
+
s => s.id !== server.id && s.modelPath === server.modelPath
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
let deleteModelOption = false;
|
|
751
|
+
const showDeleteModelOption = otherServersWithSameModel.length === 0;
|
|
752
|
+
// 0 = checkbox (delete model), 1 = confirm button
|
|
753
|
+
let selectedOption = showDeleteModelOption ? 0 : 1;
|
|
754
|
+
|
|
755
|
+
const modal = createModal('Remove Server', showDeleteModelOption ? 18 : 14, 'red');
|
|
756
|
+
|
|
757
|
+
function renderDialog(): void {
|
|
758
|
+
let content = '\n';
|
|
759
|
+
content += ` {bold}Remove server: ${server.id}{/bold}\n\n`;
|
|
760
|
+
content += ` Model: ${server.modelName}\n`;
|
|
761
|
+
content += ` Port: ${server.port}\n`;
|
|
762
|
+
content += ` Status: ${server.status === 'running' ? '{green-fg}running{/green-fg}' : '{gray-fg}stopped{/gray-fg}'}\n\n`;
|
|
763
|
+
|
|
764
|
+
if (server.status === 'running') {
|
|
765
|
+
content += ` {yellow-fg}⚠ Server will be stopped{/yellow-fg}\n\n`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (showDeleteModelOption) {
|
|
769
|
+
const checkbox = deleteModelOption ? '☑' : '☐';
|
|
770
|
+
const isCheckboxSelected = selectedOption === 0;
|
|
771
|
+
if (isCheckboxSelected) {
|
|
772
|
+
content += ` {cyan-bg}{15-fg}${checkbox} Also delete model file{/15-fg}{/cyan-bg}\n`;
|
|
773
|
+
} else {
|
|
774
|
+
content += ` ${checkbox} Also delete model file\n`;
|
|
775
|
+
}
|
|
776
|
+
content += ` {gray-fg}${server.modelPath}{/gray-fg}\n\n`;
|
|
777
|
+
} else {
|
|
778
|
+
content += ` {gray-fg}Model is used by ${otherServersWithSameModel.length} other server(s) - cannot delete{/gray-fg}\n\n`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const isConfirmSelected = selectedOption === 1 || !showDeleteModelOption;
|
|
782
|
+
if (isConfirmSelected) {
|
|
783
|
+
content += ` {cyan-bg}{15-fg}[ Confirm Remove ]{/15-fg}{/cyan-bg}\n\n`;
|
|
784
|
+
} else {
|
|
785
|
+
content += ` [ Confirm Remove ]\n\n`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
content += ` {gray-fg}[↑/↓] Select [Space] Toggle [Enter] Confirm [ESC] Cancel{/gray-fg}`;
|
|
789
|
+
modal.setContent(content);
|
|
790
|
+
screen.render();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
renderDialog();
|
|
794
|
+
modal.focus();
|
|
795
|
+
|
|
796
|
+
return new Promise((resolve) => {
|
|
797
|
+
modal.key(['up', 'k'], () => {
|
|
798
|
+
if (showDeleteModelOption && selectedOption === 1) {
|
|
799
|
+
selectedOption = 0;
|
|
800
|
+
renderDialog();
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
modal.key(['down', 'j'], () => {
|
|
805
|
+
if (showDeleteModelOption && selectedOption === 0) {
|
|
806
|
+
selectedOption = 1;
|
|
807
|
+
renderDialog();
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
modal.key(['space'], () => {
|
|
812
|
+
if (showDeleteModelOption && selectedOption === 0) {
|
|
813
|
+
deleteModelOption = !deleteModelOption;
|
|
814
|
+
renderDialog();
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
modal.key(['escape'], () => {
|
|
819
|
+
screen.remove(modal);
|
|
820
|
+
registerHandlers();
|
|
821
|
+
startPolling();
|
|
822
|
+
resolve();
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
modal.key(['enter'], async () => {
|
|
826
|
+
screen.remove(modal);
|
|
827
|
+
|
|
828
|
+
// Show progress
|
|
829
|
+
const progressModal = showProgressModal('Removing server...');
|
|
830
|
+
|
|
831
|
+
try {
|
|
832
|
+
// Stop and unload service if running
|
|
833
|
+
if (server.status === 'running') {
|
|
834
|
+
progressModal.setContent('\n {cyan-fg}Stopping server...{/cyan-fg}');
|
|
835
|
+
screen.render();
|
|
836
|
+
try {
|
|
837
|
+
await launchctlManager.unloadService(server.plistPath);
|
|
838
|
+
await launchctlManager.waitForServiceStop(server.label, 5000);
|
|
839
|
+
} catch (err) {
|
|
840
|
+
// Continue even if unload fails
|
|
841
|
+
}
|
|
842
|
+
} else {
|
|
843
|
+
// Still try to unload in case it's in a weird state
|
|
844
|
+
try {
|
|
845
|
+
await launchctlManager.unloadService(server.plistPath);
|
|
846
|
+
} catch (err) {
|
|
847
|
+
// Ignore
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Delete plist
|
|
852
|
+
progressModal.setContent('\n {cyan-fg}Removing configuration...{/cyan-fg}');
|
|
853
|
+
screen.render();
|
|
854
|
+
await launchctlManager.deletePlist(server.plistPath);
|
|
855
|
+
|
|
856
|
+
// Delete server config
|
|
857
|
+
await stateManager.deleteServerConfig(server.id);
|
|
858
|
+
|
|
859
|
+
// Delete model if requested
|
|
860
|
+
if (deleteModelOption && showDeleteModelOption) {
|
|
861
|
+
progressModal.setContent('\n {cyan-fg}Deleting model file...{/cyan-fg}');
|
|
862
|
+
screen.render();
|
|
863
|
+
await fs.unlink(server.modelPath);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
screen.remove(progressModal);
|
|
867
|
+
|
|
868
|
+
// Remove server from our arrays
|
|
869
|
+
const idx = servers.findIndex(s => s.id === server.id);
|
|
870
|
+
if (idx !== -1) {
|
|
871
|
+
servers.splice(idx, 1);
|
|
872
|
+
aggregators.delete(server.id);
|
|
873
|
+
historyManagers.delete(server.id);
|
|
874
|
+
serverDataMap.delete(server.id);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Go back to list view
|
|
878
|
+
viewMode = 'list';
|
|
879
|
+
selectedRowIndex = Math.min(selectedRowIndex, Math.max(0, servers.length - 1));
|
|
880
|
+
selectedServerIndex = selectedRowIndex;
|
|
881
|
+
|
|
882
|
+
registerHandlers();
|
|
883
|
+
startPolling();
|
|
884
|
+
resolve();
|
|
885
|
+
|
|
886
|
+
} catch (err) {
|
|
887
|
+
screen.remove(progressModal);
|
|
888
|
+
await showErrorModal(err instanceof Error ? err.message : 'Unknown error');
|
|
889
|
+
registerHandlers();
|
|
890
|
+
startPolling();
|
|
891
|
+
resolve();
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Create server flow
|
|
898
|
+
async function showCreateServerFlow(): Promise<void> {
|
|
899
|
+
// Pause the monitor
|
|
900
|
+
if (intervalId) clearInterval(intervalId);
|
|
901
|
+
if (spinnerIntervalId) clearInterval(spinnerIntervalId);
|
|
902
|
+
unregisterHandlers();
|
|
903
|
+
|
|
904
|
+
// Step 1: Model selection
|
|
905
|
+
const models = await modelScanner.scanModels();
|
|
906
|
+
if (models.length === 0) {
|
|
907
|
+
await showErrorModal('No models found in ~/models directory.\nUse [M]odels → [S]earch to download models.');
|
|
908
|
+
// Immediately render with cached data for instant feedback
|
|
677
909
|
const content = renderListView(lastSystemMetrics);
|
|
678
910
|
contentBox.setContent(content);
|
|
679
911
|
screen.render();
|
|
912
|
+
registerHandlers();
|
|
913
|
+
startPolling();
|
|
914
|
+
return;
|
|
680
915
|
}
|
|
681
|
-
});
|
|
682
916
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
917
|
+
// Check which models already have servers
|
|
918
|
+
const allServers = await stateManager.getAllServers();
|
|
919
|
+
const modelsWithServers = new Set(allServers.map(s => s.modelPath));
|
|
920
|
+
|
|
921
|
+
let selectedModelIndex = 0;
|
|
922
|
+
let scrollOffset = 0;
|
|
923
|
+
const maxVisible = 8;
|
|
924
|
+
|
|
925
|
+
const modelModal = createModal('Create Server - Select Model', maxVisible + 8);
|
|
926
|
+
|
|
927
|
+
function renderModelPicker(): void {
|
|
928
|
+
// Adjust scroll offset
|
|
929
|
+
if (selectedModelIndex < scrollOffset) {
|
|
930
|
+
scrollOffset = selectedModelIndex;
|
|
931
|
+
} else if (selectedModelIndex >= scrollOffset + maxVisible) {
|
|
932
|
+
scrollOffset = selectedModelIndex - maxVisible + 1;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
let content = '\n';
|
|
936
|
+
content += ' {bold}Select a model to create a server for:{/bold}\n\n';
|
|
937
|
+
|
|
938
|
+
const visibleModels = models.slice(scrollOffset, scrollOffset + maxVisible);
|
|
939
|
+
|
|
940
|
+
for (let i = 0; i < visibleModels.length; i++) {
|
|
941
|
+
const model = visibleModels[i];
|
|
942
|
+
const actualIndex = scrollOffset + i;
|
|
943
|
+
const isSelected = actualIndex === selectedModelIndex;
|
|
944
|
+
const hasServer = modelsWithServers.has(model.path);
|
|
945
|
+
const indicator = isSelected ? '►' : ' ';
|
|
946
|
+
|
|
947
|
+
// Truncate filename if too long
|
|
948
|
+
let displayName = model.filename;
|
|
949
|
+
const maxLen = 40;
|
|
950
|
+
if (displayName.length > maxLen) {
|
|
951
|
+
displayName = displayName.substring(0, maxLen - 3) + '...';
|
|
952
|
+
}
|
|
953
|
+
displayName = displayName.padEnd(maxLen);
|
|
954
|
+
|
|
955
|
+
const size = model.sizeFormatted.padStart(8);
|
|
956
|
+
const serverIndicator = hasServer ? ' {yellow-fg}(has server){/yellow-fg}' : '';
|
|
957
|
+
const serverIndicatorPlain = hasServer ? ' (has server)' : '';
|
|
958
|
+
|
|
959
|
+
if (isSelected) {
|
|
960
|
+
content += ` {cyan-bg}{15-fg}${indicator} ${displayName} ${size}${serverIndicatorPlain}{/15-fg}{/cyan-bg}\n`;
|
|
961
|
+
} else {
|
|
962
|
+
content += ` ${indicator} ${displayName} {gray-fg}${size}{/gray-fg}${serverIndicator}\n`;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Scroll indicator
|
|
967
|
+
if (models.length > maxVisible) {
|
|
968
|
+
const scrollInfo = `${selectedModelIndex + 1}/${models.length}`;
|
|
969
|
+
content += `\n {gray-fg}${scrollInfo}{/gray-fg}`;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
content += '\n\n {gray-fg}[↑/↓] Navigate [Enter] Select [ESC] Cancel{/gray-fg}';
|
|
973
|
+
modelModal.setContent(content);
|
|
974
|
+
screen.render();
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
renderModelPicker();
|
|
978
|
+
modelModal.focus();
|
|
979
|
+
|
|
980
|
+
const selectedModel = await new Promise<ModelInfo | null>((resolve) => {
|
|
981
|
+
modelModal.key(['up', 'k'], () => {
|
|
982
|
+
selectedModelIndex = Math.max(0, selectedModelIndex - 1);
|
|
983
|
+
renderModelPicker();
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
modelModal.key(['down', 'j'], () => {
|
|
987
|
+
selectedModelIndex = Math.min(models.length - 1, selectedModelIndex + 1);
|
|
988
|
+
renderModelPicker();
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
modelModal.key(['escape'], () => {
|
|
992
|
+
screen.remove(modelModal);
|
|
993
|
+
resolve(null);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
modelModal.key(['enter'], () => {
|
|
997
|
+
screen.remove(modelModal);
|
|
998
|
+
resolve(models[selectedModelIndex]);
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
if (!selectedModel) {
|
|
1003
|
+
// Immediately render with cached data for instant feedback
|
|
687
1004
|
const content = renderListView(lastSystemMetrics);
|
|
688
1005
|
contentBox.setContent(content);
|
|
689
1006
|
screen.render();
|
|
1007
|
+
registerHandlers();
|
|
1008
|
+
startPolling();
|
|
1009
|
+
return;
|
|
690
1010
|
}
|
|
691
|
-
});
|
|
692
1011
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1012
|
+
// Create a non-null reference for closures
|
|
1013
|
+
const model = selectedModel;
|
|
1014
|
+
|
|
1015
|
+
// Check if server already exists for this model
|
|
1016
|
+
const existingServer = allServers.find(s => s.modelPath === model.path);
|
|
1017
|
+
if (existingServer) {
|
|
1018
|
+
await showErrorModal(`Server already exists for this model.\nServer ID: ${existingServer.id}\nPort: ${existingServer.port}`);
|
|
1019
|
+
// Immediately render with cached data for instant feedback
|
|
1020
|
+
const content = renderListView(lastSystemMetrics);
|
|
1021
|
+
contentBox.setContent(content);
|
|
1022
|
+
screen.render();
|
|
1023
|
+
registerHandlers();
|
|
1024
|
+
startPolling();
|
|
1025
|
+
return;
|
|
700
1026
|
}
|
|
701
|
-
});
|
|
702
1027
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1028
|
+
// Step 2: Configuration
|
|
1029
|
+
interface CreateConfig {
|
|
1030
|
+
host: string;
|
|
1031
|
+
port: number;
|
|
1032
|
+
threads: number;
|
|
1033
|
+
ctxSize: number;
|
|
1034
|
+
gpuLayers: number;
|
|
1035
|
+
verbose: boolean;
|
|
1036
|
+
}
|
|
707
1037
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
1038
|
+
// Generate smart defaults
|
|
1039
|
+
const defaultPort = await portManager.findAvailablePort();
|
|
1040
|
+
const modelSize = model.size;
|
|
1041
|
+
|
|
1042
|
+
// Smart context size based on model size
|
|
1043
|
+
let defaultCtxSize = 4096;
|
|
1044
|
+
if (modelSize < 1024 * 1024 * 1024) { // < 1GB
|
|
1045
|
+
defaultCtxSize = 2048;
|
|
1046
|
+
} else if (modelSize < 3 * 1024 * 1024 * 1024) { // < 3GB
|
|
1047
|
+
defaultCtxSize = 4096;
|
|
1048
|
+
} else if (modelSize < 6 * 1024 * 1024 * 1024) { // < 6GB
|
|
1049
|
+
defaultCtxSize = 8192;
|
|
1050
|
+
} else {
|
|
1051
|
+
defaultCtxSize = 16384;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const os = await import('os');
|
|
1055
|
+
const defaultThreads = Math.max(1, Math.floor(os.cpus().length / 2));
|
|
1056
|
+
|
|
1057
|
+
const config: CreateConfig = {
|
|
1058
|
+
host: '127.0.0.1',
|
|
1059
|
+
port: defaultPort,
|
|
1060
|
+
threads: defaultThreads,
|
|
1061
|
+
ctxSize: defaultCtxSize,
|
|
1062
|
+
gpuLayers: 60,
|
|
1063
|
+
verbose: true,
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
// Configuration fields
|
|
1067
|
+
const fields = [
|
|
1068
|
+
{ key: 'host', label: 'Host', type: 'select', options: ['127.0.0.1', '0.0.0.0'] },
|
|
1069
|
+
{ key: 'port', label: 'Port', type: 'number' },
|
|
1070
|
+
{ key: 'threads', label: 'Threads', type: 'number' },
|
|
1071
|
+
{ key: 'ctxSize', label: 'Context Size', type: 'number' },
|
|
1072
|
+
{ key: 'gpuLayers', label: 'GPU Layers', type: 'number' },
|
|
1073
|
+
{ key: 'verbose', label: 'Verbose Logs', type: 'toggle' },
|
|
1074
|
+
];
|
|
1075
|
+
|
|
1076
|
+
let selectedFieldIndex = 0;
|
|
1077
|
+
const configModal = createModal('Create Server - Configuration', 20);
|
|
1078
|
+
|
|
1079
|
+
function formatConfigValue(key: string, value: any): string {
|
|
1080
|
+
if (key === 'verbose') return value ? 'Enabled' : 'Disabled';
|
|
1081
|
+
if (key === 'ctxSize') return formatContextSize(value);
|
|
1082
|
+
return String(value);
|
|
722
1083
|
}
|
|
723
|
-
});
|
|
724
1084
|
|
|
725
|
-
|
|
1085
|
+
function renderConfigScreen(): void {
|
|
1086
|
+
let content = '\n';
|
|
1087
|
+
content += ` {bold}Model:{/bold} ${model.filename}\n`;
|
|
1088
|
+
content += ` {bold}Size:{/bold} ${model.sizeFormatted}\n\n`;
|
|
726
1089
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
if (inHistoricalView) return;
|
|
1090
|
+
content += ' {bold}Server Configuration:{/bold}\n';
|
|
1091
|
+
content += ' ─'.repeat(30) + '\n';
|
|
730
1092
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1093
|
+
for (let i = 0; i < fields.length; i++) {
|
|
1094
|
+
const field = fields[i];
|
|
1095
|
+
const isSelected = i === selectedFieldIndex;
|
|
1096
|
+
const indicator = isSelected ? '►' : ' ';
|
|
1097
|
+
const label = field.label.padEnd(14);
|
|
1098
|
+
const value = formatConfigValue(field.key, (config as any)[field.key]);
|
|
734
1099
|
|
|
735
|
-
|
|
736
|
-
|
|
1100
|
+
if (isSelected) {
|
|
1101
|
+
content += ` {cyan-bg}{15-fg}${indicator} ${label}${value}{/15-fg}{/cyan-bg}\n`;
|
|
1102
|
+
} else {
|
|
1103
|
+
content += ` ${indicator} ${label}{cyan-fg}${value}{/cyan-fg}\n`;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
737
1106
|
|
|
738
|
-
|
|
739
|
-
|
|
1107
|
+
content += '\n';
|
|
1108
|
+
const createSelected = selectedFieldIndex === fields.length;
|
|
1109
|
+
if (createSelected) {
|
|
1110
|
+
content += ` {green-bg}{15-fg}[ Create Server ]{/15-fg}{/green-bg}\n`;
|
|
1111
|
+
} else {
|
|
1112
|
+
content += ` {green-fg}[ Create Server ]{/green-fg}\n`;
|
|
1113
|
+
}
|
|
740
1114
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
1115
|
+
content += '\n {gray-fg}[↑/↓] Navigate [Enter] Edit/Create [ESC] Cancel{/gray-fg}';
|
|
1116
|
+
configModal.setContent(content);
|
|
1117
|
+
screen.render();
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
renderConfigScreen();
|
|
1121
|
+
configModal.focus();
|
|
1122
|
+
|
|
1123
|
+
const shouldCreate = await new Promise<boolean>((resolve) => {
|
|
1124
|
+
configModal.key(['up', 'k'], () => {
|
|
1125
|
+
selectedFieldIndex = Math.max(0, selectedFieldIndex - 1);
|
|
1126
|
+
renderConfigScreen();
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
configModal.key(['down', 'j'], () => {
|
|
1130
|
+
selectedFieldIndex = Math.min(fields.length, selectedFieldIndex + 1);
|
|
1131
|
+
renderConfigScreen();
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
configModal.key(['escape'], () => {
|
|
1135
|
+
screen.remove(configModal);
|
|
1136
|
+
resolve(false);
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
configModal.key(['enter'], async () => {
|
|
1140
|
+
if (selectedFieldIndex === fields.length) {
|
|
1141
|
+
// Create button selected
|
|
1142
|
+
screen.remove(configModal);
|
|
1143
|
+
resolve(true);
|
|
1144
|
+
} else {
|
|
1145
|
+
// Edit field
|
|
1146
|
+
const field = fields[selectedFieldIndex];
|
|
1147
|
+
|
|
1148
|
+
if (field.type === 'select') {
|
|
1149
|
+
// Show select dialog
|
|
1150
|
+
const options = field.options!;
|
|
1151
|
+
let optionIndex = options.indexOf((config as any)[field.key]);
|
|
1152
|
+
if (optionIndex < 0) optionIndex = 0;
|
|
1153
|
+
|
|
1154
|
+
const selectModal = createModal(field.label, options.length + 6);
|
|
1155
|
+
|
|
1156
|
+
function renderSelectOptions(): void {
|
|
1157
|
+
let content = '\n';
|
|
1158
|
+
for (let i = 0; i < options.length; i++) {
|
|
1159
|
+
const isOpt = i === optionIndex;
|
|
1160
|
+
const ind = isOpt ? '●' : '○';
|
|
1161
|
+
if (isOpt) {
|
|
1162
|
+
content += ` {cyan-fg}${ind} ${options[i]}{/cyan-fg}\n`;
|
|
1163
|
+
} else {
|
|
1164
|
+
content += ` {gray-fg}${ind} ${options[i]}{/gray-fg}\n`;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (field.key === 'host' && options[optionIndex] === '0.0.0.0') {
|
|
1168
|
+
content += '\n {yellow-fg}⚠ Warning: Exposes server to network{/yellow-fg}';
|
|
1169
|
+
}
|
|
1170
|
+
content += '\n\n {gray-fg}[↑/↓] Select [Enter] Confirm{/gray-fg}';
|
|
1171
|
+
selectModal.setContent(content);
|
|
1172
|
+
screen.render();
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
renderSelectOptions();
|
|
1176
|
+
selectModal.focus();
|
|
1177
|
+
|
|
1178
|
+
await new Promise<void>((resolveSelect) => {
|
|
1179
|
+
selectModal.key(['up', 'k'], () => {
|
|
1180
|
+
optionIndex = Math.max(0, optionIndex - 1);
|
|
1181
|
+
renderSelectOptions();
|
|
1182
|
+
});
|
|
1183
|
+
selectModal.key(['down', 'j'], () => {
|
|
1184
|
+
optionIndex = Math.min(options.length - 1, optionIndex + 1);
|
|
1185
|
+
renderSelectOptions();
|
|
1186
|
+
});
|
|
1187
|
+
selectModal.key(['enter'], () => {
|
|
1188
|
+
(config as any)[field.key] = options[optionIndex];
|
|
1189
|
+
screen.remove(selectModal);
|
|
1190
|
+
resolveSelect();
|
|
1191
|
+
});
|
|
1192
|
+
selectModal.key(['escape'], () => {
|
|
1193
|
+
screen.remove(selectModal);
|
|
1194
|
+
resolveSelect();
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
renderConfigScreen();
|
|
1199
|
+
configModal.focus();
|
|
1200
|
+
|
|
1201
|
+
} else if (field.type === 'toggle') {
|
|
1202
|
+
(config as any)[field.key] = !(config as any)[field.key];
|
|
1203
|
+
renderConfigScreen();
|
|
1204
|
+
|
|
1205
|
+
} else if (field.type === 'number') {
|
|
1206
|
+
// Number input
|
|
1207
|
+
const isCtxSize = field.key === 'ctxSize';
|
|
1208
|
+
const inputModal = createModal(`Edit ${field.label}`, isCtxSize ? 11 : 10);
|
|
1209
|
+
|
|
1210
|
+
const currentDisplay = isCtxSize
|
|
1211
|
+
? formatContextSize((config as any)[field.key])
|
|
1212
|
+
: (config as any)[field.key];
|
|
1213
|
+
|
|
1214
|
+
const infoText = blessed.text({
|
|
1215
|
+
parent: inputModal,
|
|
1216
|
+
top: 1,
|
|
1217
|
+
left: 2,
|
|
1218
|
+
content: `Current: ${currentDisplay}`,
|
|
1219
|
+
tags: true,
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
// Add hint for context size
|
|
1223
|
+
if (isCtxSize) {
|
|
1224
|
+
blessed.text({
|
|
1225
|
+
parent: inputModal,
|
|
1226
|
+
top: 2,
|
|
1227
|
+
left: 2,
|
|
1228
|
+
content: '{gray-fg}Accepts: 4096, 4k, 8k, 16k, 32k, 64k, 128k{/gray-fg}',
|
|
1229
|
+
tags: true,
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const inputBox = blessed.textbox({
|
|
1234
|
+
parent: inputModal,
|
|
1235
|
+
top: isCtxSize ? 4 : 3,
|
|
1236
|
+
left: 2,
|
|
1237
|
+
right: 2,
|
|
1238
|
+
height: 3,
|
|
1239
|
+
inputOnFocus: true,
|
|
1240
|
+
border: { type: 'line' },
|
|
1241
|
+
style: {
|
|
1242
|
+
border: { fg: 'white' },
|
|
1243
|
+
focus: { border: { fg: 'green' } },
|
|
1244
|
+
},
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
blessed.text({
|
|
1248
|
+
parent: inputModal,
|
|
1249
|
+
bottom: 1,
|
|
1250
|
+
left: 2,
|
|
1251
|
+
content: '{gray-fg}[Enter] Confirm [ESC] Cancel{/gray-fg}',
|
|
1252
|
+
tags: true,
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
// Pre-fill with k notation for context size
|
|
1256
|
+
const initialValue = isCtxSize
|
|
1257
|
+
? formatContextSize((config as any)[field.key])
|
|
1258
|
+
: String((config as any)[field.key]);
|
|
1259
|
+
inputBox.setValue(initialValue);
|
|
1260
|
+
screen.render();
|
|
1261
|
+
inputBox.focus();
|
|
1262
|
+
|
|
1263
|
+
await new Promise<void>((resolveInput) => {
|
|
1264
|
+
inputBox.on('submit', (value: string) => {
|
|
1265
|
+
let numValue: number | null;
|
|
1266
|
+
|
|
1267
|
+
if (isCtxSize) {
|
|
1268
|
+
numValue = parseContextSize(value);
|
|
1269
|
+
} else {
|
|
1270
|
+
numValue = parseInt(value, 10);
|
|
1271
|
+
if (isNaN(numValue)) numValue = null;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (numValue !== null && numValue > 0) {
|
|
1275
|
+
(config as any)[field.key] = numValue;
|
|
1276
|
+
}
|
|
1277
|
+
screen.remove(inputModal);
|
|
1278
|
+
resolveInput();
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
inputBox.on('cancel', () => {
|
|
1282
|
+
screen.remove(inputModal);
|
|
1283
|
+
resolveInput();
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
inputBox.key(['escape'], () => {
|
|
1287
|
+
screen.remove(inputModal);
|
|
1288
|
+
resolveInput();
|
|
1289
|
+
});
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
renderConfigScreen();
|
|
1293
|
+
configModal.focus();
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
if (!shouldCreate) {
|
|
1300
|
+
// Immediately render with cached data for instant feedback
|
|
1301
|
+
const content = renderListView(lastSystemMetrics);
|
|
1302
|
+
contentBox.setContent(content);
|
|
1303
|
+
screen.render();
|
|
1304
|
+
registerHandlers();
|
|
1305
|
+
startPolling();
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Step 3: Create the server
|
|
1310
|
+
const progressModal = showProgressModal('Creating server...');
|
|
1311
|
+
|
|
1312
|
+
try {
|
|
1313
|
+
// Generate full server config
|
|
1314
|
+
const serverOptions: ServerOptions = {
|
|
1315
|
+
port: config.port,
|
|
1316
|
+
host: config.host,
|
|
1317
|
+
threads: config.threads,
|
|
1318
|
+
ctxSize: config.ctxSize,
|
|
1319
|
+
gpuLayers: config.gpuLayers,
|
|
1320
|
+
verbose: config.verbose,
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
progressModal.setContent('\n {cyan-fg}Generating configuration...{/cyan-fg}');
|
|
1324
|
+
screen.render();
|
|
1325
|
+
|
|
1326
|
+
const serverConfig = await configGenerator.generateConfig(
|
|
1327
|
+
model.path,
|
|
1328
|
+
model.filename,
|
|
1329
|
+
model.size,
|
|
1330
|
+
config.port,
|
|
1331
|
+
serverOptions
|
|
1332
|
+
);
|
|
1333
|
+
|
|
1334
|
+
// Ensure log directory exists
|
|
1335
|
+
await ensureDir(path.dirname(serverConfig.stdoutPath));
|
|
1336
|
+
|
|
1337
|
+
// Create plist
|
|
1338
|
+
progressModal.setContent('\n {cyan-fg}Creating launchctl service...{/cyan-fg}');
|
|
1339
|
+
screen.render();
|
|
1340
|
+
await launchctlManager.createPlist(serverConfig);
|
|
1341
|
+
|
|
1342
|
+
// Load service
|
|
1343
|
+
try {
|
|
1344
|
+
await launchctlManager.loadService(serverConfig.plistPath);
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
await launchctlManager.deletePlist(serverConfig.plistPath);
|
|
1347
|
+
throw new Error(`Failed to load service: ${(error as Error).message}`);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// Start service
|
|
1351
|
+
progressModal.setContent('\n {cyan-fg}Starting server...{/cyan-fg}');
|
|
1352
|
+
screen.render();
|
|
1353
|
+
try {
|
|
1354
|
+
await launchctlManager.startService(serverConfig.label);
|
|
1355
|
+
} catch (error) {
|
|
1356
|
+
await launchctlManager.unloadService(serverConfig.plistPath);
|
|
1357
|
+
await launchctlManager.deletePlist(serverConfig.plistPath);
|
|
1358
|
+
throw new Error(`Failed to start service: ${(error as Error).message}`);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Wait for startup
|
|
1362
|
+
progressModal.setContent('\n {cyan-fg}Waiting for server to start...{/cyan-fg}');
|
|
1363
|
+
screen.render();
|
|
1364
|
+
const started = await launchctlManager.waitForServiceStart(serverConfig.label, 5000);
|
|
1365
|
+
|
|
1366
|
+
if (!started) {
|
|
1367
|
+
await launchctlManager.unloadService(serverConfig.plistPath);
|
|
1368
|
+
await launchctlManager.deletePlist(serverConfig.plistPath);
|
|
1369
|
+
throw new Error('Server failed to start. Check logs.');
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// Wait for port to be ready (server may take a moment to bind)
|
|
1373
|
+
progressModal.setContent('\n {cyan-fg}Waiting for server to be ready...{/cyan-fg}');
|
|
1374
|
+
screen.render();
|
|
1375
|
+
const portTimeout = 10000; // 10 seconds
|
|
1376
|
+
const portStartTime = Date.now();
|
|
1377
|
+
let portReady = false;
|
|
1378
|
+
while (Date.now() - portStartTime < portTimeout) {
|
|
1379
|
+
if (await isPortInUse(serverConfig.port)) {
|
|
1380
|
+
portReady = true;
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
if (!portReady) {
|
|
1387
|
+
await launchctlManager.unloadService(serverConfig.plistPath);
|
|
1388
|
+
await launchctlManager.deletePlist(serverConfig.plistPath);
|
|
1389
|
+
throw new Error('Server started but port not responding. Check logs.');
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Update config with running status
|
|
1393
|
+
let updatedConfig = await statusChecker.updateServerStatus(serverConfig);
|
|
1394
|
+
|
|
1395
|
+
// Parse Metal memory allocation (wait a bit for model to load)
|
|
1396
|
+
progressModal.setContent('\n {cyan-fg}Detecting GPU memory allocation...{/cyan-fg}');
|
|
1397
|
+
screen.render();
|
|
1398
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
1399
|
+
const metalMemoryMB = await parseMetalMemoryFromLog(updatedConfig.stderrPath);
|
|
1400
|
+
if (metalMemoryMB) {
|
|
1401
|
+
updatedConfig = { ...updatedConfig, metalMemoryMB };
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Save server config
|
|
1405
|
+
await stateManager.saveServerConfig(updatedConfig);
|
|
1406
|
+
|
|
1407
|
+
// Show success message briefly
|
|
1408
|
+
progressModal.setContent('\n {green-fg}✓ Server created successfully!{/green-fg}');
|
|
1409
|
+
screen.render();
|
|
1410
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1411
|
+
|
|
1412
|
+
screen.remove(progressModal);
|
|
1413
|
+
|
|
1414
|
+
// Add to our arrays
|
|
1415
|
+
servers.push(updatedConfig);
|
|
1416
|
+
aggregators.set(updatedConfig.id, new MetricsAggregator(updatedConfig));
|
|
1417
|
+
historyManagers.set(updatedConfig.id, new HistoryManager(updatedConfig.id));
|
|
1418
|
+
serverDataMap.set(updatedConfig.id, {
|
|
1419
|
+
server: updatedConfig,
|
|
1420
|
+
data: null,
|
|
1421
|
+
error: null,
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
// Select the new server in list view
|
|
1425
|
+
selectedRowIndex = servers.length - 1;
|
|
1426
|
+
selectedServerIndex = selectedRowIndex;
|
|
1427
|
+
|
|
1428
|
+
registerHandlers();
|
|
1429
|
+
startPolling();
|
|
1430
|
+
|
|
1431
|
+
} catch (err) {
|
|
1432
|
+
screen.remove(progressModal);
|
|
1433
|
+
await showErrorModal(err instanceof Error ? err.message : 'Unknown error');
|
|
1434
|
+
// Immediately render with cached data for instant feedback
|
|
1435
|
+
const content = renderListView(lastSystemMetrics);
|
|
1436
|
+
contentBox.setContent(content);
|
|
1437
|
+
screen.render();
|
|
1438
|
+
registerHandlers();
|
|
1439
|
+
startPolling();
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// Store key handler references for cleanup when switching views
|
|
1444
|
+
const keyHandlers = {
|
|
1445
|
+
up: () => {
|
|
1446
|
+
if (viewMode === 'list') {
|
|
1447
|
+
selectedRowIndex = Math.max(0, selectedRowIndex - 1);
|
|
1448
|
+
// Re-render immediately for responsive feel
|
|
749
1449
|
const content = renderListView(lastSystemMetrics);
|
|
750
1450
|
contentBox.setContent(content);
|
|
751
1451
|
screen.render();
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
//
|
|
758
|
-
|
|
759
|
-
// Re-attach content box when returning from history
|
|
760
|
-
screen.append(contentBox);
|
|
761
|
-
// Re-render the detail view
|
|
762
|
-
const content = renderDetailView(lastSystemMetrics);
|
|
1452
|
+
}
|
|
1453
|
+
},
|
|
1454
|
+
down: () => {
|
|
1455
|
+
if (viewMode === 'list') {
|
|
1456
|
+
selectedRowIndex = Math.min(servers.length - 1, selectedRowIndex + 1);
|
|
1457
|
+
// Re-render immediately for responsive feel
|
|
1458
|
+
const content = renderListView(lastSystemMetrics);
|
|
763
1459
|
contentBox.setContent(content);
|
|
764
1460
|
screen.render();
|
|
1461
|
+
}
|
|
1462
|
+
},
|
|
1463
|
+
enter: () => {
|
|
1464
|
+
if (viewMode === 'list') {
|
|
1465
|
+
showLoading();
|
|
1466
|
+
selectedServerIndex = selectedRowIndex;
|
|
1467
|
+
viewMode = 'detail';
|
|
1468
|
+
fetchData();
|
|
1469
|
+
}
|
|
1470
|
+
},
|
|
1471
|
+
escape: () => {
|
|
1472
|
+
// Don't handle ESC if we're in historical view - let historical view handle it
|
|
1473
|
+
if (inHistoricalView) return;
|
|
1474
|
+
|
|
1475
|
+
if (viewMode === 'detail') {
|
|
1476
|
+
showLoading();
|
|
1477
|
+
viewMode = 'list';
|
|
1478
|
+
cameFromDirectJump = false; // Clear direct jump flag when returning to list
|
|
1479
|
+
fetchData();
|
|
1480
|
+
} else if (viewMode === 'list') {
|
|
1481
|
+
// ESC in list view - exit
|
|
1482
|
+
showLoading();
|
|
1483
|
+
if (intervalId) clearInterval(intervalId);
|
|
1484
|
+
if (spinnerIntervalId) clearInterval(spinnerIntervalId);
|
|
1485
|
+
setTimeout(() => {
|
|
1486
|
+
screen.destroy();
|
|
1487
|
+
process.exit(0);
|
|
1488
|
+
}, 100);
|
|
1489
|
+
}
|
|
1490
|
+
},
|
|
1491
|
+
models: async () => {
|
|
1492
|
+
if (onModels && viewMode === 'list' && !inHistoricalView) {
|
|
1493
|
+
// Pause monitor (don't destroy - we'll resume when returning)
|
|
1494
|
+
controls.pause();
|
|
1495
|
+
await onModels(controls);
|
|
1496
|
+
}
|
|
1497
|
+
},
|
|
1498
|
+
history: async () => {
|
|
1499
|
+
// Prevent entering historical view if already there
|
|
1500
|
+
if (inHistoricalView) return;
|
|
1501
|
+
|
|
1502
|
+
// Keep polling in background for live historical updates
|
|
1503
|
+
// Stop spinner if running
|
|
1504
|
+
if (spinnerIntervalId) clearInterval(spinnerIntervalId);
|
|
1505
|
+
|
|
1506
|
+
// Remove current content box
|
|
1507
|
+
screen.remove(contentBox);
|
|
1508
|
+
|
|
1509
|
+
// Mark that we're in historical view
|
|
1510
|
+
inHistoricalView = true;
|
|
1511
|
+
|
|
1512
|
+
if (viewMode === 'list') {
|
|
1513
|
+
// Show multi-server historical view
|
|
1514
|
+
await createMultiServerHistoricalUI(screen, servers, selectedServerIndex, () => {
|
|
1515
|
+
// Mark that we've left historical view
|
|
1516
|
+
inHistoricalView = false;
|
|
1517
|
+
// Re-attach content box when returning from history
|
|
1518
|
+
screen.append(contentBox);
|
|
1519
|
+
// Re-render the list view
|
|
1520
|
+
const content = renderListView(lastSystemMetrics);
|
|
1521
|
+
contentBox.setContent(content);
|
|
1522
|
+
screen.render();
|
|
1523
|
+
});
|
|
1524
|
+
} else {
|
|
1525
|
+
// Show single-server historical view for selected server
|
|
1526
|
+
const selectedServer = servers[selectedServerIndex];
|
|
1527
|
+
await createHistoricalUI(screen, selectedServer, () => {
|
|
1528
|
+
// Mark that we've left historical view
|
|
1529
|
+
inHistoricalView = false;
|
|
1530
|
+
// Re-attach content box when returning from history
|
|
1531
|
+
screen.append(contentBox);
|
|
1532
|
+
// Re-render the detail view
|
|
1533
|
+
const content = renderDetailView(lastSystemMetrics);
|
|
1534
|
+
contentBox.setContent(content);
|
|
1535
|
+
screen.render();
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
},
|
|
1539
|
+
config: async () => {
|
|
1540
|
+
// Only available from detail view and not in historical view
|
|
1541
|
+
if (viewMode !== 'detail' || inHistoricalView) return;
|
|
1542
|
+
|
|
1543
|
+
// Pause monitor
|
|
1544
|
+
controls.pause();
|
|
1545
|
+
|
|
1546
|
+
const selectedServer = servers[selectedServerIndex];
|
|
1547
|
+
await createConfigUI(screen, selectedServer, (updatedServer) => {
|
|
1548
|
+
if (updatedServer) {
|
|
1549
|
+
// Check if server ID changed (model migration)
|
|
1550
|
+
if (updatedServer.id !== selectedServer.id) {
|
|
1551
|
+
// Replace server in array and update aggregator/history manager
|
|
1552
|
+
servers[selectedServerIndex] = updatedServer;
|
|
1553
|
+
aggregators.delete(selectedServer.id);
|
|
1554
|
+
historyManagers.delete(selectedServer.id);
|
|
1555
|
+
serverDataMap.delete(selectedServer.id);
|
|
1556
|
+
aggregators.set(updatedServer.id, new MetricsAggregator(updatedServer));
|
|
1557
|
+
historyManagers.set(updatedServer.id, new HistoryManager(updatedServer.id));
|
|
1558
|
+
serverDataMap.set(updatedServer.id, {
|
|
1559
|
+
server: updatedServer,
|
|
1560
|
+
data: null,
|
|
1561
|
+
error: null,
|
|
1562
|
+
});
|
|
1563
|
+
} else {
|
|
1564
|
+
// Update server in place
|
|
1565
|
+
servers[selectedServerIndex] = updatedServer;
|
|
1566
|
+
serverDataMap.set(updatedServer.id, {
|
|
1567
|
+
server: updatedServer,
|
|
1568
|
+
data: null,
|
|
1569
|
+
error: null,
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
// Resume monitor
|
|
1574
|
+
controls.resume();
|
|
765
1575
|
});
|
|
766
|
-
}
|
|
767
|
-
|
|
1576
|
+
},
|
|
1577
|
+
remove: async () => {
|
|
1578
|
+
// Only available from detail view and not in historical view
|
|
1579
|
+
if (viewMode !== 'detail' || inHistoricalView) return;
|
|
768
1580
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1581
|
+
const selectedServer = servers[selectedServerIndex];
|
|
1582
|
+
|
|
1583
|
+
// Show remove server dialog
|
|
1584
|
+
await showRemoveServerDialog(selectedServer);
|
|
1585
|
+
},
|
|
1586
|
+
startStop: async () => {
|
|
1587
|
+
// Only available from detail view and not in historical view
|
|
1588
|
+
if (viewMode !== 'detail' || inHistoricalView) return;
|
|
1589
|
+
|
|
1590
|
+
const selectedServer = servers[selectedServerIndex];
|
|
1591
|
+
|
|
1592
|
+
// If running, stop it. If stopped, start it.
|
|
1593
|
+
if (selectedServer.status === 'running') {
|
|
1594
|
+
// Stop the server
|
|
1595
|
+
if (intervalId) clearInterval(intervalId);
|
|
1596
|
+
if (spinnerIntervalId) clearInterval(spinnerIntervalId);
|
|
1597
|
+
unregisterHandlers();
|
|
1598
|
+
|
|
1599
|
+
const progressModal = showProgressModal('Stopping server...');
|
|
1600
|
+
|
|
1601
|
+
try {
|
|
1602
|
+
// Unload service (this stops and unregisters it)
|
|
1603
|
+
progressModal.setContent('\n {cyan-fg}Stopping server...{/cyan-fg}');
|
|
1604
|
+
screen.render();
|
|
1605
|
+
await launchctlManager.unloadService(selectedServer.plistPath);
|
|
1606
|
+
|
|
1607
|
+
// Wait for shutdown
|
|
1608
|
+
progressModal.setContent('\n {cyan-fg}Waiting for server to stop...{/cyan-fg}');
|
|
1609
|
+
screen.render();
|
|
1610
|
+
await launchctlManager.waitForServiceStop(selectedServer.label, 5000);
|
|
1611
|
+
|
|
1612
|
+
// Update server status
|
|
1613
|
+
const updatedServer = await statusChecker.updateServerStatus(selectedServer);
|
|
1614
|
+
servers[selectedServerIndex] = updatedServer;
|
|
1615
|
+
serverDataMap.set(updatedServer.id, {
|
|
1616
|
+
server: updatedServer,
|
|
1617
|
+
data: null,
|
|
1618
|
+
error: null,
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
// Save updated config
|
|
1622
|
+
await stateManager.saveServerConfig(updatedServer);
|
|
1623
|
+
|
|
1624
|
+
// Show success briefly
|
|
1625
|
+
progressModal.setContent('\n {green-fg}✓ Server stopped successfully!{/green-fg}');
|
|
1626
|
+
screen.render();
|
|
1627
|
+
await new Promise(resolve => setTimeout(resolve, 800));
|
|
1628
|
+
|
|
1629
|
+
screen.remove(progressModal);
|
|
1630
|
+
registerHandlers();
|
|
1631
|
+
startPolling();
|
|
1632
|
+
|
|
1633
|
+
} catch (err) {
|
|
1634
|
+
screen.remove(progressModal);
|
|
1635
|
+
await showErrorModal(err instanceof Error ? err.message : 'Unknown error');
|
|
1636
|
+
registerHandlers();
|
|
1637
|
+
startPolling();
|
|
1638
|
+
}
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Start the server
|
|
1643
|
+
|
|
1644
|
+
// Pause the monitor
|
|
1645
|
+
if (intervalId) clearInterval(intervalId);
|
|
1646
|
+
if (spinnerIntervalId) clearInterval(spinnerIntervalId);
|
|
1647
|
+
unregisterHandlers();
|
|
1648
|
+
|
|
1649
|
+
const progressModal = showProgressModal('Starting server...');
|
|
1650
|
+
|
|
1651
|
+
try {
|
|
1652
|
+
// Recreate plist if needed
|
|
1653
|
+
const plistExists = await fs.access(selectedServer.plistPath).then(() => true).catch(() => false);
|
|
1654
|
+
if (!plistExists) {
|
|
1655
|
+
progressModal.setContent('\n {cyan-fg}Recreating plist...{/cyan-fg}');
|
|
1656
|
+
screen.render();
|
|
1657
|
+
await launchctlManager.createPlist(selectedServer);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// Load service
|
|
1661
|
+
progressModal.setContent('\n {cyan-fg}Loading service...{/cyan-fg}');
|
|
1662
|
+
screen.render();
|
|
1663
|
+
try {
|
|
1664
|
+
await launchctlManager.loadService(selectedServer.plistPath);
|
|
1665
|
+
} catch (err) {
|
|
1666
|
+
// May already be loaded, continue
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// Start service
|
|
1670
|
+
progressModal.setContent('\n {cyan-fg}Starting server...{/cyan-fg}');
|
|
1671
|
+
screen.render();
|
|
1672
|
+
await launchctlManager.startService(selectedServer.label);
|
|
1673
|
+
|
|
1674
|
+
// Wait for startup
|
|
1675
|
+
progressModal.setContent('\n {cyan-fg}Waiting for server to start...{/cyan-fg}');
|
|
1676
|
+
screen.render();
|
|
1677
|
+
const started = await launchctlManager.waitForServiceStart(selectedServer.label, 5000);
|
|
1678
|
+
|
|
1679
|
+
if (!started) {
|
|
1680
|
+
throw new Error('Server failed to start. Check logs.');
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Wait for port to be ready
|
|
1684
|
+
progressModal.setContent('\n {cyan-fg}Waiting for server to be ready...{/cyan-fg}');
|
|
1685
|
+
screen.render();
|
|
1686
|
+
const portTimeout = 10000;
|
|
1687
|
+
const portStartTime = Date.now();
|
|
1688
|
+
let portReady = false;
|
|
1689
|
+
while (Date.now() - portStartTime < portTimeout) {
|
|
1690
|
+
if (await isPortInUse(selectedServer.port)) {
|
|
1691
|
+
portReady = true;
|
|
1692
|
+
break;
|
|
1693
|
+
}
|
|
1694
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
if (!portReady) {
|
|
1698
|
+
throw new Error('Server started but port not responding. Check logs.');
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Update server status
|
|
1702
|
+
const updatedServer = await statusChecker.updateServerStatus(selectedServer);
|
|
1703
|
+
servers[selectedServerIndex] = updatedServer;
|
|
1704
|
+
serverDataMap.set(updatedServer.id, {
|
|
1705
|
+
server: updatedServer,
|
|
1706
|
+
data: null,
|
|
1707
|
+
error: null,
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
// Save updated config
|
|
1711
|
+
await stateManager.saveServerConfig(updatedServer);
|
|
1712
|
+
|
|
1713
|
+
// Show success briefly
|
|
1714
|
+
progressModal.setContent('\n {green-fg}✓ Server started successfully!{/green-fg}');
|
|
1715
|
+
screen.render();
|
|
1716
|
+
await new Promise(resolve => setTimeout(resolve, 800));
|
|
1717
|
+
|
|
1718
|
+
screen.remove(progressModal);
|
|
1719
|
+
registerHandlers();
|
|
1720
|
+
startPolling();
|
|
1721
|
+
|
|
1722
|
+
} catch (err) {
|
|
1723
|
+
screen.remove(progressModal);
|
|
1724
|
+
await showErrorModal(err instanceof Error ? err.message : 'Unknown error');
|
|
1725
|
+
registerHandlers();
|
|
1726
|
+
startPolling();
|
|
1727
|
+
}
|
|
1728
|
+
},
|
|
1729
|
+
create: async () => {
|
|
1730
|
+
// Only available from list view and not in historical view
|
|
1731
|
+
if (viewMode !== 'list' || inHistoricalView) return;
|
|
1732
|
+
|
|
1733
|
+
// Show create server flow
|
|
1734
|
+
await showCreateServerFlow();
|
|
1735
|
+
},
|
|
1736
|
+
quit: () => {
|
|
1737
|
+
showLoading();
|
|
1738
|
+
if (intervalId) clearInterval(intervalId);
|
|
1739
|
+
if (spinnerIntervalId) clearInterval(spinnerIntervalId);
|
|
1740
|
+
// Small delay to show the loading state before exit
|
|
1741
|
+
setTimeout(() => {
|
|
1742
|
+
screen.destroy();
|
|
1743
|
+
process.exit(0);
|
|
1744
|
+
}, 100);
|
|
1745
|
+
},
|
|
1746
|
+
};
|
|
1747
|
+
|
|
1748
|
+
// Unregister all keyboard handlers
|
|
1749
|
+
function unregisterHandlers() {
|
|
1750
|
+
screen.unkey('up', keyHandlers.up);
|
|
1751
|
+
screen.unkey('k', keyHandlers.up);
|
|
1752
|
+
screen.unkey('down', keyHandlers.down);
|
|
1753
|
+
screen.unkey('j', keyHandlers.down);
|
|
1754
|
+
screen.unkey('enter', keyHandlers.enter);
|
|
1755
|
+
screen.unkey('escape', keyHandlers.escape);
|
|
1756
|
+
screen.unkey('m', keyHandlers.models);
|
|
1757
|
+
screen.unkey('M', keyHandlers.models);
|
|
1758
|
+
screen.unkey('h', keyHandlers.history);
|
|
1759
|
+
screen.unkey('H', keyHandlers.history);
|
|
1760
|
+
screen.unkey('c', keyHandlers.config);
|
|
1761
|
+
screen.unkey('C', keyHandlers.config);
|
|
1762
|
+
screen.unkey('r', keyHandlers.remove);
|
|
1763
|
+
screen.unkey('R', keyHandlers.remove);
|
|
1764
|
+
screen.unkey('s', keyHandlers.startStop);
|
|
1765
|
+
screen.unkey('S', keyHandlers.startStop);
|
|
1766
|
+
screen.unkey('n', keyHandlers.create);
|
|
1767
|
+
screen.unkey('N', keyHandlers.create);
|
|
1768
|
+
screen.unkey('q', keyHandlers.quit);
|
|
1769
|
+
screen.unkey('Q', keyHandlers.quit);
|
|
1770
|
+
screen.unkey('C-c', keyHandlers.quit);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// Register keyboard handlers
|
|
1774
|
+
function registerHandlers() {
|
|
1775
|
+
screen.key(['up', 'k'], keyHandlers.up);
|
|
1776
|
+
screen.key(['down', 'j'], keyHandlers.down);
|
|
1777
|
+
screen.key(['enter'], keyHandlers.enter);
|
|
1778
|
+
screen.key(['escape'], keyHandlers.escape);
|
|
1779
|
+
screen.key(['m', 'M'], keyHandlers.models);
|
|
1780
|
+
screen.key(['h', 'H'], keyHandlers.history);
|
|
1781
|
+
screen.key(['c', 'C'], keyHandlers.config);
|
|
1782
|
+
screen.key(['r', 'R'], keyHandlers.remove);
|
|
1783
|
+
screen.key(['s', 'S'], keyHandlers.startStop);
|
|
1784
|
+
screen.key(['n', 'N'], keyHandlers.create);
|
|
1785
|
+
screen.key(['q', 'Q', 'C-c'], keyHandlers.quit);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// Controls object for pause/resume from other views
|
|
1789
|
+
const controls: MonitorUIControls = {
|
|
1790
|
+
pause: () => {
|
|
1791
|
+
unregisterHandlers();
|
|
1792
|
+
if (intervalId) clearInterval(intervalId);
|
|
1793
|
+
if (spinnerIntervalId) clearInterval(spinnerIntervalId);
|
|
1794
|
+
screen.remove(contentBox);
|
|
1795
|
+
},
|
|
1796
|
+
resume: () => {
|
|
1797
|
+
screen.append(contentBox);
|
|
1798
|
+
registerHandlers();
|
|
1799
|
+
// Re-render with last known data (instant, no loading)
|
|
1800
|
+
let content = '';
|
|
1801
|
+
if (viewMode === 'list') {
|
|
1802
|
+
content = renderListView(lastSystemMetrics);
|
|
1803
|
+
} else {
|
|
1804
|
+
content = renderDetailView(lastSystemMetrics);
|
|
1805
|
+
}
|
|
1806
|
+
contentBox.setContent(content);
|
|
1807
|
+
screen.render();
|
|
1808
|
+
// Resume polling
|
|
1809
|
+
startPolling();
|
|
1810
|
+
},
|
|
1811
|
+
getServers: () => servers,
|
|
1812
|
+
};
|
|
779
1813
|
|
|
780
|
-
// Initial
|
|
781
|
-
|
|
782
|
-
|
|
1814
|
+
// Initial registration
|
|
1815
|
+
registerHandlers();
|
|
1816
|
+
|
|
1817
|
+
// Initial display - skip "Connecting" message when returning from another view
|
|
1818
|
+
if (!skipConnectingMessage) {
|
|
1819
|
+
contentBox.setContent('{cyan-fg}⏳ Connecting to servers...{/cyan-fg}');
|
|
1820
|
+
screen.render();
|
|
1821
|
+
}
|
|
783
1822
|
|
|
784
1823
|
startPolling();
|
|
785
1824
|
|
|
@@ -789,4 +1828,6 @@ export async function createMultiServerMonitorUI(
|
|
|
789
1828
|
// Note: macmon child processes will automatically die when parent exits
|
|
790
1829
|
// since they're spawned with detached: false
|
|
791
1830
|
});
|
|
1831
|
+
|
|
1832
|
+
return controls;
|
|
792
1833
|
}
|