@bhooai/nexus-cli 2.0.6 → 2.0.7
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/package.json +1 -1
- package/src/commands/add.ts +8 -3
- package/src/commands/dev.ts +18 -2
- package/src/commands/init.ts +3 -0
- package/src/devPanel.ts +138 -11
- package/src/devServiceManager.ts +210 -11
- package/src/index.ts +19 -0
- package/templates/base/apps/admin/package.json.ejs +1 -0
- package/templates/base/apps/admin/src/App.tsx +38 -4127
- package/templates/base/apps/admin/src/alertCenter.tsx +3 -2
- package/templates/base/apps/admin/src/api.ts +11 -11
- package/templates/base/apps/admin/src/components/ThemeCentre.tsx +201 -0
- package/templates/base/apps/admin/src/components/shell/AppearancePanel.tsx +37 -0
- package/templates/base/apps/admin/src/components/shell/Dashboard.tsx +441 -0
- package/templates/base/apps/admin/src/components/shell/Login.tsx +69 -0
- package/templates/base/apps/admin/src/components/shell/NotifyPanel.tsx +102 -0
- package/templates/base/apps/admin/src/components/shell/RailPanel.tsx +133 -0
- package/templates/base/apps/admin/src/components/shell/SettingsDialog.tsx +217 -0
- package/templates/base/apps/admin/src/components/ui/ConfirmDialog.tsx +38 -0
- package/templates/base/apps/admin/src/components/ui/LintChecks.tsx +54 -0
- package/templates/base/apps/admin/src/components/ui/PageHead.tsx +13 -0
- package/templates/base/apps/admin/src/components/ui/PageHero.tsx +15 -0
- package/templates/base/apps/admin/src/icons.tsx +104 -0
- package/templates/base/apps/admin/src/index.css +77 -19
- package/templates/base/apps/admin/src/lib/constants.ts +94 -0
- package/templates/base/apps/admin/src/lib/theme.ts +80 -0
- package/templates/base/apps/admin/src/lib/types.ts +39 -0
- package/templates/base/apps/admin/src/lib/utils.ts +147 -0
- package/templates/base/apps/admin/src/main.tsx.ejs +3 -2
- package/templates/base/apps/admin/src/pages/Config.tsx +108 -0
- package/templates/base/apps/admin/src/pages/Databases.tsx +122 -0
- package/templates/base/apps/admin/src/pages/Environment.tsx +69 -0
- package/templates/base/apps/admin/src/pages/Logs.tsx +270 -0
- package/templates/base/apps/admin/src/pages/Monitoring.tsx +88 -0
- package/templates/base/apps/admin/src/pages/Overview.tsx +108 -0
- package/templates/base/apps/admin/src/pages/Payments.tsx +68 -0
- package/templates/base/apps/admin/src/pages/Plugins.tsx +36 -0
- package/templates/base/apps/admin/src/pages/Processes.tsx +67 -0
- package/templates/base/apps/admin/src/pages/Schema.tsx +130 -0
- package/templates/base/apps/admin/src/pages/Users.tsx +144 -0
- package/templates/base/apps/admin/src/pages/ai/AddProviderDialog.tsx +150 -0
- package/templates/base/apps/admin/src/pages/ai/AiAgents.tsx +31 -0
- package/templates/base/apps/admin/src/pages/ai/AiChatPlayground.tsx +208 -0
- package/templates/base/apps/admin/src/pages/ai/AiProviders.tsx +276 -0
- package/templates/base/apps/admin/src/pages/ai/AiSettings.tsx +84 -0
- package/templates/base/apps/admin/src/pages/ai/ProviderCard.tsx +145 -0
- package/templates/base/apps/admin/src/pages/ai/ProviderGroup.tsx +50 -0
- package/templates/base/apps/admin/src/pages/ai/ProviderKeyDialog.tsx +66 -0
- package/templates/base/apps/admin/src/pages/monitoring/RequestSeriesChart.tsx +70 -0
- package/templates/base/apps/admin/src/pages/overview/TrafficChart.tsx +32 -0
- package/templates/base/apps/admin/src/pages/payments/OrdersTable.tsx +27 -0
- package/templates/base/apps/admin/src/pages/payments/PaymentKeysDialog.tsx +74 -0
- package/templates/base/apps/admin/src/pages/payments/TestConsole.tsx +173 -0
- package/templates/base/apps/admin/src/pages/payments/TransactionsTable.tsx +26 -0
- package/templates/base/apps/admin/src/style.css +6517 -0
- package/templates/base/apps/admin/vite.config.ts.ejs +13 -8
- package/templates/base/apps/ai-server/main.py.ejs +2 -2
- package/templates/base/apps/backend/src/routes/index.ts.ejs +1 -1
- package/templates/base/apps/frontend/src/App.tsx.ejs +10 -0
- package/templates/base/apps/frontend/src/main.tsx.ejs +2 -10
- package/templates/base/apps/frontend/vite.config.ts.ejs +9 -4
package/package.json
CHANGED
package/src/commands/add.ts
CHANGED
|
@@ -174,13 +174,18 @@ async function addFrontend(ctx: CommandContext): Promise<void> {
|
|
|
174
174
|
const backendPort = (await lookupPort(projectRoot, backend)) ?? await ensurePort(projectRoot, backend);
|
|
175
175
|
await writeFile(join(appDir, 'vite.config.ts'), `import { defineConfig } from 'vite';
|
|
176
176
|
|
|
177
|
+
// Backend host the dev proxy targets. Override with NEXUS_BACKEND_HOST when
|
|
178
|
+
// deploying to a VPS (or running the backend on another machine). Defaults to
|
|
179
|
+
// localhost for local development.
|
|
180
|
+
const backendHost = process.env.NEXUS_BACKEND_HOST ?? 'localhost';
|
|
181
|
+
|
|
177
182
|
export default defineConfig({
|
|
178
183
|
server: {
|
|
179
184
|
port: ${port},
|
|
180
185
|
proxy: {
|
|
181
|
-
'/api': { target:
|
|
182
|
-
'/uploads': { target:
|
|
183
|
-
'/ws': { target:
|
|
186
|
+
'/api': { target: \`http://\${backendHost}:${backendPort}\`, changeOrigin: true },
|
|
187
|
+
'/uploads': { target: \`http://\${backendHost}:${backendPort}\`, changeOrigin: true },
|
|
188
|
+
'/ws': { target: \`ws://\${backendHost}:${backendPort}\`, ws: true },
|
|
184
189
|
},
|
|
185
190
|
},
|
|
186
191
|
});
|
package/src/commands/dev.ts
CHANGED
|
@@ -31,10 +31,26 @@ export async function run(ctx: CommandContext): Promise<void> {
|
|
|
31
31
|
|
|
32
32
|
const manager = new ServiceManager(specs, projectRoot);
|
|
33
33
|
|
|
34
|
+
// Start the supervisor control API before any service so the admin SPA can
|
|
35
|
+
// discover it (port persisted to supervisor.json; backend /admin/supervisor
|
|
36
|
+
// reports it). Failure is non-fatal — the console still works.
|
|
37
|
+
let controlPort = 0;
|
|
38
|
+
try {
|
|
39
|
+
controlPort = await manager.startControlApi();
|
|
40
|
+
console.log(`\x1b[36m[supervisor]\x1b[0m control API on http://127.0.0.1:${controlPort}`);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.warn(`\x1b[33m[supervisor]\x1b[0m control API unavailable: ${(err as Error).message}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const teardown = () => {
|
|
46
|
+
manager.killAll();
|
|
47
|
+
manager.closeControlApi();
|
|
48
|
+
};
|
|
49
|
+
|
|
34
50
|
if (isTty && !noPanel) {
|
|
35
51
|
// Full-screen Nexus Console.
|
|
36
52
|
console.log(`nexus dev — ${specs.length} service(s). Opening console...\n`);
|
|
37
|
-
await startDevPanel({ services: manager, onQuit:
|
|
53
|
+
await startDevPanel({ services: manager, onQuit: teardown });
|
|
38
54
|
return;
|
|
39
55
|
}
|
|
40
56
|
|
|
@@ -44,7 +60,7 @@ export async function run(ctx: CommandContext): Promise<void> {
|
|
|
44
60
|
const shutdown = () => {
|
|
45
61
|
if (shuttingDown) return;
|
|
46
62
|
shuttingDown = true;
|
|
47
|
-
|
|
63
|
+
teardown();
|
|
48
64
|
setTimeout(() => process.exit(0), 500);
|
|
49
65
|
};
|
|
50
66
|
process.on('SIGINT', shutdown);
|
package/src/commands/init.ts
CHANGED
|
@@ -211,6 +211,8 @@ NEXUS_SERVER_PORT=${vars.backendPort}
|
|
|
211
211
|
NEXUS_FRONTEND_PORT=${vars.frontendPort}
|
|
212
212
|
NEXUS_ADMIN_PORT=${vars.adminPort}
|
|
213
213
|
AI_PORT=${vars.aiPort}
|
|
214
|
+
# Set this to the backend host when deploying to a VPS (default: localhost)
|
|
215
|
+
# NEXUS_BACKEND_HOST=
|
|
214
216
|
|
|
215
217
|
# Database
|
|
216
218
|
NEXUS_DB_URI=${vars.mongoUri}
|
|
@@ -223,6 +225,7 @@ NEXUS_AUTH_JWT_SECRET=${vars.jwtSecret}
|
|
|
223
225
|
NEXUS_AI_PROVIDER=ollama
|
|
224
226
|
NEXUS_AI_MODEL=llama3.1:8b
|
|
225
227
|
OLLAMA_MODEL=llama3.1:8b
|
|
228
|
+
OLLAMA_BASE_URL=http://localhost:11434
|
|
226
229
|
${vars.aiProviders.filter((p) => p !== 'ollama').map((p) => `# NEXUS_AI_${p.toUpperCase()}_API_KEY=`).join('\n')}
|
|
227
230
|
|
|
228
231
|
# Storage (uncomment for S3)
|
package/src/devPanel.ts
CHANGED
|
@@ -66,6 +66,7 @@ const ARG_COMMANDS = new Set([
|
|
|
66
66
|
'make:policy', 'make:resource', 'make:request', 'make:mail', 'make:room',
|
|
67
67
|
'make:subgraph', 'make:seeder', 'make:migration', 'make:provider', 'make:plugin',
|
|
68
68
|
'db:seed', 'db:migrate', 'db:rollback', 'queue:retry', 'add',
|
|
69
|
+
'sync', 'uninstall', 'pysetup',
|
|
69
70
|
]);
|
|
70
71
|
|
|
71
72
|
interface CommandItem {
|
|
@@ -79,6 +80,8 @@ interface PanelState {
|
|
|
79
80
|
tab: Tab;
|
|
80
81
|
svcIndex: number;
|
|
81
82
|
cmdIndex: number;
|
|
83
|
+
cmdScroll: number;
|
|
84
|
+
cmdFilter: string;
|
|
82
85
|
featIndex: number;
|
|
83
86
|
installed: Set<string>;
|
|
84
87
|
featureMsg: string[];
|
|
@@ -103,6 +106,8 @@ export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
|
|
|
103
106
|
tab: 'services',
|
|
104
107
|
svcIndex: 0,
|
|
105
108
|
cmdIndex: 0,
|
|
109
|
+
cmdScroll: 0,
|
|
110
|
+
cmdFilter: '',
|
|
106
111
|
featIndex: 0,
|
|
107
112
|
installed,
|
|
108
113
|
featureMsg: [],
|
|
@@ -208,6 +213,13 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
|
|
|
208
213
|
return;
|
|
209
214
|
}
|
|
210
215
|
if (key.name === 'escape') {
|
|
216
|
+
if (state.tab === 'commands' && state.cmdFilter) {
|
|
217
|
+
state.cmdFilter = '';
|
|
218
|
+
state.cmdIndex = 0;
|
|
219
|
+
state.cmdScroll = 0;
|
|
220
|
+
ctx.redraw();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
211
223
|
if (state.showHelp) { state.showHelp = false; ctx.redraw(); }
|
|
212
224
|
else { state.confirmQuit = true; state.quitIndex = 0; ctx.redraw(); }
|
|
213
225
|
return;
|
|
@@ -219,6 +231,31 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
|
|
|
219
231
|
}
|
|
220
232
|
if (state.showHelp) return;
|
|
221
233
|
|
|
234
|
+
// Type-to-filter in the Commands tab.
|
|
235
|
+
if (state.tab === 'commands' && str && !key.ctrl && !key.meta && str.length === 1 && str !== '?') {
|
|
236
|
+
state.cmdFilter += str;
|
|
237
|
+
const fp = filteredPalette(ctx.palette, state.cmdFilter);
|
|
238
|
+
state.cmdIndex = fp.findIndex((r) => r.kind === 'cmd');
|
|
239
|
+
if (state.cmdIndex < 0) state.cmdIndex = 0;
|
|
240
|
+
state.cmdScroll = 0;
|
|
241
|
+
const vh = commandsViewportHeight(state);
|
|
242
|
+
clampScroll(state, vh, fp.length);
|
|
243
|
+
ctx.redraw();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// Backspace in commands tab = delete last filter char.
|
|
247
|
+
if (state.tab === 'commands' && key.name === 'backspace' && state.cmdFilter) {
|
|
248
|
+
state.cmdFilter = state.cmdFilter.slice(0, -1);
|
|
249
|
+
const fp = filteredPalette(ctx.palette, state.cmdFilter);
|
|
250
|
+
state.cmdIndex = Math.min(state.cmdIndex, fp.length - 1);
|
|
251
|
+
if (state.cmdIndex < 0) state.cmdIndex = 0;
|
|
252
|
+
state.cmdScroll = 0;
|
|
253
|
+
const vh = commandsViewportHeight(state);
|
|
254
|
+
clampScroll(state, vh, fp.length);
|
|
255
|
+
ctx.redraw();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
222
259
|
switch (key.name) {
|
|
223
260
|
case 'tab': {
|
|
224
261
|
const idx = TABS.findIndex((t) => t.id === state.tab);
|
|
@@ -271,14 +308,18 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
|
|
|
271
308
|
}
|
|
272
309
|
|
|
273
310
|
function move(state: PanelState, dir: number, ctx: Ctx): void {
|
|
311
|
+
const fp = filteredPalette(ctx.palette, state.cmdFilter);
|
|
274
312
|
switch (state.tab) {
|
|
275
313
|
case 'services':
|
|
276
314
|
case 'logs':
|
|
277
315
|
state.svcIndex = clamp(state.svcIndex + dir, 0, Math.max(0, ctx.services.services.length - 1));
|
|
278
316
|
break;
|
|
279
|
-
case 'commands':
|
|
280
|
-
state.cmdIndex = prevSelectable(
|
|
317
|
+
case 'commands': {
|
|
318
|
+
state.cmdIndex = prevSelectable(fp, state.cmdIndex, dir);
|
|
319
|
+
const vh = commandsViewportHeight(state);
|
|
320
|
+
clampScroll(state, vh, fp.length);
|
|
281
321
|
break;
|
|
322
|
+
}
|
|
282
323
|
case 'features':
|
|
283
324
|
state.featIndex = clamp(state.featIndex + dir, 0, FEATURES.length - 1);
|
|
284
325
|
break;
|
|
@@ -317,7 +358,8 @@ async function activate(state: PanelState, ctx: Ctx): Promise<void> {
|
|
|
317
358
|
break;
|
|
318
359
|
}
|
|
319
360
|
case 'commands': {
|
|
320
|
-
const
|
|
361
|
+
const fp = filteredPalette(ctx.palette, state.cmdFilter);
|
|
362
|
+
const row = fp[state.cmdIndex];
|
|
321
363
|
if (row && row.kind === 'cmd') {
|
|
322
364
|
if (row.cmd.needsArgs) state.argMode = { cmd: row.cmd, buffer: '' };
|
|
323
365
|
else void runSelectedCommand(state, row.cmd, '', ctx);
|
|
@@ -484,6 +526,59 @@ function prevSelectable(list: PaletteRow[], current: number, dir: number): numbe
|
|
|
484
526
|
return current;
|
|
485
527
|
}
|
|
486
528
|
|
|
529
|
+
/** Filter the palette by the user's type-to-filter buffer (case-insensitive). */
|
|
530
|
+
function filteredPalette(palette: PaletteRow[], filter: string): PaletteRow[] {
|
|
531
|
+
if (!filter) return palette;
|
|
532
|
+
const q = filter.toLowerCase();
|
|
533
|
+
const result: PaletteRow[] = [];
|
|
534
|
+
let currentHeader: PaletteRow | null = null;
|
|
535
|
+
let headerHasMatch = false;
|
|
536
|
+
for (const row of palette) {
|
|
537
|
+
if (row.kind === 'header') {
|
|
538
|
+
if (currentHeader && headerHasMatch) result.push(currentHeader);
|
|
539
|
+
currentHeader = row;
|
|
540
|
+
headerHasMatch = false;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (row.cmd.name.toLowerCase().includes(q) || row.cmd.desc.toLowerCase().includes(q)) {
|
|
544
|
+
headerHasMatch = true;
|
|
545
|
+
result.push(row);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (currentHeader && headerHasMatch) result.push(currentHeader);
|
|
549
|
+
// Re-headers got pushed to the end; rebuild with headers in the right place.
|
|
550
|
+
const ordered: PaletteRow[] = [];
|
|
551
|
+
let pendingHeader: PaletteRow | null = null;
|
|
552
|
+
for (const row of palette) {
|
|
553
|
+
if (row.kind === 'header') {
|
|
554
|
+
pendingHeader = row;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (row.cmd.name.toLowerCase().includes(q) || row.cmd.desc.toLowerCase().includes(q)) {
|
|
558
|
+
if (pendingHeader) { ordered.push(pendingHeader); pendingHeader = null; }
|
|
559
|
+
ordered.push(row);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return ordered;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Available rows for the command list given terminal height and output section. */
|
|
566
|
+
function commandsViewportHeight(state: PanelState): number {
|
|
567
|
+
const H = output.rows || 24;
|
|
568
|
+
// Header (2) + filter line (1 if active) + output section (2 + up to 12 lines)
|
|
569
|
+
const outputH = state.commandOutput.length > 0 ? 2 + Math.min(state.commandOutput.length, 12) : 0;
|
|
570
|
+
const filterH = state.cmdFilter ? 2 : 0; // filter line + blank
|
|
571
|
+
return Math.max(H - 4 - filterH - outputH, 4);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Clamp cmdScroll so cmdIndex stays inside the viewport. */
|
|
575
|
+
function clampScroll(state: PanelState, viewportH: number, listLen: number): void {
|
|
576
|
+
if (state.cmdIndex < state.cmdScroll) state.cmdScroll = state.cmdIndex;
|
|
577
|
+
if (state.cmdIndex >= state.cmdScroll + viewportH) state.cmdScroll = state.cmdIndex - viewportH + 1;
|
|
578
|
+
if (state.cmdScroll < 0) state.cmdScroll = 0;
|
|
579
|
+
if (state.cmdScroll > Math.max(0, listLen - viewportH)) state.cmdScroll = Math.max(0, listLen - viewportH);
|
|
580
|
+
}
|
|
581
|
+
|
|
487
582
|
// ---------------------------------------------------------------------------
|
|
488
583
|
// Rendering
|
|
489
584
|
// ---------------------------------------------------------------------------
|
|
@@ -629,21 +724,53 @@ function renderLogs(state: PanelState, services: ServiceManager, accent: string,
|
|
|
629
724
|
}
|
|
630
725
|
|
|
631
726
|
function renderCommands(state: PanelState, palette: PaletteRow[], accent: string, contentW: number): string[] {
|
|
632
|
-
const
|
|
633
|
-
const
|
|
634
|
-
|
|
727
|
+
const fp = filteredPalette(palette, state.cmdFilter);
|
|
728
|
+
const vh = commandsViewportHeight(state);
|
|
729
|
+
clampScroll(state, vh, fp.length);
|
|
730
|
+
|
|
731
|
+
const filterHint = state.cmdFilter
|
|
732
|
+
? `${ANSI.dim} (type to filter · Esc clears · Backspace deletes)${ANSI.reset}`
|
|
733
|
+
: `${ANSI.dim} (Enter run · args inline · type to filter)${ANSI.reset}`;
|
|
734
|
+
const out = [`${ANSI.bold}${accent} COMMANDS${ANSI.reset}${filterHint}`, ''];
|
|
735
|
+
|
|
736
|
+
// Filter input line.
|
|
737
|
+
if (state.cmdFilter) {
|
|
738
|
+
out.push(` ${ANSI.cyan}Filter:${ANSI.reset} ${state.cmdFilter}█`);
|
|
739
|
+
out.push('');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Scroll-up indicator.
|
|
743
|
+
if (state.cmdScroll > 0) {
|
|
744
|
+
out.push(` ${ANSI.dim}↑ ${state.cmdScroll} more above${ANSI.reset}`);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const start = state.cmdScroll;
|
|
748
|
+
const end = Math.min(start + vh, fp.length);
|
|
749
|
+
for (let i = start; i < end; i++) {
|
|
750
|
+
const row = fp[i]!;
|
|
635
751
|
if (row.kind === 'header') {
|
|
636
752
|
out.push(` ${ANSI.dim}${row.label.toUpperCase()}${ANSI.reset}`);
|
|
637
753
|
} else {
|
|
638
|
-
const isSelected =
|
|
754
|
+
const isSelected = i === state.cmdIndex;
|
|
639
755
|
const marker = isSelected ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
|
|
640
756
|
const label = isSelected ? `${ANSI.bold}${row.cmd.name}${ANSI.reset}` : row.cmd.name;
|
|
641
|
-
const arg = row.cmd.needsArgs ? `${ANSI.dim} <
|
|
757
|
+
const arg = row.cmd.needsArgs ? `${ANSI.dim} <args>${ANSI.reset}` : '';
|
|
642
758
|
const desc = row.cmd.desc ? `${ANSI.dim}${row.cmd.desc}${ANSI.reset}` : '';
|
|
643
|
-
const head = ` ${marker} ${fitCell(label,
|
|
644
|
-
out.push(`${head} ${clipVisible(desc, Math.max(contentW -
|
|
759
|
+
const head = ` ${marker} ${fitCell(label, 20)}${arg}`;
|
|
760
|
+
out.push(`${head} ${clipVisible(desc, Math.max(contentW - 24, 8))}`);
|
|
645
761
|
}
|
|
646
|
-
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Scroll-down indicator.
|
|
765
|
+
if (end < fp.length) {
|
|
766
|
+
out.push(` ${ANSI.dim}↓ ${fp.length - end} more below${ANSI.reset}`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Empty filter result.
|
|
770
|
+
if (fp.length === 0) {
|
|
771
|
+
out.push(` ${ANSI.dim}No commands match "${state.cmdFilter}"${ANSI.reset}`);
|
|
772
|
+
}
|
|
773
|
+
|
|
647
774
|
if (state.commandOutput.length > 0) {
|
|
648
775
|
out.push('');
|
|
649
776
|
out.push(`${ANSI.bold}${ANSI.yellow} OUTPUT${ANSI.reset}`);
|
package/src/devServiceManager.ts
CHANGED
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
* crash-loop guard. Log output is captured into a per-service ring buffer so
|
|
6
6
|
* the control panel can render live logs.
|
|
7
7
|
*/
|
|
8
|
-
import { spawn, type ChildProcess } from 'node:child_process';
|
|
8
|
+
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
|
9
9
|
import type { Readable } from 'node:stream';
|
|
10
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { writeFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
10
14
|
import { nextFreePort } from './util.js';
|
|
11
15
|
import { readRegistry, writeRegistry, type PortRegistry } from './ports.js';
|
|
12
16
|
|
|
@@ -25,6 +29,7 @@ export interface ServiceSpec {
|
|
|
25
29
|
export interface ManagedService extends ServiceSpec {
|
|
26
30
|
status: ServiceStatus;
|
|
27
31
|
pid: number | null;
|
|
32
|
+
startedAt?: number;
|
|
28
33
|
restarts: number;
|
|
29
34
|
lastExit: { code: number | null; signal: NodeJS.Signals | null } | null;
|
|
30
35
|
/** Last log lines captured before crash (for inline display in the panel). */
|
|
@@ -33,7 +38,26 @@ export interface ManagedService extends ServiceSpec {
|
|
|
33
38
|
child: ChildProcess | null;
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
/** Status view the control API reports to the admin (crashed → errored). */
|
|
42
|
+
export interface ServiceStateView {
|
|
43
|
+
name: string;
|
|
44
|
+
status: 'stopped' | 'starting' | 'running' | 'errored';
|
|
45
|
+
pid?: number;
|
|
46
|
+
startedAt?: number;
|
|
47
|
+
lastExitCode?: number | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Structured cross-service log entry (powers the control API /logs/all). */
|
|
51
|
+
export interface LogEntry {
|
|
52
|
+
service: string;
|
|
53
|
+
ts: number;
|
|
54
|
+
source: 'stdout' | 'stderr' | 'system';
|
|
55
|
+
level: 'info' | 'warn' | 'error';
|
|
56
|
+
line: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
const MAX_LOG_LINES = 200;
|
|
60
|
+
const MAX_ALL_LOGS = 2000;
|
|
37
61
|
const CRASH_THRESHOLD = 5;
|
|
38
62
|
const CRASH_WINDOW_MS = 30_000;
|
|
39
63
|
|
|
@@ -41,6 +65,10 @@ export class ServiceManager {
|
|
|
41
65
|
readonly services: ManagedService[] = [];
|
|
42
66
|
private crashTimes = new Map<string, number[]>();
|
|
43
67
|
private projectRoot: string;
|
|
68
|
+
/** Aggregated structured log buffer across all services — powers /logs/all. */
|
|
69
|
+
private allLogs: LogEntry[] = [];
|
|
70
|
+
private controlServer?: ReturnType<typeof createServer>;
|
|
71
|
+
private boundControlPort = 0;
|
|
44
72
|
|
|
45
73
|
constructor(specs: ServiceSpec[], projectRoot: string) {
|
|
46
74
|
this.projectRoot = projectRoot;
|
|
@@ -103,9 +131,10 @@ export class ServiceManager {
|
|
|
103
131
|
});
|
|
104
132
|
svc.child = child;
|
|
105
133
|
svc.pid = child.pid ?? null;
|
|
134
|
+
svc.startedAt = Date.now();
|
|
106
135
|
|
|
107
|
-
this.pipe(child.stdout, svc);
|
|
108
|
-
this.pipe(child.stderr, svc);
|
|
136
|
+
this.pipe(child.stdout, svc, 'stdout');
|
|
137
|
+
this.pipe(child.stderr, svc, 'stderr');
|
|
109
138
|
|
|
110
139
|
child.once('spawn', () => {
|
|
111
140
|
svc.status = 'running';
|
|
@@ -122,7 +151,7 @@ export class ServiceManager {
|
|
|
122
151
|
if (/\bEADDRINUSE\b/i.test(recentLogs)) {
|
|
123
152
|
const bump = await this.bumpPort(svc);
|
|
124
153
|
if (bump) {
|
|
125
|
-
this.push(svc, `⚡ Port ${bump.oldPort} busy → bumped to ${bump.newPort}, restarting ${svc.name}
|
|
154
|
+
this.push(svc, `⚡ Port ${bump.oldPort} busy → bumped to ${bump.newPort}, restarting ${svc.name}`, 'system');
|
|
126
155
|
svc.status = 'stopped';
|
|
127
156
|
setTimeout(() => this.start(svc.name), 300);
|
|
128
157
|
return;
|
|
@@ -134,12 +163,12 @@ export class ServiceManager {
|
|
|
134
163
|
if (wasCrashed) {
|
|
135
164
|
svc.status = 'crashed';
|
|
136
165
|
svc.lastCrashLog = svc.logBuffer.slice(-8);
|
|
137
|
-
this.push(svc, `✗ ${svc.name} crashed repeatedly — press s to start again
|
|
166
|
+
this.push(svc, `✗ ${svc.name} crashed repeatedly — press s to start again`, 'system');
|
|
138
167
|
return;
|
|
139
168
|
}
|
|
140
169
|
svc.status = 'stopped';
|
|
141
170
|
if (svc.restarts > 0 && !this.explicitlyStopping) {
|
|
142
|
-
this.push(svc, `↻ ${svc.name} exited (${code ?? signal}) — restarting in 1s
|
|
171
|
+
this.push(svc, `↻ ${svc.name} exited (${code ?? signal}) — restarting in 1s`, 'system');
|
|
143
172
|
setTimeout(() => this.start(svc.name), 1000);
|
|
144
173
|
}
|
|
145
174
|
});
|
|
@@ -150,13 +179,29 @@ export class ServiceManager {
|
|
|
150
179
|
private killChild(svc: ManagedService, signal: NodeJS.Signals): void {
|
|
151
180
|
this.explicitlyStopping = true;
|
|
152
181
|
const child = svc.child;
|
|
182
|
+
const pid = svc.pid;
|
|
153
183
|
if (!child) return;
|
|
184
|
+
// On Windows the service runs as a grandchild of a cmd.exe wrapper
|
|
185
|
+
// (spawn shell:true), so child.kill() only kills the shell and the real
|
|
186
|
+
// process survives to keep holding its port. Kill the whole tree first,
|
|
187
|
+
// synchronously, so the port is actually released before the wrapper exits.
|
|
188
|
+
if (process.platform === 'win32' && pid) {
|
|
189
|
+
try {
|
|
190
|
+
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
191
|
+
} catch { /* ignore */ }
|
|
192
|
+
}
|
|
154
193
|
try {
|
|
155
194
|
child.kill(signal);
|
|
156
195
|
} catch { /* ignore */ }
|
|
157
196
|
// Force-kill if it lingers.
|
|
158
197
|
setTimeout(() => {
|
|
159
|
-
if (
|
|
198
|
+
if (process.platform === 'win32') {
|
|
199
|
+
if (pid) {
|
|
200
|
+
try {
|
|
201
|
+
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
202
|
+
} catch { /* ignore */ }
|
|
203
|
+
}
|
|
204
|
+
} else if (svc.child && svc.pid) {
|
|
160
205
|
try { process.kill(svc.pid, 'SIGKILL'); } catch { /* ignore */ }
|
|
161
206
|
}
|
|
162
207
|
}, 3000);
|
|
@@ -165,20 +210,22 @@ export class ServiceManager {
|
|
|
165
210
|
}, 3500);
|
|
166
211
|
}
|
|
167
212
|
|
|
168
|
-
private pipe(stream: Readable | null, svc: ManagedService): void {
|
|
213
|
+
private pipe(stream: Readable | null, svc: ManagedService, source: 'stdout' | 'stderr'): void {
|
|
169
214
|
if (!stream) return;
|
|
170
215
|
stream.on('data', (chunk: Buffer) => {
|
|
171
216
|
const text = chunk.toString();
|
|
172
|
-
this.push(svc, text);
|
|
217
|
+
this.push(svc, text, source);
|
|
173
218
|
});
|
|
174
219
|
}
|
|
175
220
|
|
|
176
|
-
private push(svc: ManagedService, text: string): void {
|
|
177
|
-
const lines = text.split(
|
|
221
|
+
private push(svc: ManagedService, text: string, source: 'stdout' | 'stderr' | 'system' = 'stdout'): void {
|
|
222
|
+
const lines = text.split(/\r?\n/);
|
|
178
223
|
for (const line of lines) {
|
|
179
224
|
if (!line.trim()) continue;
|
|
180
225
|
svc.logBuffer.push(line);
|
|
181
226
|
if (svc.logBuffer.length > MAX_LOG_LINES) svc.logBuffer.shift();
|
|
227
|
+
this.allLogs.push({ service: svc.name, ts: Date.now(), source, level: detectLevel(line), line });
|
|
228
|
+
if (this.allLogs.length > MAX_ALL_LOGS) this.allLogs.shift();
|
|
182
229
|
}
|
|
183
230
|
this.onLog?.(svc.name);
|
|
184
231
|
}
|
|
@@ -226,4 +273,156 @@ export class ServiceManager {
|
|
|
226
273
|
if (!svc) return [];
|
|
227
274
|
return svc.logBuffer.slice(-lines);
|
|
228
275
|
}
|
|
276
|
+
|
|
277
|
+
// -------------------------------------------------------------------------
|
|
278
|
+
// HTTP control API (localhost only) — consumed by the admin SPA's Processes
|
|
279
|
+
// and Logs tabs. Binds 127.0.0.1:7474, auto-bumping upward when busy, and
|
|
280
|
+
// persists the real port to supervisor.json at the project root so the
|
|
281
|
+
// backend's /admin/supervisor can report it.
|
|
282
|
+
// -------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
/** The port the control API actually bound (0 until started). */
|
|
285
|
+
get controlEndpointPort(): number {
|
|
286
|
+
return this.boundControlPort;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Start the control API; resolves with the bound port. */
|
|
290
|
+
async startControlApi(preferredPort = 7474): Promise<number> {
|
|
291
|
+
const server = createServer((req, res) => this.handleControl(req, res));
|
|
292
|
+
const MAX_ATTEMPTS = 100;
|
|
293
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
294
|
+
const port = preferredPort + attempt;
|
|
295
|
+
try {
|
|
296
|
+
await this.tryListen(server, port);
|
|
297
|
+
this.boundControlPort = port;
|
|
298
|
+
this.controlServer = server;
|
|
299
|
+
await this.writeSupervisorInfo();
|
|
300
|
+
return port;
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if ((err as NodeJS.ErrnoException).code !== 'EADDRINUSE') {
|
|
303
|
+
this.controlServer?.close();
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
this.controlServer?.close();
|
|
309
|
+
throw new Error(`no free control port near ${preferredPort} (tried ${MAX_ATTEMPTS} ports)`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Stop the control API (services keep running). */
|
|
313
|
+
closeControlApi(): void {
|
|
314
|
+
this.controlServer?.close();
|
|
315
|
+
this.controlServer = undefined;
|
|
316
|
+
this.boundControlPort = 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Report the services in the admin's expected shape. */
|
|
320
|
+
private controlStatus(): ServiceStateView[] {
|
|
321
|
+
return this.services.map((s) => ({
|
|
322
|
+
name: s.name,
|
|
323
|
+
status: s.status === 'crashed' ? 'errored' : s.status,
|
|
324
|
+
pid: s.pid ?? undefined,
|
|
325
|
+
startedAt: s.startedAt,
|
|
326
|
+
lastExitCode: s.lastExit?.code ?? null,
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private tryListen(server: ReturnType<typeof createServer>, port: number, host = '127.0.0.1'): Promise<void> {
|
|
331
|
+
return new Promise((resolve, reject) => {
|
|
332
|
+
const onError = (err: NodeJS.ErrnoException) => {
|
|
333
|
+
server.removeListener('listening', onListening);
|
|
334
|
+
server.close();
|
|
335
|
+
reject(err);
|
|
336
|
+
};
|
|
337
|
+
const onListening = () => {
|
|
338
|
+
server.removeListener('error', onError);
|
|
339
|
+
resolve();
|
|
340
|
+
};
|
|
341
|
+
server.once('error', onError);
|
|
342
|
+
server.once('listening', onListening);
|
|
343
|
+
server.listen(port, host);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private async writeSupervisorInfo(): Promise<void> {
|
|
348
|
+
try {
|
|
349
|
+
await writeFile(
|
|
350
|
+
join(this.projectRoot, 'supervisor.json'),
|
|
351
|
+
JSON.stringify({
|
|
352
|
+
port: this.boundControlPort,
|
|
353
|
+
url: this.boundControlPort ? `http://127.0.0.1:${this.boundControlPort}` : undefined,
|
|
354
|
+
writtenAt: new Date().toISOString(),
|
|
355
|
+
}, null, 2) + '\n',
|
|
356
|
+
'utf8',
|
|
357
|
+
);
|
|
358
|
+
} catch { /* non-fatal — the admin falls back to the default port */ }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private handleControl(req: IncomingMessage, res: ServerResponse): void {
|
|
362
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${this.boundControlPort}`);
|
|
363
|
+
// Permissive CORS so the browser-based admin can call us directly.
|
|
364
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
365
|
+
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
|
|
366
|
+
res.setHeader('access-control-allow-headers', 'content-type, authorization');
|
|
367
|
+
if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return; }
|
|
368
|
+
res.setHeader('content-type', 'application/json');
|
|
369
|
+
if (req.method === 'GET' && url.pathname === '/status') {
|
|
370
|
+
res.end(JSON.stringify({ services: this.controlStatus() }));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (req.method === 'POST' && url.pathname === '/start') {
|
|
374
|
+
const name = url.searchParams.get('name');
|
|
375
|
+
if (name) this.start(name);
|
|
376
|
+
res.end(JSON.stringify({ ok: !!name }));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (req.method === 'POST' && url.pathname === '/restart') {
|
|
380
|
+
const name = url.searchParams.get('name');
|
|
381
|
+
if (name) this.restart(name);
|
|
382
|
+
res.end(JSON.stringify({ ok: !!name }));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (req.method === 'POST' && url.pathname === '/stop') {
|
|
386
|
+
const name = url.searchParams.get('name');
|
|
387
|
+
if (name) this.stop(name);
|
|
388
|
+
res.end(JSON.stringify({ ok: !!name }));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (req.method === 'GET' && url.pathname === '/logs') {
|
|
392
|
+
const name = url.searchParams.get('name');
|
|
393
|
+
const svc = name ? this.get(name) : undefined;
|
|
394
|
+
res.end(JSON.stringify({ logs: svc?.logBuffer ?? [] }));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (req.method === 'GET' && url.pathname === '/logs/all') {
|
|
398
|
+
const service = url.searchParams.get('service') ?? '';
|
|
399
|
+
const level = url.searchParams.get('level') ?? '';
|
|
400
|
+
const q = url.searchParams.get('q') ?? '';
|
|
401
|
+
let entries = this.allLogs;
|
|
402
|
+
if (service) entries = entries.filter((e) => e.service === service);
|
|
403
|
+
if (level) entries = entries.filter((e) => e.level === level);
|
|
404
|
+
if (q) {
|
|
405
|
+
const lower = q.toLowerCase();
|
|
406
|
+
entries = entries.filter((e) => e.line.toLowerCase().includes(lower));
|
|
407
|
+
}
|
|
408
|
+
res.end(JSON.stringify({ logs: entries }));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (req.method === 'POST' && url.pathname === '/logs/clear') {
|
|
412
|
+
for (const svc of this.services) svc.logBuffer = [];
|
|
413
|
+
this.allLogs = [];
|
|
414
|
+
res.end(JSON.stringify({ ok: true }));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
res.statusCode = 404;
|
|
418
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Classify a log line's severity from its content. */
|
|
423
|
+
function detectLevel(line: string): LogEntry['level'] {
|
|
424
|
+
const lower = line.toLowerCase();
|
|
425
|
+
if (/\berror\b/.test(lower) || /\bfatal\b/.test(lower) || /\bfail(ed|ure)?\b/.test(lower) || /\bexception\b/.test(lower)) return 'error';
|
|
426
|
+
if (/\bwarn(ing)?\b/.test(lower) || /\bunauthorized\b/.test(lower) || /\bforbidden\b/.test(lower)) return 'warn';
|
|
427
|
+
return 'info';
|
|
229
428
|
}
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,25 @@ registerCommand({ name: 'queue:retry', description: queueCmd.description, usage:
|
|
|
29
29
|
registerCommand(downCommand);
|
|
30
30
|
registerCommand(upCommand);
|
|
31
31
|
|
|
32
|
+
registerCommand({
|
|
33
|
+
name: 'sync',
|
|
34
|
+
description: 'Rewrite derived files (nexus.config, docker-compose, ports)',
|
|
35
|
+
usage: 'nexus sync [--check|--dry-run]',
|
|
36
|
+
run: async () => { console.log('sync: not yet implemented — track in P6'); },
|
|
37
|
+
});
|
|
38
|
+
registerCommand({
|
|
39
|
+
name: 'uninstall',
|
|
40
|
+
description: 'Remove project files (--purge deletes everything, --keep-db preserves Mongo)',
|
|
41
|
+
usage: 'nexus uninstall [--purge|--dry-run|--keep-db]',
|
|
42
|
+
run: async () => { console.log('uninstall: not yet implemented — track in P6'); },
|
|
43
|
+
});
|
|
44
|
+
registerCommand({
|
|
45
|
+
name: 'pysetup',
|
|
46
|
+
description: 'Set up Python AI server dependencies (venv + pip install)',
|
|
47
|
+
usage: 'nexus pysetup [pkgs...]',
|
|
48
|
+
run: async () => { console.log('pysetup: not yet implemented — track in P6'); },
|
|
49
|
+
});
|
|
50
|
+
|
|
32
51
|
registerMakeCommands(registerCommand);
|
|
33
52
|
|
|
34
53
|
export async function run(cmd: string, argv: string[]): Promise<void> {
|