UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

338 lines 13.2 kB
/** * Log Update Progress Reporter - Clean multi-line progress using log-update * Uses proven anti-flicker technique: only update when content meaningfully changes */ import chalk from 'chalk'; import logUpdate from 'log-update'; import ProgressEventEmitter from '../events/ProgressEventEmitter.js'; export class LogUpdateProgressReporter { projects = new Map(); totalSteps = 0; completedSteps = 0; spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; spinnerIndex = 0; spinnerInterval = null; startTime = Date.now(); renderInterval = null; lastOutput = ''; resizeTimeout = null; isResizing = false; lastRenderTime = 0; RENDER_THROTTLE_MS = 100; // Match render interval progressEventHandler = this.handleProgressEvent.bind(this); previousLineCount = 0; outputCache = ''; constructor() { // Listen to progress events with proper bound handler ProgressEventEmitter.on('progress', this.progressEventHandler); // Don't start render loop until startSync is called } /** * Start sync operation */ startSync(operation, options) { // Start the render loop now this.startRenderLoop(); // The first render will show the header } /** * Set total steps */ setTotalSteps(projectCount, entitiesPerProject, totalSteps) { this.totalSteps = totalSteps || (projectCount * entitiesPerProject); } /** * Handle progress events */ handleProgressEvent(event) { if (event.type === 'start') { if (event.entity === 'project' && event.projectName) { this.startProject(event.projectName); } else if (event.projectName) { this.startEntity(event.projectName, event.entity); } } else if (event.type === 'progress' && event.count !== undefined) { this.updateEntity(event.projectName || '', event.entity, event.count, event.isOngoing, event.total); } else if (event.type === 'complete') { this.completeEntity(event.projectName || '', event.entity, event.count); this.completedSteps++; } } /** * Start a new project section */ startProject(projectName) { const isFeature = projectName.toLowerCase().includes('feature') || projectName.toLowerCase().includes('flag'); this.projects.set(projectName, { name: projectName, entities: new Map(), isFeature }); } /** * Start tracking an entity */ startEntity(projectName, entityName) { const project = this.projects.get(projectName); if (!project) return; // Skip if already tracking if (project.entities.has(entityName)) return; project.entities.set(entityName, { name: entityName, current: 0, status: 'active', startTime: Date.now() }); } /** * Update entity progress */ updateEntity(projectName, entityName, count, isOngoing, total) { const project = this.projects.get(projectName); if (!project) return; const entity = project.entities.get(entityName); if (!entity || entity.status === 'complete') return; entity.current = count; if (total !== undefined) { // If total is explicitly provided (e.g., for flag rulesets), use it entity.total = total; } else if (!isOngoing) { // Otherwise, if not ongoing, the count is the total entity.total = count; } } /** * Complete an entity */ completeEntity(projectName, entityName, finalCount) { const project = this.projects.get(projectName); if (!project) return; const entity = project.entities.get(entityName); if (!entity) return; entity.status = 'complete'; entity.current = finalCount || entity.current; entity.total = entity.current; } /** * Start render loop with proven anti-flicker technique */ startRenderLoop() { // Single render loop like npm/Yarn - update everything together this.renderInterval = setInterval(() => { this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length; this.render(); }, 100); // 10 FPS for smooth spinners // Terminal resize handling - immediate clear and redraw like Gauge if (process.stdout.isTTY) { process.stdout.on('resize', () => { // Clear any resize timeout if (this.resizeTimeout) { clearTimeout(this.resizeTimeout); } // Immediate redraw on resize (like npm's Gauge) // Clear the current output completely logUpdate.clear(); // Force a full redraw with new dimensions this.outputCache = ''; // Clear cache to force update this.previousLineCount = 0; this.render(); }); } } /** * Request a render with throttling */ requestRender(force = false) { const now = Date.now(); // Throttle renders to prevent excessive updates if (!force && (now - this.lastRenderTime) < this.RENDER_THROTTLE_MS) { return; } this.lastRenderTime = now; this.render(); } /** * Fit content to terminal height to prevent scrolling flicker */ fitToTerminalHeight(lines) { const terminalHeight = process.stdout.rows || 24; const maxLines = Math.max(1, terminalHeight - 2); // Reserve 2 lines for margins if (lines.length <= maxLines) { return lines; } // Truncate and add indicator const truncatedLines = lines.slice(0, maxLines - 1); const hiddenCount = lines.length - truncatedLines.length; truncatedLines.push(chalk.gray(`... and ${hiddenCount} more lines (resize terminal for full view)`)); return truncatedLines; } /** * Render with smart change detection to prevent unnecessary updates */ render() { const lines = []; const terminalHeight = process.stdout.rows || 24; const terminalWidth = process.stdout.columns || 80; // For very small terminals, show minimal content if (terminalHeight < 6) { const minimalOutput = chalk.blue('🚀 Sync Running (resize terminal for details)'); if (this.outputCache !== minimalOutput) { this.outputCache = minimalOutput; logUpdate(minimalOutput); } return; } // Header lines.push(chalk.bold.blue('🚀 Optimizely Cache Sync')); lines.push(chalk.gray(`Full Sync • ${new Date().toLocaleTimeString()}`)); lines.push(''); // Show all projects and entities - don't truncate content let projectCount = 0; for (const project of this.projects.values()) { const icon = project.isFeature ? '📦' : '📂'; lines.push(chalk.bold.cyan(`${icon} ${project.name}`)); const entities = Array.from(project.entities.entries()); for (let index = 0; index < entities.length; index++) { const [name, entity] = entities[index]; const isLast = index === entities.length - 1; const prefix = isLast ? '└─' : '│ '; const displayName = name.replace(/_/g, ' ').padEnd(16, ' '); const dots = '.'.repeat(Math.max(1, 20 - displayName.length)); let status = ''; if (entity.status === 'complete') { const count = entity.total || entity.current; const countStr = count === 0 ? 'none' : count.toString(); status = chalk.green(`${countStr.padStart(6)} ✔`); } else if (entity.status === 'active') { const countStr = entity.current.toString(); const totalStr = entity.total ? entity.total.toString() : '?'; const progress = `${countStr}/${totalStr}`; const spinner = this.spinnerFrames[this.spinnerIndex]; // Use bright cyan for active spinner to make it stand out status = chalk.yellow(progress.padStart(9)) + ' ' + chalk.cyanBright(spinner); // Add context for special cases (only if there's enough terminal width) if (name === 'flag_rulesets' && terminalWidth > 100) { // Get actual batch size from env or use default const batchSize = parseInt(process.env.FLAG_RULESET_BATCH_SIZE || '10'); if (batchSize > 1) { const completedBatches = Math.floor(entity.current / batchSize); const totalBatches = entity.total ? Math.ceil(entity.total / batchSize) : '?'; status += chalk.gray(` (batch ${completedBatches}/${totalBatches})`); } // Don't show batch info when batch size is 1 as it's redundant } } else { // Pending status - no spinner status = chalk.gray('pending'.padStart(9)); } // Truncate line if it's too long for terminal width let line = `${prefix} ${chalk.white(displayName)} ${chalk.gray(dots)} ${status}`; if (line.length > terminalWidth) { line = line.substring(0, terminalWidth - 3) + '...'; } lines.push(line); } // Add blank line between projects (but not after the last one) if (projectCount < this.projects.size - 1) { lines.push(''); } projectCount++; } // No truncation - show all projects // Progress bar if (this.totalSteps > 0 && this.completedSteps >= 0) { const percent = Math.round((this.completedSteps / this.totalSteps) * 100); const barLength = 30; const filledLength = Math.max(0, Math.min(barLength, Math.round((barLength * this.completedSteps) / this.totalSteps))); const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength); const elapsed = Math.round((Date.now() - this.startTime) / 1000); const progressText = `Progress: [${bar}] ${percent}% (${this.completedSteps}/${this.totalSteps}) ${elapsed}s`; lines.push(chalk.cyan(progressText)); } // Join all lines - log-update handles scrolling const currentOutput = lines.join('\n'); // Only update if content has changed if (this.outputCache !== currentOutput) { this.outputCache = currentOutput; logUpdate(currentOutput); } } /** * Complete sync operation */ completeSync(result) { // Stop render loop this.stopRenderLoop(); // Final render this.render(); // Persist the output (log-update pattern) logUpdate.done(); // Success message console.log(''); console.log(chalk.bold.green('✨ Sync Completed Successfully!')); if (result.duration) { console.log(chalk.gray(`⏱️ Completed in ${(result.duration / 1000).toFixed(1)}s`)); } } /** * Report error */ error(error) { // Stop render loop this.stopRenderLoop(); // Persist current output (log-update pattern) logUpdate.done(); console.log(''); console.error(chalk.red.bold('❌ Sync Failed!')); console.error(chalk.red(error.message)); } /** * Stop render loop */ stopRenderLoop() { if (this.renderInterval) { clearInterval(this.renderInterval); this.renderInterval = null; } if (this.resizeTimeout) { clearTimeout(this.resizeTimeout); this.resizeTimeout = null; } } /** * Clean up */ dispose() { this.stopRenderLoop(); logUpdate.done(); // Reset state this.lastOutput = ''; this.isResizing = false; // Remove event listeners with proper handler reference ProgressEventEmitter.removeListener('progress', this.progressEventHandler); // Remove resize listeners if they exist if (process.stdout.isTTY) { process.stdout.removeAllListeners('resize'); } } /** * Legacy updateProgress method (no-op in log-update mode) */ updateProgress(progress) { // Do nothing - we use events only } } //# sourceMappingURL=LogUpdateProgressReporter.js.map