@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
338 lines • 13.2 kB
JavaScript
/**
* 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