diff --git a/src/widgets/HealthMonitor.ts b/src/widgets/HealthMonitor.ts index 47428db..6a2a39c 100644 --- a/src/widgets/HealthMonitor.ts +++ b/src/widgets/HealthMonitor.ts @@ -1,5 +1,19 @@ +/** + * HealthMonitor Widget - Displays health check status with pending animations + * + * Features: + * - Pending health checks with animated spinner + * - Completed health checks with response times + * - Status colors (healthy/unhealthy/checking) + * - Scrollable content + */ + import * as blessed from 'blessed' +// ============================================================================= +// Types +// ============================================================================= + export interface HealthStatus { service: string port: number @@ -8,71 +22,196 @@ export interface HealthStatus { lastCheck?: Date } +export interface HealthMonitorOptions { + /** Label for the panel */ + label?: string + /** Maximum checks to display (default: 20) */ + maxVisible?: number + /** Box options */ + boxOptions?: blessed.Widgets.BoxOptions +} + +// ============================================================================= +// HealthMonitor Widget +// ============================================================================= + export class HealthMonitor { private box: blessed.Widgets.BoxElement - private healthChecks: Map = new Map() + private healthChecks = new Map() + private pendingChecks = new Set() + private maxVisible: number + + constructor(parent: blessed.Widgets.Screen | blessed.Widgets.Node, options: HealthMonitorOptions = {}) { + this.maxVisible = options.maxVisible ?? 20 - constructor(parent: blessed.Widgets.Screen | blessed.Widgets.Node, options?: blessed.Widgets.BoxOptions) { this.box = blessed.box({ parent, - label: 'Health Status', + label: ` ${options.label ?? 'Health'} `, border: { type: 'line' }, style: { - border: { - fg: 'cyan' - }, - ...options?.style + border: { fg: 'cyan' }, + label: { fg: 'white', bold: true }, + focus: { border: { fg: 'green' } }, }, tags: true, - ...options + scrollable: true, + alwaysScroll: true, + scrollbar: { ch: '│', style: { bg: 'cyan' } }, + keys: true, + vi: true, + mouse: true, + clickable: true, + ...options.boxOptions, }) } - updateHealth(health: HealthStatus): void { - this.healthChecks.set(`${health.service}:${health.port}`, health) + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Mark a service as waiting for health check (shows spinner) + */ + markPending(service: string): void { + this.pendingChecks.add(service) this.render() } - private render(): void { - const lines: string[] = [] - - this.healthChecks.forEach(health => { - const statusColor = this.getStatusColor(health.status) - const statusSymbol = this.getStatusSymbol(health.status) - const responseTime = health.responseTime ? ` (${health.responseTime}ms)` : '' - - lines.push(`{${statusColor}-fg}${statusSymbol}{/} ${health.service}:${health.port}${responseTime}`) + /** + * Update health check status + */ + updateHealth(service: string, port: number, healthy: boolean, responseTime?: number): void { + const key = `${service}:${port}` + this.healthChecks.set(key, { + service, + port, + status: healthy ? 'healthy' : 'unhealthy', + responseTime, + lastCheck: new Date(), }) + // Remove from pending when healthy + if (healthy) { + this.pendingChecks.delete(service) + } + + this.render() + } + + /** + * Set a check as still in progress + */ + setChecking(service: string, port: number): void { + const key = `${service}:${port}` + this.healthChecks.set(key, { + service, + port, + status: 'checking', + lastCheck: new Date(), + }) + this.render() + } + + /** + * Get spinner character (call this from animation loop) + */ + getSpinnerChar(): string { + const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + const index = Math.floor(Date.now() / 100) % spinnerChars.length + return spinnerChars[index] + } + + /** + * Render the widget (call from animation loop for spinner updates) + */ + render(): void { + const lines: string[] = [] + const spinner = this.getSpinnerChar() + + // Show pending health checks first + const pending = Array.from(this.pendingChecks).sort() + if (pending.length > 0) { + const maxPending = Math.min(5, pending.length) + for (let i = 0; i < maxPending; i++) { + lines.push(`{yellow-fg}${spinner}{/} ${pending[i]} {gray-fg}waiting...{/}`) + } + if (pending.length > 5) { + lines.push(`{gray-fg}+${pending.length - 5} more waiting...{/}`) + } + if (lines.length > 0) { + lines.push('') + } + } + + // Show completed health checks + const checks = Array.from(this.healthChecks.values()).sort((a, b) => a.service.localeCompare(b.service)) + + const maxChecks = this.maxVisible - Math.min(pending.length, 5) - (pending.length > 0 ? 1 : 0) + for (const check of checks.slice(0, maxChecks)) { + const symbol = check.status === 'healthy' ? '✓' : check.status === 'unhealthy' ? '✗' : '◐' + const color = check.status === 'healthy' ? 'green' : check.status === 'unhealthy' ? 'red' : 'yellow' + const time = check.responseTime !== undefined ? ` {gray-fg}(${check.responseTime}ms){/}` : '' + + lines.push(`{${color}-fg}${symbol}{/} ${check.service}:${check.port}${time}`) + } + + if (checks.length > maxChecks) { + lines.push(`{gray-fg}+${checks.length - maxChecks} more...{/}`) + } + this.box.setContent(lines.join('\n')) this.box.screen.render() } - private getStatusColor(status: HealthStatus['status']): string { - switch (status) { - case 'healthy': return 'green' - case 'unhealthy': return 'red' - case 'checking': return 'yellow' - default: return 'gray' - } - } - - private getStatusSymbol(status: HealthStatus['status']): string { - switch (status) { - case 'healthy': return '✓' - case 'unhealthy': return '✗' - case 'checking': return '◐' - default: return '?' - } - } - + /** + * Clear all health checks + */ clear(): void { this.healthChecks.clear() + this.pendingChecks.clear() this.box.setContent('') this.box.screen.render() } + /** + * Get counts + */ + getCounts(): { healthy: number; unhealthy: number; pending: number } { + const checks = Array.from(this.healthChecks.values()) + return { + healthy: checks.filter(c => c.status === 'healthy').length, + unhealthy: checks.filter(c => c.status === 'unhealthy').length, + pending: this.pendingChecks.size, + } + } + + /** + * Scroll up + */ + scrollUp(lines = 1): void { + this.box.scroll(-lines) + this.box.screen.render() + } + + /** + * Scroll down + */ + scrollDown(lines = 1): void { + this.box.scroll(lines) + this.box.screen.render() + } + + /** + * Focus this widget + */ + focus(): void { + this.box.focus() + } + + /** + * Get the underlying blessed element + */ get element(): blessed.Widgets.BoxElement { return this.box } -} \ No newline at end of file +} diff --git a/src/widgets/LogViewer.ts b/src/widgets/LogViewer.ts index 4652a64..029b90e 100644 --- a/src/widgets/LogViewer.ts +++ b/src/widgets/LogViewer.ts @@ -1,41 +1,241 @@ +/** + * LogViewer Widget - Displays logs with filtering and auto-scroll + * + * Features: + * - Service-based log filtering + * - Auto-scroll when at bottom + * - Log level colors + * - Stored logs for re-filtering + * - Scroll position tracking + */ + import * as contrib from 'blessed-contrib' +// ============================================================================= +// Types +// ============================================================================= + +export type LogLevel = 'info' | 'warn' | 'error' | 'debug' + export interface LogEntry { - timestamp: Date - level: 'info' | 'warn' | 'error' | 'debug' + timestamp: string + level: LogLevel service: string message: string } +export interface LogViewerOptions { + /** Maximum number of logs to store (default: 1000) */ + maxLogs?: number + /** Buffer length for blessed log element (default: 1000) */ + bufferLength?: number + /** Label for the panel */ + label?: string + /** Log element options */ + logOptions?: any +} + +// ============================================================================= +// LogViewer Widget +// ============================================================================= + export class LogViewer { private log: contrib.Widgets.LogElement - private maxLines: number + private allLogs: LogEntry[] = [] + private filter: string | null = null + private maxLogs: number + private autoScroll = true + private labelBase: string - constructor(parent: any, options?: any) { - this.maxLines = options?.maxLines || 1000 + constructor(parent: any, options: LogViewerOptions = {}) { + this.maxLogs = options.maxLogs ?? 1000 + this.labelBase = options.label ?? 'Logs' this.log = contrib.log({ parent, - label: 'Logs', + label: ` ${this.labelBase} `, border: { type: 'line' }, style: { - border: { - fg: 'cyan' - } + border: { fg: 'cyan' }, + label: { fg: 'white', bold: true }, + focus: { border: { fg: 'green' } }, }, - bufferLength: this.maxLines, - ...options + bufferLength: options.bufferLength ?? 1000, + tags: true, + scrollable: true, + alwaysScroll: true, + scrollbar: { ch: '│', style: { bg: 'cyan' } }, + keys: true, + vi: true, + mouse: true, + clickable: true, + ...options.logOptions, + }) + + // Track scroll position for auto-scroll + this.log.on('scroll', () => { + this.updateAutoScroll() }) } + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Add a log entry + */ addLog(entry: LogEntry): void { - const timestamp = entry.timestamp.toISOString().split('T')[1].split('.')[0] - const levelColor = this.getLevelColor(entry.level) - const message = `${timestamp} [${levelColor}${entry.level}${'{/}'}] [${entry.service}] ${entry.message}` - this.log.log(message) + // Store log + this.allLogs.push(entry) + if (this.allLogs.length > this.maxLogs) { + this.allLogs.shift() + } + + // Only display if passes filter + if (!this.filter || entry.service.toLowerCase().includes(this.filter.toLowerCase())) { + const wasAtBottom = this.isAtBottom() + this.log.log(this.formatLogEntry(entry)) + + // Auto-scroll if user was at bottom + if (wasAtBottom && this.autoScroll) { + this.scrollToBottom() + } + } } - private getLevelColor(level: LogEntry['level']): string { + /** + * Add a log with simple parameters (convenience method) + */ + log(level: LogLevel, service: string, message: string): void { + const timestamp = new Date().toISOString().split('T')[1].split('.')[0] + this.addLog({ timestamp, level, service, message }) + } + + /** + * Set filter to show only logs from matching services + */ + setFilter(serviceFilter: string | null): void { + this.filter = serviceFilter + this.rerender() + + // Update label + if (serviceFilter) { + this.log.setLabel(` ${this.labelBase} (${serviceFilter}) `) + } else { + this.log.setLabel(` ${this.labelBase} `) + } + + this.log.screen.render() + } + + /** + * Get current filter + */ + getFilter(): string | null { + return this.filter + } + + /** + * Scroll up + */ + scrollUp(lines = 1): void { + ;(this.log as any).scroll(-lines) + this.autoScroll = false + this.log.screen.render() + } + + /** + * Scroll down + */ + scrollDown(lines = 1): void { + ;(this.log as any).scroll(lines) + this.updateAutoScroll() + this.log.screen.render() + } + + /** + * Scroll to top + */ + scrollToTop(): void { + ;(this.log as any).setScrollPerc?.(0) + this.autoScroll = false + this.log.screen.render() + } + + /** + * Scroll to bottom (enables auto-scroll) + */ + scrollToBottom(): void { + ;(this.log as any).setScrollPerc?.(100) + this.autoScroll = true + this.log.screen.render() + } + + /** + * Check if auto-scroll is enabled + */ + isAutoScrollEnabled(): boolean { + return this.autoScroll + } + + /** + * Clear all logs + */ + clear(): void { + this.allLogs = [] + ;(this.log as any).setContent('') + this.log.screen.render() + } + + /** + * Focus this widget + */ + focus(): void { + this.log.focus() + } + + /** + * Get the underlying blessed element + */ + get element(): contrib.Widgets.LogElement { + return this.log + } + + // --------------------------------------------------------------------------- + // Private Methods + // --------------------------------------------------------------------------- + + private formatLogEntry(entry: LogEntry): string { + const levelColor = this.getLevelColor(entry.level) + const shortService = entry.service.length > 12 ? entry.service.slice(0, 9) + '...' : entry.service + return `{gray-fg}${entry.timestamp}{/} ${levelColor}[${shortService}]{/} ${entry.message}` + } + + private rerender(): void { + ;(this.log as any).setContent('') + + for (const entry of this.allLogs) { + if (!this.filter || entry.service.toLowerCase().includes(this.filter.toLowerCase())) { + this.log.log(this.formatLogEntry(entry)) + } + } + } + + private isAtBottom(): boolean { + const logElement = this.log as any + const scrollHeight = logElement.getScrollHeight?.() || 0 + const scrollPos = logElement.childBase || 0 + const visibleHeight = (logElement.height || 10) - 2 + + return scrollHeight <= visibleHeight || scrollPos + visibleHeight >= scrollHeight - 1 + } + + private updateAutoScroll(): void { + this.autoScroll = this.isAtBottom() + } + + private getLevelColor(level: LogLevel): string { switch (level) { case 'info': return '{cyan-fg}' case 'warn': return '{yellow-fg}' @@ -44,17 +244,4 @@ export class LogViewer { default: return '' } } - - clear(): void { - this.log.setContent('') - this.log.screen.render() - } - - focus(): void { - this.log.focus() - } - - get element(): contrib.Widgets.LogElement { - return this.log - } -} \ No newline at end of file +} diff --git a/src/widgets/ProgressPanel.ts b/src/widgets/ProgressPanel.ts new file mode 100644 index 0000000..3fff332 --- /dev/null +++ b/src/widgets/ProgressPanel.ts @@ -0,0 +1,174 @@ +/** + * ProgressPanel Widget - Displays progress bar with stats + * + * Features: + * - Progress bar with percentage + * - Phase info with name + * - Service counts (healthy/starting/failed/skipped) + * - Elapsed time tracking + */ + +import * as blessed from 'blessed' + +// ============================================================================= +// Types +// ============================================================================= + +export interface ProgressState { + phase: number + totalPhases: number + phaseName?: string + completed: number + total: number + elapsed: number +} + +export interface ServiceCounts { + healthy: number + starting: number + failed: number + skipped: number +} + +export interface ProgressPanelOptions { + /** Label for the panel */ + label?: string + /** Progress bar width (default: 40) */ + barWidth?: number + /** Box options */ + boxOptions?: blessed.Widgets.BoxOptions +} + +// ============================================================================= +// ProgressPanel Widget +// ============================================================================= + +export class ProgressPanel { + private box: blessed.Widgets.BoxElement + private progress: ProgressState = { + phase: 0, + totalPhases: 0, + completed: 0, + total: 0, + elapsed: 0, + } + private counts: ServiceCounts = { + healthy: 0, + starting: 0, + failed: 0, + skipped: 0, + } + private barWidth: number + + constructor(parent: blessed.Widgets.Screen | blessed.Widgets.Node, options: ProgressPanelOptions = {}) { + this.barWidth = options.barWidth ?? 40 + + this.box = blessed.box({ + parent, + label: ` ${options.label ?? 'Progress'} `, + border: { type: 'line' }, + style: { + border: { fg: 'cyan' }, + label: { fg: 'white', bold: true }, + }, + tags: true, + ...options.boxOptions, + }) + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Set progress state + */ + setProgress(state: Partial): void { + Object.assign(this.progress, state) + this.render() + } + + /** + * Set phase info + */ + setPhase(phase: number, totalPhases: number, phaseName?: string): void { + this.progress.phase = phase + this.progress.totalPhases = totalPhases + if (phaseName !== undefined) { + this.progress.phaseName = phaseName + } + this.render() + } + + /** + * Set service counts + */ + setCounts(counts: Partial): void { + Object.assign(this.counts, counts) + this.render() + } + + /** + * Update elapsed time + */ + setElapsed(elapsed: number): void { + this.progress.elapsed = elapsed + this.render() + } + + /** + * Render the widget + */ + render(): void { + const { phase, totalPhases, phaseName, completed, total, elapsed } = this.progress + const { healthy, starting, failed, skipped } = this.counts + const percent = total > 0 ? Math.round((completed / total) * 100) : 0 + + const lines: string[] = [] + + // Phase info with name + if (phaseName) { + lines.push(`{cyan-fg}{bold}${phaseName}{/bold} (Step ${phase}/${totalPhases}){/}`) + } else { + lines.push(`{cyan-fg}Phase {bold}${phase}{/bold}/${totalPhases}{/}`) + } + lines.push('') + + // Progress bar + const filled = Math.round((percent / 100) * this.barWidth) + const empty = this.barWidth - filled + const bar = '{green-fg}' + '█'.repeat(filled) + '{/}' + '{gray-fg}' + '░'.repeat(empty) + '{/}' + lines.push(bar + ` {white-fg}${percent}%{/}`) + lines.push('') + + // Stats + lines.push(`{green-fg}${healthy}{/} healthy {yellow-fg}${starting}{/} starting {red-fg}${failed}{/} failed {gray-fg}${skipped}{/} skipped`) + lines.push('') + + // Service count and elapsed time + const serviceCount = `${completed}/${total}` + lines.push(`{cyan-fg}${serviceCount}{/} services │ {cyan-fg}${this.formatDuration(elapsed)}{/} elapsed`) + + this.box.setContent(lines.join('\n')) + this.box.screen.render() + } + + /** + * Get the underlying blessed element + */ + get element(): blessed.Widgets.BoxElement { + return this.box + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` + const mins = Math.floor(ms / 60000) + const secs = Math.round((ms % 60000) / 1000) + return `${mins}m ${secs}s` + } +} diff --git a/src/widgets/RoadmapPanel.ts b/src/widgets/RoadmapPanel.ts new file mode 100644 index 0000000..80f174e --- /dev/null +++ b/src/widgets/RoadmapPanel.ts @@ -0,0 +1,152 @@ +/** + * RoadmapPanel Widget - Displays phase roadmap with completion status + * + * Features: + * - Phase tracking with visual indicators + * - Current phase highlighting + * - Completed phase markers + * - Auto-updating current phase + */ + +import * as blessed from 'blessed' + +// ============================================================================= +// Types +// ============================================================================= + +export interface PhaseInfo { + index: number + name: string + completed: boolean +} + +export interface RoadmapPanelOptions { + /** Label for the panel */ + label?: string + /** Box options */ + boxOptions?: blessed.Widgets.BoxOptions +} + +// ============================================================================= +// RoadmapPanel Widget +// ============================================================================= + +export class RoadmapPanel { + private box: blessed.Widgets.BoxElement + private phases: PhaseInfo[] = [] + private currentPhase = 0 + + constructor(parent: blessed.Widgets.Screen | blessed.Widgets.Node, options: RoadmapPanelOptions = {}) { + this.box = blessed.box({ + parent, + label: ` ${options.label ?? 'Roadmap'} `, + border: { type: 'line' }, + style: { + border: { fg: 'cyan' }, + label: { fg: 'white', bold: true }, + }, + tags: true, + ...options.boxOptions, + }) + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Set or update a phase + */ + setPhase(index: number, name: string, completed = false): void { + const existing = this.phases.find(p => p.index === index) + if (existing) { + existing.name = name + existing.completed = completed + } else { + this.phases.push({ index, name, completed }) + this.phases.sort((a, b) => a.index - b.index) + } + this.render() + } + + /** + * Set the current phase (also marks previous phases as completed) + */ + setCurrentPhase(index: number, name?: string): void { + this.currentPhase = index + + // Mark previous phases as completed + for (const phase of this.phases) { + if (phase.index < index) { + phase.completed = true + } + } + + // Update or add current phase + if (name) { + this.setPhase(index, name, false) + } + + this.render() + } + + /** + * Mark a phase as completed + */ + completePhase(index: number): void { + const phase = this.phases.find(p => p.index === index) + if (phase) { + phase.completed = true + this.render() + } + } + + /** + * Clear all phases + */ + clear(): void { + this.phases = [] + this.currentPhase = 0 + this.box.setContent('') + this.box.screen.render() + } + + /** + * Render the widget + */ + render(): void { + const lines: string[] = [] + + for (const phase of this.phases) { + const isCurrent = phase.index === this.currentPhase + let symbol: string + let color: string + + if (phase.completed) { + symbol = '✓' + color = 'green' + } else if (isCurrent) { + symbol = '●' + color = 'yellow' + } else { + symbol = '○' + color = 'gray' + } + + const nameStyle = isCurrent ? '{bold}' : '' + const nameStyleEnd = isCurrent ? '{/bold}' : '' + + lines.push(`{${color}-fg}${symbol}{/} ${nameStyle}${phase.name}${nameStyleEnd}`) + } + + this.box.setContent(lines.join('\n')) + this.box.screen.render() + } + + /** + * Get the underlying blessed element + */ + get element(): blessed.Widgets.BoxElement { + return this.box + } +} diff --git a/src/widgets/ServiceList.ts b/src/widgets/ServiceList.ts index 2177718..b68e4ae 100644 --- a/src/widgets/ServiceList.ts +++ b/src/widgets/ServiceList.ts @@ -1,81 +1,361 @@ +/** + * ServiceList Widget - Displays services grouped by feature with collapsible sections + * + * Features: + * - Feature-based grouping (parses "feature.service" IDs) + * - Collapsible/expandable feature sections + * - Status-based sorting (starting/failed first) + * - Visual selection with keyboard navigation + * - Status symbols and colors + */ + import * as blessed from 'blessed' +// ============================================================================= +// Types +// ============================================================================= + +export type ServiceStatus = 'pending' | 'starting' | 'running' | 'healthy' | 'failed' | 'skipped' + export interface ServiceInfo { + id: string + feature: string name: string - status: 'running' | 'stopped' | 'error' | 'starting' + status: ServiceStatus port?: number - pid?: number + duration?: number + error?: string + startTime?: number } -export class ServiceList { - private list: blessed.Widgets.ListElement - private services: Map = new Map() +export interface FeatureGroup { + id: string + services: ServiceInfo[] + expanded: boolean +} - constructor(parent: blessed.Widgets.Screen | blessed.Widgets.Node, options?: blessed.Widgets.ListOptions) { - this.list = blessed.list({ +export interface ServiceListOptions { + /** Parse service IDs as "feature.name" (default: true) */ + groupByFeature?: boolean + /** Sort by status priority (default: true) */ + sortByStatus?: boolean + /** Allow feature expansion/collapse (default: true) */ + collapsible?: boolean + /** Start features collapsed (default: true) */ + startCollapsed?: boolean + /** Box options for the widget */ + boxOptions?: blessed.Widgets.BoxOptions +} + +// ============================================================================= +// ServiceList Widget +// ============================================================================= + +export class ServiceList { + private box: blessed.Widgets.BoxElement + private services = new Map() + private featureGroups = new Map() + private featureOrder: string[] = [] + private selectedFeature: string | null = null + + private groupByFeature: boolean + private sortByStatus: boolean + private collapsible: boolean + private startCollapsed: boolean + + constructor(parent: blessed.Widgets.Screen | blessed.Widgets.Node, options: ServiceListOptions = {}) { + this.groupByFeature = options.groupByFeature ?? true + this.sortByStatus = options.sortByStatus ?? true + this.collapsible = options.collapsible ?? true + this.startCollapsed = options.startCollapsed ?? true + + this.box = blessed.box({ parent, - label: 'Services', + label: ' Services ', border: { type: 'line' }, style: { - selected: { - bg: 'blue' - }, - border: { - fg: 'cyan' - }, - ...options?.style + border: { fg: 'cyan' }, + label: { fg: 'white', bold: true }, + focus: { border: { fg: 'green' } }, }, + tags: true, + scrollable: true, + alwaysScroll: true, + scrollbar: { ch: '│', style: { bg: 'cyan' } }, keys: true, vi: true, mouse: true, - scrollbar: { - ch: ' ', - track: { - bg: 'cyan' - }, - style: { - inverse: true - } - }, - ...options + clickable: true, + ...options.boxOptions, }) } - updateService(service: ServiceInfo): void { - this.services.set(service.name, service) + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Update or add a service + */ + updateService(id: string, status: ServiceStatus, port?: number, duration?: number, error?: string): void { + const [feature, name] = this.parseServiceId(id) + + let service = this.services.get(id) + if (!service) { + service = { id, feature, name, status, port } + this.services.set(id, service) + + // Add to feature group + let group = this.featureGroups.get(feature) + if (!group) { + group = { id: feature, services: [], expanded: true } + this.featureGroups.set(feature, group) + this.featureOrder.push(feature) + } + group.services.push(service) + } + + service.status = status + if (port !== undefined) service.port = port + if (duration !== undefined) service.duration = duration + if (error !== undefined) service.error = error + if (status === 'starting' && !service.startTime) { + service.startTime = Date.now() + } + this.render() } - removeService(name: string): void { - this.services.delete(name) + /** + * Remove a service + */ + removeService(id: string): void { + const service = this.services.get(id) + if (!service) return + + this.services.delete(id) + + const group = this.featureGroups.get(service.feature) + if (group) { + group.services = group.services.filter(s => s.id !== id) + if (group.services.length === 0) { + this.featureGroups.delete(service.feature) + this.featureOrder = this.featureOrder.filter(f => f !== service.feature) + } + } + this.render() } + /** + * Toggle expansion of a feature group + */ + toggleFeatureExpand(feature?: string): void { + if (!this.collapsible) return + const targetFeature = feature ?? this.selectedFeature + if (!targetFeature) return + + const group = this.featureGroups.get(targetFeature) + if (group) { + group.expanded = !group.expanded + this.render() + } + } + + /** + * Navigate to next/previous feature + */ + navigate(direction: number): void { + if (this.featureOrder.length === 0) return + + if (this.selectedFeature === null) { + this.selectedFeature = this.featureOrder[0] + } else { + const currentIndex = this.featureOrder.indexOf(this.selectedFeature) + const newIndex = Math.max(0, Math.min(this.featureOrder.length - 1, currentIndex + direction)) + this.selectedFeature = this.featureOrder[newIndex] + } + + this.render() + this.scrollToSelectedFeature() + } + + /** + * Get the currently selected feature ID + */ + getSelectedFeature(): string | null { + return this.selectedFeature + } + + /** + * Get service counts + */ + getCounts(): { running: number; starting: number; failed: number; total: number } { + const services = Array.from(this.services.values()) + return { + running: services.filter(s => s.status === 'healthy' || s.status === 'running').length, + starting: services.filter(s => s.status === 'starting').length, + failed: services.filter(s => s.status === 'failed').length, + total: services.length, + } + } + + /** + * Clear all services + */ + clear(): void { + this.services.clear() + this.featureGroups.clear() + this.featureOrder = [] + this.selectedFeature = null + this.box.setContent('') + this.box.screen.render() + } + + /** + * Focus this widget + */ + focus(): void { + this.box.focus() + } + + /** + * Get the underlying blessed element + */ + get element(): blessed.Widgets.BoxElement { + return this.box + } + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + private render(): void { - const items = Array.from(this.services.values()).map(service => { - const status = this.getStatusSymbol(service.status) - const port = service.port ? `:${service.port}` : '' - return `${status} ${service.name}${port}` - }) - this.list.setItems(items) - this.list.screen.render() + const lines: string[] = [] + + // Sort features by activity if enabled + const sortedFeatures = this.sortByStatus + ? [...this.featureOrder].sort((a, b) => { + const groupA = this.featureGroups.get(a) + const groupB = this.featureGroups.get(b) + if (!groupA || !groupB) return 0 + + const activeA = groupA.services.filter(s => s.status === 'starting' || s.status === 'failed').length + const activeB = groupB.services.filter(s => s.status === 'starting' || s.status === 'failed').length + + if (activeA !== activeB) return activeB - activeA + return a.localeCompare(b) + }) + : this.featureOrder + + for (const featureId of sortedFeatures) { + const group = this.featureGroups.get(featureId) + if (!group) continue + + // Feature header + const running = group.services.filter(s => s.status === 'healthy' || s.status === 'running').length + const total = group.services.length + const arrow = group.expanded ? '▼' : '▶' + const featureColor = running === total ? 'green' : running > 0 ? 'yellow' : 'white' + const selected = this.selectedFeature === featureId ? '{inverse}' : '' + const selectedEnd = this.selectedFeature === featureId ? '{/inverse}' : '' + + lines.push(`${selected}{${featureColor}-fg}${arrow} ${featureId}{/} {gray-fg}(${running}/${total}){/}${selectedEnd}`) + + // Services (if expanded) + if (group.expanded) { + const sortedServices = this.sortByStatus + ? [...group.services].sort((a, b) => { + const priority = (status: ServiceStatus): number => { + switch (status) { + case 'starting': return 0 + case 'failed': return 1 + case 'pending': return 2 + case 'running': return 3 + case 'healthy': return 4 + case 'skipped': return 5 + default: return 6 + } + } + return priority(a.status) - priority(b.status) + }) + : group.services + + for (const service of sortedServices) { + const symbol = this.getStatusSymbol(service.status) + const color = this.getStatusColor(service.status) + const port = service.port ? `{gray-fg}:${service.port}{/}` : '' + const duration = service.duration ? ` {gray-fg}${service.duration}ms{/}` : '' + const errorMsg = service.error ? ` {red-fg}${service.error.slice(0, 30)}{/}` : '' + + lines.push(` {${color}-fg}${symbol}{/} ${service.name}${port}${duration}${errorMsg}`) + } + } + } + + this.box.setContent(lines.join('\n')) + this.box.screen.render() } - private getStatusSymbol(status: ServiceInfo['status']): string { + private scrollToSelectedFeature(): void { + if (!this.selectedFeature) return + + let lineIndex = 0 + for (const featureId of this.featureOrder) { + if (featureId === this.selectedFeature) break + lineIndex++ + const group = this.featureGroups.get(featureId) + if (group?.expanded) { + lineIndex += group.services.length + } + } + + const boxHeight = (this.box as any).height - 2 + const scrollPos = (this.box as any).childBase || 0 + + if (lineIndex < scrollPos) { + this.box.scrollTo(lineIndex) + } else if (lineIndex >= scrollPos + boxHeight) { + this.box.scrollTo(lineIndex - boxHeight + 1) + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private parseServiceId(id: string): [string, string] { + if (!this.groupByFeature) { + return ['default', id] + } + const parts = id.split('.') + if (parts.length >= 2) { + return [parts[0], parts.slice(1).join('.')] + } + return ['unknown', id] + } + + private getStatusSymbol(status: ServiceStatus): string { switch (status) { - case 'running': return '●' - case 'stopped': return '○' - case 'error': return '✗' + case 'pending': return '○' case 'starting': return '◐' + case 'running': + case 'healthy': return '●' + case 'failed': return '✗' + case 'skipped': return '⊘' default: return '?' } } - focus(): void { - this.list.focus() + private getStatusColor(status: ServiceStatus): string { + switch (status) { + case 'pending': return 'gray' + case 'starting': return 'yellow' + case 'running': + case 'healthy': return 'green' + case 'failed': return 'red' + case 'skipped': return 'gray' + default: return 'white' + } } - - get element(): blessed.Widgets.ListElement { - return this.list - } -} \ No newline at end of file +} diff --git a/src/widgets/index.ts b/src/widgets/index.ts index fafb480..87b90d7 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -1,4 +1,6 @@ export * from './ServiceList' export * from './LogViewer' export * from './HealthMonitor' -export * from './StatusBar' \ No newline at end of file +export * from './StatusBar' +export * from './ProgressPanel' +export * from './RoadmapPanel'