diff --git a/src/widgets/HealthMonitor.ts b/src/widgets/HealthMonitor.ts index 5e2e5ed..1f0cadc 100644 --- a/src/widgets/HealthMonitor.ts +++ b/src/widgets/HealthMonitor.ts @@ -29,6 +29,8 @@ export interface HealthMonitorOptions { maxVisible?: number /** Use horizontal layout (items side by side) */ horizontal?: boolean + /** Group items by feature prefix (e.g., analytics.api → analytics group) */ + grouped?: boolean /** Box options */ boxOptions?: blessed.Widgets.BoxOptions } @@ -43,10 +45,12 @@ export class HealthMonitor { private pendingChecks = new Set() private maxVisible: number private horizontal: boolean + private grouped: boolean constructor(parent: blessed.Widgets.Screen | blessed.Widgets.Node, options: HealthMonitorOptions = {}) { this.maxVisible = options.maxVisible ?? 20 this.horizontal = options.horizontal ?? false + this.grouped = options.grouped ?? false this.box = blessed.box({ parent, @@ -136,7 +140,9 @@ export class HealthMonitor { * Render the widget (call from animation loop for spinner updates) */ render(): void { - if (this.horizontal) { + if (this.grouped) { + this.renderGrouped() + } else if (this.horizontal) { this.renderHorizontal() } else { this.renderVertical() @@ -215,6 +221,110 @@ export class HealthMonitor { this.box.screen.render() } + private renderGrouped(): void { + const lines: string[] = [] + const spinner = this.getSpinnerChar() + + // Group health checks by feature + const groups = new Map() + const checks = Array.from(this.healthChecks.values()) + + for (const check of checks) { + const feature = this.extractFeature(check.service) + const group = groups.get(feature) || [] + group.push(check) + groups.set(feature, group) + } + + // Sort groups by name + const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0])) + + // Render each group + for (const [feature, groupChecks] of sortedGroups) { + // Group header with counts + const healthy = groupChecks.filter(c => c.status === 'healthy').length + const checking = groupChecks.filter(c => c.status === 'checking').length + const unhealthy = groupChecks.filter(c => c.status === 'unhealthy').length + + let statusSummary = '' + if (healthy > 0) statusSummary += `{green-fg}${healthy}✓{/}` + if (checking > 0) statusSummary += `${statusSummary ? ' ' : ''}{yellow-fg}${checking}◐{/}` + if (unhealthy > 0) statusSummary += `${statusSummary ? ' ' : ''}{red-fg}${unhealthy}✗{/}` + + lines.push(`{bold}{cyan-fg}${feature}{/}{/} {gray-fg}(${statusSummary}){/}`) + + // Sort checks within group by service name + groupChecks.sort((a, b) => a.service.localeCompare(b.service)) + + for (const check of groupChecks) { + const symbol = check.status === 'healthy' ? '✓' : check.status === 'unhealthy' ? '✗' : spinner + const color = check.status === 'healthy' ? 'green' : check.status === 'unhealthy' ? 'red' : 'yellow' + const time = check.responseTime !== undefined ? ` {gray-fg}(${check.responseTime}ms){/}` : '' + const shortName = this.extractShortName(check.service) + + lines.push(` {${color}-fg}${symbol}{/} ${shortName}:${check.port}${time}`) + } + } + + // Show pending checks that aren't in healthChecks yet + const pendingNotInChecks = Array.from(this.pendingChecks).filter( + service => !checks.some(c => c.service === service) + ) + if (pendingNotInChecks.length > 0) { + if (lines.length > 0) lines.push('') + lines.push(`{bold}{yellow-fg}Waiting{/}{/}`) + for (const service of pendingNotInChecks.sort().slice(0, 5)) { + lines.push(` {yellow-fg}${spinner}{/} ${service}`) + } + if (pendingNotInChecks.length > 5) { + lines.push(` {gray-fg}+${pendingNotInChecks.length - 5} more...{/}`) + } + } + + this.box.setContent(lines.join('\n')) + this.box.screen.render() + } + + /** + * Extract feature name from service identifier + * Examples: + * - "lilith-dev-postgres" → "docker" + * - "lilith-dev-nginx" → "docker" + * - "analytics.api" → "analytics" + * - "landing.frontend" → "landing" + * - "sso.api" → "sso" + */ + private extractFeature(service: string): string { + // Docker containers have "lilith-dev-" prefix + if (service.startsWith('lilith-dev-')) { + return 'docker' + } + // Service format: "feature.component" + const dotIndex = service.indexOf('.') + if (dotIndex > 0) { + return service.substring(0, dotIndex) + } + // Fallback to full name + return service + } + + /** + * Extract short name from service identifier + * Examples: + * - "lilith-dev-postgres" → "postgres" + * - "analytics.api" → "api" + */ + private extractShortName(service: string): string { + if (service.startsWith('lilith-dev-')) { + return service.substring('lilith-dev-'.length) + } + const dotIndex = service.indexOf('.') + if (dotIndex > 0) { + return service.substring(dotIndex + 1) + } + return service + } + /** * Clear all health checks */