diff --git a/run/cli/commands/dev-helpers.ts b/run/cli/commands/dev-helpers.ts index 53fe005..6a44205 100644 --- a/run/cli/commands/dev-helpers.ts +++ b/run/cli/commands/dev-helpers.ts @@ -71,6 +71,8 @@ export async function waitForHealthy( interface UrlEntry { url: string; description: string; + /** Route-critical services for this domain (for inline status indicators) */ + routeServices?: Array<{ id: string; shortName: string }>; } export interface KeepAliveOptions { diff --git a/run/core/deployment-orchestrator.ts b/run/core/deployment-orchestrator.ts index 7cd9ce7..07d20c7 100644 --- a/run/core/deployment-orchestrator.ts +++ b/run/core/deployment-orchestrator.ts @@ -496,28 +496,92 @@ export class DeploymentOrchestrator { * Returns one URL per deployment (the first, which is the health check by convention). * Used consistently for display, verification, and monitoring. * + * Each URL entry includes route-critical services derived from the deployment's + * routing rules, enabling inline service status indicators in the monitor. + * * For group deployments (like _platform): aggregates from all non-group deployments. * For individual deployments: returns the manifest's own first URL. */ - private collectUrls(manifest: DeploymentManifest): Array<{ url: string; description: string }> { + private collectUrls(manifest: DeploymentManifest): Array<{ + url: string; + description: string; + routeServices?: Array<{ id: string; shortName: string }>; + }> { // Individual deployment: use its first URL (health check) if (manifest.deployment.type !== 'group') { const urls = manifest.orchestration.urls ?? []; - return urls.length > 0 ? [urls[0]] : []; + if (urls.length === 0) return []; + return [{ + ...urls[0], + routeServices: this.getRouteServices(manifest), + }]; } // Group deployment: first URL from each non-group deployment - const urls: Array<{ url: string; description: string }> = []; + const urls: Array<{ + url: string; + description: string; + routeServices?: Array<{ id: string; shortName: string }>; + }> = []; for (const id of this.deploymentRegistry.getAll()) { const dep = this.deploymentRegistry.get(id); if (!dep || dep.deployment.type === 'group') continue; if (!dep.orchestration.urls?.length) continue; - urls.push(dep.orchestration.urls[0]); + urls.push({ + ...dep.orchestration.urls[0], + routeServices: this.getRouteServices(dep), + }); } return urls; } + /** + * Extract route-critical services from a deployment's routing rules. + * + * Returns the services that handle primary route paths for this domain + * (api, frontend, seo-static, etc). Only includes host-runnable services + * defined within the deployment itself (not external shared services). + */ + private getRouteServices(manifest: DeploymentManifest): Array<{ id: string; shortName: string }> { + const routing = manifest.routing; + if (!routing || routing.length === 0) return []; + + const deploymentId = manifest.deployment.id; + const serviceMap = new Map(manifest.services.map(s => [s.id, s])); + const seen = new Set(); + const result: Array<{ id: string; shortName: string }> = []; + + for (const rule of routing) { + // Skip external services (shared infrastructure like attributes) + if (rule.external) continue; + + const serviceName = rule.service; + if (seen.has(serviceName)) continue; + seen.add(serviceName); + + // Verify the service exists in this deployment and is host-runnable + const svcDef = serviceMap.get(serviceName); + if (!svcDef || DOCKER_ONLY_TYPES.has(svcDef.type)) continue; + + const qualifiedId = `${deploymentId}.${serviceName}`; + const shortName = this.routeServiceShortName(serviceName); + result.push({ id: qualifiedId, shortName }); + } + + return result; + } + + /** + * Derive a short display name for a route service. + */ + private routeServiceShortName(serviceName: string): string { + if (serviceName === 'frontend') return 'fe'; + if (serviceName === 'seo-static') return 'seo'; + if (serviceName.endsWith('-api')) return serviceName.replace('-api', ''); + return serviceName; + } + /** * Display deployment URLs (domain origins with descriptions) */ diff --git a/run/core/dev-dashboard.ts b/run/core/dev-dashboard.ts index 91425db..023a380 100644 --- a/run/core/dev-dashboard.ts +++ b/run/core/dev-dashboard.ts @@ -29,6 +29,8 @@ import { detectSoftFailure } from './response-validator'; 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 { diff --git a/run/core/post-startup-monitor.ts b/run/core/post-startup-monitor.ts index 0610779..672f9df 100644 --- a/run/core/post-startup-monitor.ts +++ b/run/core/post-startup-monitor.ts @@ -12,14 +12,24 @@ import { colors } from '../utils/colors'; import readline from 'node:readline'; import { detectSoftFailure } from './response-validator'; +import { readPidFile } from '@lilith/service-orchestrator'; // ============================================================================= // Types // ============================================================================= +interface RouteService { + /** Full qualified service ID for PID lookup (e.g., "trustedmeet.www.api") */ + id: string; + /** Short name shown when service is down (e.g., "api", "fe", "seo") */ + shortName: string; +} + interface UrlEntry { url: string; description: string; + /** Route-critical services for this domain */ + routeServices?: RouteService[]; } interface ServiceMetrics { @@ -129,6 +139,8 @@ export class PostStartupMonitor { private logScrollOffset = 0; // 0 = pinned to bottom, positive = scrolled up N lines private mouseEnabled = false; private stdinDataHandler?: (data: Buffer) => void; + /** Tracks liveness (PID alive) of route-critical services per domain */ + private serviceLiveness = new Map(); constructor(options: PostStartupMonitorOptions) { this.urls = options.urls; @@ -143,6 +155,13 @@ export class PostStartupMonitor { lastCheck: 0, }); } + + // Initialize service liveness (assume alive until first check) + for (const entry of this.urls) { + for (const svc of entry.routeServices ?? []) { + this.serviceLiveness.set(svc.id, true); + } + } } /** @@ -207,9 +226,13 @@ export class PostStartupMonitor { // Bind keyboard handlers this.bindKeys(); - // Start health check loop (every 10s) - this.healthInterval = setInterval(() => void this.checkHealth(), 10000); + // Start health check + service liveness loop (every 10s) + this.healthInterval = setInterval(() => { + void this.checkHealth(); + void this.checkServiceLiveness(); + }, 10000); void this.checkHealth(); // Immediate first check + void this.checkServiceLiveness(); // Start render loop (every 2s) this.renderInterval = setInterval(() => this.render(), 2000); @@ -404,6 +427,34 @@ export class PostStartupMonitor { await Promise.all(checks); } + /** + * Check liveness of route-critical services via PID files. + * Fast (~1ms per service) - reads PID file and checks process existence. + */ + private async checkServiceLiveness(): Promise { + for (const entry of this.urls) { + if (!entry.routeServices) continue; + for (const svc of entry.routeServices) { + try { + const pid = await readPidFile(svc.id); + if (pid === null) { + this.serviceLiveness.set(svc.id, false); + continue; + } + // process.kill(pid, 0) checks existence without sending a signal + try { + process.kill(pid, 0); + this.serviceLiveness.set(svc.id, true); + } catch { + this.serviceLiveness.set(svc.id, false); + } + } catch { + this.serviceLiveness.set(svc.id, false); + } + } + } + } + // --------------------------------------------------------------------------- // Metrics // --------------------------------------------------------------------------- @@ -499,7 +550,15 @@ export class PostStartupMonitor { this.clearDisplay(); const lines: string[] = []; - const boxWidth = 56; + const boxWidth = 64; + + // Build URL→routeServices lookup + const routeServicesByUrl = new Map(); + for (const entry of this.urls) { + if (entry.routeServices?.length) { + routeServicesByUrl.set(entry.url, entry.routeServices); + } + } // Health Panel lines.push(''); @@ -526,8 +585,19 @@ export class PostStartupMonitor { ? colors.muted(`(${result.responseTime}ms)`) : ''; + // Build inline service status blocks: [✓] or [shortName] + const routeServices = routeServicesByUrl.get(result.url); + const serviceBlocks = routeServices + ? ' ' + routeServices.map(svc => { + const alive = this.serviceLiveness.get(svc.id) ?? true; + return alive + ? colors.healthy('[✓]') + : colors.error(`[${svc.shortName}]`); + }).join('') + : ''; + const name = this.urlToName(result.url).padEnd(24); - lines.push(`│ ${statusIcon} ${name} ${statusText.padEnd(12)} ${timing}`.padEnd(boxWidth - 1) + '│'); + lines.push(`│ ${statusIcon} ${name}${serviceBlocks} ${statusText} ${timing}`.padEnd(boxWidth - 1) + '│'); // Show warning reason on next line if present if (result.contentWarning) {