chore(widgets): 🔧 Update TypeScript files in widgets
This commit is contained in:
parent
dc92ef731b
commit
1837cd45bf
6 changed files with 1050 additions and 116 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
174
src/widgets/ProgressPanel.ts
Normal file
174
src/widgets/ProgressPanel.ts
Normal 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
152
src/widgets/RoadmapPanel.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue