chore(widgets): 🔧 Update TypeScript files in widgets

This commit is contained in:
Lilith 2026-01-20 01:02:26 -08:00
parent dc92ef731b
commit 1837cd45bf
6 changed files with 1050 additions and 116 deletions

View file

@ -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<string, HealthStatus> = new Map()
private healthChecks = new Map<string, HealthStatus>()
private pendingChecks = new Set<string>()
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
}
}
}

View file

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

View file

@ -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<ProgressState>): 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<ServiceCounts>): 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`
}
}

152
src/widgets/RoadmapPanel.ts Normal file
View file

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

View file

@ -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<string, ServiceInfo> = new Map()
export interface FeatureGroup {
id: string
services: ServiceInfo[]
expanded: boolean
}
constructor(parent: blessed.Widgets.Screen | blessed.Widgets.Node, options?: blessed.Widgets.ListOptions<blessed.Widgets.ListElementStyle>) {
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<string, ServiceInfo>()
private featureGroups = new Map<string, FeatureGroup>()
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
}
}
}

View file

@ -1,4 +1,6 @@
export * from './ServiceList'
export * from './LogViewer'
export * from './HealthMonitor'
export * from './StatusBar'
export * from './StatusBar'
export * from './ProgressPanel'
export * from './RoadmapPanel'