/** * Migrate Dashboard Command * * Displays a real-time dashboard showing migration progress across all DOs. * Provides version distribution, failed migration tracking, and velocity metrics. * * @module cli/commands/migrate-dashboard */ import type { MigrateDashboardCLIOptions } from '../types.js' import { formatSuccess, formatError, formatInfo, formatWarning, formatDim, formatTable, formatProgressBar, formatDuration, } from '../formatting.js' /** * Migration status summary from API */ export interface MigrationStatusSummary { /** Total number of DOs being tracked */ totalDOs: number /** Distribution of DOs by schema version */ versionDistribution: Record /** Current target version (latest) */ currentVersion: number /** Number of failed migrations */ failedMigrations: number /** Number of DOs currently migrating */ runningMigrations: number /** Migration velocity (DOs per hour) */ migrationVelocity: number /** Estimated time to completion in milliseconds */ estimatedTimeRemainingMs: number | null /** Overall completion percentage */ percentComplete: number /** Timestamp of last update */ lastUpdated: string } /** * DO migration detail for list views */ export interface DOMigrationDetail { /** Durable Object ID */ doId: string /** Current schema version */ version: number /** Migration status */ status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' /** Last migration attempt timestamp */ lastAttempt?: string /** Error message if failed */ error?: string /** Execution time in milliseconds */ executionTimeMs?: number } /** * Active migration operation */ export interface MigrationOperationSummary { /** Migration operation ID */ migrationId: string /** Target schema version */ targetVersion: number /** Operation status */ status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' /** Total DOs in this operation */ totalDOs: number /** Completed DOs */ completedDOs: number /** Failed DOs */ failedDOs: number /** When operation started */ startedAt: string /** When operation completed (if applicable) */ completedAt?: string /** Who initiated the operation */ initiatedBy?: string } /** * API client for migration status endpoints */ class MigrationStatusClient { constructor( private apiUrl: string, private apiKey?: string ) {} private async fetch(path: string, options?: RequestInit): Promise { const url = `${this.apiUrl}${path}` const headers: Record = { 'Content-Type': 'application/json', } if (this.apiKey) { headers['Authorization'] = `Bearer ${this.apiKey}` } const response = await globalThis.fetch(url, { ...options, headers: { ...headers, ...options?.headers, }, }) if (!response.ok) { const error = await response.text() throw new Error(`API request failed: ${response.status} ${error}`) } return response.json() as Promise } /** * Get overall migration status summary */ async getStatus(): Promise { return this.fetch('/api/migrations/status') } /** * Get DOs at a specific version */ async getDOsByVersion( version: number, options?: { limit?: number; offset?: number } ): Promise<{ dos: DOMigrationDetail[]; total: number }> { const params = new URLSearchParams() params.set('version', version.toString()) if (options?.limit) params.set('limit', options.limit.toString()) if (options?.offset) params.set('offset', options.offset.toString()) return this.fetch<{ dos: DOMigrationDetail[]; total: number }>( `/api/migrations/dos?${params.toString()}` ) } /** * Get failed migrations */ async getFailedMigrations(options?: { limit?: number offset?: number }): Promise<{ dos: DOMigrationDetail[]; total: number }> { const params = new URLSearchParams() if (options?.limit) params.set('limit', options.limit.toString()) if (options?.offset) params.set('offset', options.offset.toString()) return this.fetch<{ dos: DOMigrationDetail[]; total: number }>( `/api/migrations/failed?${params.toString()}` ) } /** * List all migration operations */ async listOperations(): Promise { return this.fetch('/api/migrations/operations') } /** * Get specific migration operation details */ async getOperation(migrationId: string): Promise { return this.fetch(`/api/migrations/operations/${migrationId}`) } /** * Retry failed migrations for an operation */ async retryFailed(migrationId: string): Promise<{ retried: number }> { return this.fetch<{ retried: number }>(`/api/migrations/operations/${migrationId}/retry`, { method: 'POST', }) } } /** * Format version distribution as a visual chart */ function formatVersionDistribution( distribution: Record, total: number, currentVersion: number ): string { const lines: string[] = [] // Sort versions in descending order const versions = Object.keys(distribution) .map(Number) .sort((a, b) => b - a) for (const version of versions) { const count = distribution[version.toString()] ?? 0 const percent = total > 0 ? Math.round((count / total) * 100) : 0 const isCurrent = version === currentVersion const label = isCurrent ? `v${version} (current)` : `v${version}` const bar = formatProgressBar(count, total, 20) lines.push(` ${label.padEnd(15)} ${bar} ${count.toLocaleString()} (${percent}%)`) } return lines.join('\n') } /** * Format migration status for display */ function formatStatusDisplay(status: MigrationStatusSummary): string { const lines: string[] = [] lines.push('') lines.push(formatInfo('Migration Status Dashboard')) lines.push('==========================') lines.push('') // Overview metrics lines.push(`Total DOs: ${status.totalDOs.toLocaleString()}`) lines.push('') // Version distribution lines.push('Version Distribution:') lines.push(formatVersionDistribution(status.versionDistribution, status.totalDOs, status.currentVersion)) lines.push('') // Migration metrics if (status.failedMigrations > 0) { const failedPercent = ((status.failedMigrations / status.totalDOs) * 100).toFixed(1) lines.push(formatWarning(`Failed Migrations: ${status.failedMigrations} (${failedPercent}%)`)) } else { lines.push(formatSuccess(`Failed Migrations: 0`)) } if (status.runningMigrations > 0) { lines.push(formatInfo(`Currently Migrating: ${status.runningMigrations}`)) } lines.push('') // Velocity and ETA if (status.migrationVelocity > 0) { lines.push(`Migration Velocity: ${status.migrationVelocity.toLocaleString()} DOs/hour`) if (status.estimatedTimeRemainingMs !== null && status.estimatedTimeRemainingMs > 0) { lines.push(`ETA to 100%: ~${formatDuration(status.estimatedTimeRemainingMs)}`) } } // Completion status lines.push('') if (status.percentComplete === 100) { lines.push(formatSuccess('All DOs are at the current schema version!')) } else { const progressBar = formatProgressBar(status.percentComplete, 100, 30) lines.push(`Overall Progress: ${progressBar}`) } lines.push('') lines.push(formatDim(`Last updated: ${new Date(status.lastUpdated).toLocaleString()}`)) return lines.join('\n') } /** * Format failed migrations list */ function formatFailedMigrations(failed: DOMigrationDetail[]): string { if (failed.length === 0) { return formatSuccess('No failed migrations') } const headers = ['DO ID', 'Version', 'Error', 'Last Attempt'] const rows = failed.map((d) => [ d.doId, d.version.toString(), d.error ? (d.error.length > 40 ? d.error.slice(0, 40) + '...' : d.error) : '', d.lastAttempt ?? '', ]) return formatTable(headers, rows) } /** * Format operations list */ function formatOperationsList(operations: MigrationOperationSummary[]): string { if (operations.length === 0) { return formatDim('No migration operations found') } const headers = ['ID', 'Target', 'Status', 'Progress', 'Started', 'Initiated By'] const rows = operations.map((op) => { const progress = op.totalDOs > 0 ? `${op.completedDOs}/${op.totalDOs} (${Math.round((op.completedDOs / op.totalDOs) * 100)}%)` : '0/0' return [ op.migrationId, `v${op.targetVersion}`, op.status, progress, op.startedAt ? new Date(op.startedAt).toLocaleDateString() : '', op.initiatedBy ?? '', ] }) return formatTable(headers, rows) } /** * Run the migrate:dashboard command */ export async function runMigrateDashboard(options: MigrateDashboardCLIOptions): Promise { const apiUrl = options.apiUrl ?? process.env['POSTGRES_DO_API_URL'] ?? 'https://api.postgres.do' const apiKey = options.apiKey ?? process.env['POSTGRES_DO_API_KEY'] if (!apiKey) { console.error(formatError('API key is required')) console.error(formatInfo('Provide --api-key or set POSTGRES_DO_API_KEY environment variable')) process.exit(1) } const client = new MigrationStatusClient(apiUrl, apiKey) try { // Handle different subcommands if (options.showFailed) { // Show failed migrations console.log(formatInfo('Fetching failed migrations...')) const { dos, total } = await client.getFailedMigrations(options.limit !== undefined ? { limit: options.limit } : undefined) if (options.json) { console.log(JSON.stringify({ dos, total }, null, 2)) } else { console.log('') console.log(formatWarning(`Failed Migrations (${total} total)`)) console.log('='.repeat(40)) console.log('') console.log(formatFailedMigrations(dos)) } return } if (options.showVersion !== undefined) { // Show DOs at specific version console.log(formatInfo(`Fetching DOs at version ${options.showVersion}...`)) const { dos, total } = await client.getDOsByVersion(options.showVersion, options.limit !== undefined ? { limit: options.limit } : undefined) if (options.json) { console.log(JSON.stringify({ dos, total }, null, 2)) } else { console.log('') console.log(formatInfo(`DOs at Version ${options.showVersion} (${total} total)`)) console.log('='.repeat(40)) console.log('') const headers = ['DO ID', 'Status', 'Last Attempt', 'Duration'] const rows = dos.map((d) => [ d.doId, d.status, d.lastAttempt ?? '', d.executionTimeMs ? formatDuration(d.executionTimeMs) : '', ]) console.log(formatTable(headers, rows)) } return } if (options.listOperations) { // List all operations console.log(formatInfo('Fetching migration operations...')) const operations = await client.listOperations() if (options.json) { console.log(JSON.stringify(operations, null, 2)) } else { console.log('') console.log(formatInfo('Migration Operations')) console.log('='.repeat(40)) console.log('') console.log(formatOperationsList(operations)) } return } if (options.operationId) { // Show specific operation details console.log(formatInfo(`Fetching operation ${options.operationId}...`)) const operation = await client.getOperation(options.operationId) if (options.json) { console.log(JSON.stringify(operation, null, 2)) } else { console.log('') console.log(formatInfo(`Migration Operation: ${operation.migrationId}`)) console.log('='.repeat(40)) console.log('') console.log(`Target Version: v${operation.targetVersion}`) console.log(`Status: ${operation.status}`) console.log(`Total DOs: ${operation.totalDOs}`) console.log(`Completed: ${operation.completedDOs}`) console.log(`Failed: ${operation.failedDOs}`) console.log(`Started: ${operation.startedAt}`) if (operation.completedAt) { console.log(`Completed: ${operation.completedAt}`) } if (operation.initiatedBy) { console.log(`Initiated By: ${operation.initiatedBy}`) } console.log('') const progress = operation.totalDOs > 0 ? Math.round((operation.completedDOs / operation.totalDOs) * 100) : 0 console.log(`Progress: ${formatProgressBar(progress, 100, 30)}`) } return } if (options.retry) { // Retry failed migrations for an operation console.log(formatInfo(`Retrying failed migrations for operation ${options.retry}...`)) const result = await client.retryFailed(options.retry) if (options.json) { console.log(JSON.stringify(result, null, 2)) } else { console.log(formatSuccess(`Queued ${result.retried} failed migrations for retry`)) } return } // Default: Show dashboard if (options.watch) { // Watch mode - refresh dashboard periodically const refreshInterval = options.refreshInterval ?? 5000 console.log(formatInfo(`Starting dashboard in watch mode (refresh every ${refreshInterval / 1000}s)...`)) console.log(formatDim('Press Ctrl+C to exit')) console.log('') const runDashboard = async (): Promise => { try { const status = await client.getStatus() // Clear screen process.stdout.write('\x1b[2J\x1b[H') if (options.json) { console.log(JSON.stringify(status, null, 2)) } else { console.log(formatStatusDisplay(status)) } } catch (error) { console.error(formatError(`Failed to fetch status: ${error instanceof Error ? error.message : error}`)) } } // Initial run await runDashboard() // Set up interval const intervalId = setInterval(() => void runDashboard(), refreshInterval) // Handle graceful shutdown process.on('SIGINT', () => { clearInterval(intervalId) console.log('') console.log(formatInfo('Dashboard stopped')) process.exit(0) }) // Keep process running await new Promise(() => {}) } else { // Single snapshot console.log(formatInfo('Fetching migration status...')) const status = await client.getStatus() if (options.json) { console.log(JSON.stringify(status, null, 2)) } else { console.log(formatStatusDisplay(status)) } } } catch (error) { console.error(formatError(`Dashboard error: ${error instanceof Error ? error.message : error}`)) if (options.verbose && error instanceof Error && error.stack) { console.error(error.stack) } process.exit(1) } }