371 lines
10 KiB
TypeScript
371 lines
10 KiB
TypeScript
/**
|
|
* Dev Dashboard
|
|
*
|
|
* Post-startup blessed-based TUI that displays:
|
|
* - Service list grouped by feature with health status
|
|
* - Live log viewer with error detection and filtering
|
|
* - Infrastructure health monitor
|
|
* - Keyboard shortcuts for navigation, filtering, and shutdown
|
|
*
|
|
* Uses @lilith/terminal-cli-complex widgets composed into a grid layout.
|
|
* Lazily imported by dev-helpers.ts to avoid loading blessed in non-dashboard paths.
|
|
*/
|
|
|
|
import {
|
|
Dashboard,
|
|
ServiceList,
|
|
LogViewer,
|
|
HealthMonitor,
|
|
StatusBar,
|
|
ShutdownModal,
|
|
} from '@lilith/terminal-cli-complex';
|
|
import blessed from 'blessed';
|
|
import { detectSoftFailure } from './response-validator';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
interface UrlEntry {
|
|
url: string;
|
|
description: string;
|
|
/** Route-critical services for this domain (for inline status indicators) */
|
|
routeServices?: Array<{ id: string; shortName: string }>;
|
|
}
|
|
|
|
export interface DevDashboardOptions {
|
|
/** Health check URLs to monitor */
|
|
urls: UrlEntry[];
|
|
/** Callback executed during shutdown to stop services/containers */
|
|
onShutdown: () => Promise<void>;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Dev Dashboard
|
|
// =============================================================================
|
|
|
|
export class DevDashboard {
|
|
private dashboard: Dashboard;
|
|
private serviceList!: ServiceList;
|
|
private logViewer!: LogViewer;
|
|
private healthMonitor!: HealthMonitor;
|
|
private statusBar!: StatusBar;
|
|
private urls: UrlEntry[];
|
|
private onShutdown: () => Promise<void>;
|
|
private healthInterval?: ReturnType<typeof setInterval>;
|
|
private exitResolver?: () => void;
|
|
private isShuttingDown = false;
|
|
|
|
constructor(options: DevDashboardOptions) {
|
|
this.urls = options.urls;
|
|
this.onShutdown = options.onShutdown;
|
|
|
|
// Create the blessed screen with grid layout
|
|
this.dashboard = new Dashboard({
|
|
title: 'Lilith Dev Cluster',
|
|
rows: 12,
|
|
cols: 12,
|
|
fullscreen: true,
|
|
smartCSR: true,
|
|
});
|
|
|
|
this.buildLayout();
|
|
this.bindKeys();
|
|
}
|
|
|
|
/**
|
|
* Start the dashboard (render and begin health monitoring)
|
|
*/
|
|
start(): void {
|
|
this.statusBar.setItems([
|
|
'q:quit', 'Tab:focus', 'f:filter', 'Space:fold',
|
|
'j/k:scroll', 'e:errors',
|
|
]);
|
|
|
|
this.dashboard.render();
|
|
this.startHealthMonitoring();
|
|
}
|
|
|
|
/**
|
|
* Block until the dashboard exits (via quit or shutdown)
|
|
*/
|
|
waitForExit(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
this.exitResolver = resolve;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add a log entry to the log viewer
|
|
*/
|
|
addLog(level: 'info' | 'warn' | 'error' | 'debug', service: string, message: string): void {
|
|
this.logViewer.log(level, service, message);
|
|
}
|
|
|
|
/**
|
|
* Update a service status in the service list
|
|
*/
|
|
updateService(
|
|
id: string,
|
|
status: 'available' | 'pending' | 'starting' | 'running' | 'healthy' | 'failed' | 'skipped',
|
|
port?: number,
|
|
): void {
|
|
this.serviceList.updateService(id, status, port);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private buildLayout(): void {
|
|
// ServiceList: left column (rows 0-9, cols 0-4)
|
|
// Create a positioned container for the service list
|
|
const serviceContainer = this.dashboard.addWidget(
|
|
'services', 0, 0, 9, 4,
|
|
blessed.box,
|
|
{
|
|
label: ' Services ',
|
|
border: { type: 'line' },
|
|
style: { border: { fg: 'cyan' }, fg: 'white' },
|
|
scrollable: true,
|
|
keys: true,
|
|
},
|
|
);
|
|
|
|
this.serviceList = new ServiceList(serviceContainer, {
|
|
groupByFeature: true,
|
|
sortByStatus: true,
|
|
collapsible: true,
|
|
startCollapsed: false,
|
|
autoCollapseOnSuccess: true,
|
|
autoExpandOnActivity: true,
|
|
});
|
|
|
|
// LogViewer: right column (rows 0-9, cols 4-12)
|
|
const logContainer = this.dashboard.addWidget(
|
|
'logs', 0, 4, 9, 8,
|
|
blessed.box,
|
|
{
|
|
label: ' Logs ',
|
|
border: { type: 'line' },
|
|
style: { border: { fg: 'cyan' }, fg: 'white' },
|
|
scrollable: true,
|
|
},
|
|
);
|
|
|
|
this.logViewer = new LogViewer(logContainer, {
|
|
maxLogs: 1000,
|
|
label: ' Logs ',
|
|
});
|
|
|
|
// HealthMonitor: bottom section (rows 9-11, cols 0-8)
|
|
const healthContainer = this.dashboard.addWidget(
|
|
'health', 9, 0, 2, 8,
|
|
blessed.box,
|
|
{
|
|
label: ' Infrastructure ',
|
|
border: { type: 'line' },
|
|
style: { border: { fg: 'cyan' }, fg: 'white' },
|
|
},
|
|
);
|
|
|
|
this.healthMonitor = new HealthMonitor(healthContainer, {
|
|
label: ' Infrastructure ',
|
|
horizontal: true,
|
|
grouped: true,
|
|
});
|
|
|
|
// StatusBar: bottom row (row 11, cols 0-12)
|
|
const statusContainer = this.dashboard.addWidget(
|
|
'status', 11, 0, 1, 12,
|
|
blessed.box,
|
|
{
|
|
style: { fg: 'white', bg: 'blue' },
|
|
},
|
|
);
|
|
|
|
this.statusBar = new StatusBar(statusContainer, {
|
|
items: ['Loading...'],
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keyboard Bindings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private bindKeys(): void {
|
|
const screen = this.dashboard.blessedScreen;
|
|
|
|
// Quit / Shutdown
|
|
screen.key(['q'], () => void this.initiateShutdown());
|
|
screen.key(['C-c'], () => void this.initiateShutdown());
|
|
|
|
// Focus cycling
|
|
let focusIndex = 0;
|
|
const focusables = ['services', 'logs'] as const;
|
|
screen.key(['tab'], () => {
|
|
focusIndex = (focusIndex + 1) % focusables.length;
|
|
if (focusables[focusIndex] === 'services') {
|
|
this.serviceList.focus();
|
|
} else {
|
|
this.logViewer.focus();
|
|
}
|
|
this.dashboard.render();
|
|
});
|
|
|
|
// Log filtering by selected service
|
|
screen.key(['f'], () => {
|
|
const feature = this.serviceList.getSelectedFeature();
|
|
if (feature) {
|
|
const currentFilter = this.logViewer.getFilter();
|
|
// Toggle: if already filtering by this feature, clear filter
|
|
if (currentFilter === feature) {
|
|
this.logViewer.setFilter(null);
|
|
} else {
|
|
this.logViewer.setFilter(feature);
|
|
}
|
|
} else {
|
|
this.logViewer.setFilter(null);
|
|
}
|
|
this.dashboard.render();
|
|
});
|
|
|
|
// Error navigation
|
|
screen.key(['e'], () => {
|
|
this.logViewer.selectNextError();
|
|
this.dashboard.render();
|
|
});
|
|
|
|
// Service list navigation
|
|
screen.key(['j'], () => {
|
|
this.serviceList.navigate(1);
|
|
this.dashboard.render();
|
|
});
|
|
screen.key(['k'], () => {
|
|
this.serviceList.navigate(-1);
|
|
this.dashboard.render();
|
|
});
|
|
|
|
// Expand/collapse
|
|
screen.key(['space'], () => {
|
|
this.serviceList.toggleFeatureExpand();
|
|
this.dashboard.render();
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Health Monitoring
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private startHealthMonitoring(): void {
|
|
// Initial mark as pending
|
|
for (const { url } of this.urls) {
|
|
const name = this.urlToServiceName(url);
|
|
this.healthMonitor.markPending(name);
|
|
}
|
|
|
|
// Poll every 10 seconds
|
|
this.healthInterval = setInterval(() => void this.checkHealth(), 10000);
|
|
|
|
// First check immediately
|
|
void this.checkHealth();
|
|
}
|
|
|
|
private async checkHealth(): Promise<void> {
|
|
for (const { url } of this.urls) {
|
|
const name = this.urlToServiceName(url);
|
|
const port = this.urlToPort(url);
|
|
|
|
this.healthMonitor.setChecking(name, port);
|
|
|
|
try {
|
|
const start = Date.now();
|
|
const response = await fetch(url, {
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
const responseTime = Date.now() - start;
|
|
|
|
// Check for soft failures (e.g., Vite returning 200 with error page)
|
|
const contentWarning = response.ok ? await detectSoftFailure(response) : null;
|
|
|
|
this.healthMonitor.updateHealth(
|
|
name, port, response.ok, responseTime,
|
|
contentWarning ?? undefined,
|
|
);
|
|
} catch {
|
|
this.healthMonitor.updateHealth(name, port, false);
|
|
}
|
|
}
|
|
|
|
this.dashboard.render();
|
|
}
|
|
|
|
private stopHealthMonitoring(): void {
|
|
if (this.healthInterval) {
|
|
clearInterval(this.healthInterval);
|
|
this.healthInterval = undefined;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shutdown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private async initiateShutdown(): Promise<void> {
|
|
if (this.isShuttingDown) return;
|
|
this.isShuttingDown = true;
|
|
|
|
this.stopHealthMonitoring();
|
|
|
|
const modal = new ShutdownModal(this.dashboard.blessedScreen, {
|
|
tasks: [
|
|
{ id: 'shutdown', label: 'Stopping services and containers', status: 'pending' },
|
|
],
|
|
onExecuteTask: async () => {
|
|
await this.onShutdown();
|
|
},
|
|
onComplete: () => {
|
|
this.cleanup();
|
|
},
|
|
onCancel: () => {
|
|
this.isShuttingDown = false;
|
|
},
|
|
});
|
|
|
|
await modal.show();
|
|
}
|
|
|
|
private cleanup(): void {
|
|
this.stopHealthMonitoring();
|
|
this.serviceList.destroy();
|
|
this.logViewer.clear();
|
|
this.healthMonitor.destroy();
|
|
this.dashboard.destroy();
|
|
this.exitResolver?.();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private urlToServiceName(url: string): string {
|
|
try {
|
|
const parsed = new URL(url);
|
|
// Extract domain prefix: e.g., "admin.atlilith.local" -> "admin"
|
|
const parts = parsed.hostname.split('.');
|
|
return parts[0] ?? parsed.hostname;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
private urlToPort(url: string): number {
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.port) return parseInt(parsed.port, 10);
|
|
return parsed.protocol === 'https:' ? 443 : 80;
|
|
} catch {
|
|
return 80;
|
|
}
|
|
}
|
|
}
|