platform-tooling/run/core/dev-dashboard.ts
Quinn Ftw 05019fbcc2 feat(cli): Update TypeScript files in CLI module
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-01 07:18:51 -08:00

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;
}
}
}