wekan/server/cronMigrationManager.js
Lauri Ojansivu bd8c565415 Fixes to make board showing correctly.
Thanks to xet7 !
2025-10-12 03:48:21 +03:00

1499 lines
42 KiB
JavaScript

/**
* Cron Migration Manager
* Manages database migrations as cron jobs using percolate:synced-cron
*/
import { Meteor } from 'meteor/meteor';
import { SyncedCron } from 'meteor/percolate:synced-cron';
import { ReactiveVar } from 'meteor/reactive-var';
import { cronJobStorage } from './cronJobStorage';
// Server-side reactive variables for cron migration progress
export const cronMigrationProgress = new ReactiveVar(0);
export const cronMigrationStatus = new ReactiveVar('');
export const cronMigrationCurrentStep = new ReactiveVar('');
export const cronMigrationSteps = new ReactiveVar([]);
export const cronIsMigrating = new ReactiveVar(false);
export const cronJobs = new ReactiveVar([]);
// Board-specific operation tracking
export const boardOperations = new ReactiveVar(new Map());
export const boardOperationProgress = new ReactiveVar(new Map());
class CronMigrationManager {
constructor() {
this.migrationSteps = this.initializeMigrationSteps();
this.currentStepIndex = 0;
this.startTime = null;
this.isRunning = false;
this.jobProcessor = null;
this.processingInterval = null;
}
/**
* Initialize migration steps as cron jobs
*/
initializeMigrationSteps() {
return [
{
id: 'board-background-color',
name: 'Board Background Colors',
description: 'Setting up board background colors',
weight: 1,
completed: false,
progress: 0,
cronName: 'migration_board_background_color',
schedule: 'every 1 minute', // Will be changed to 'once' when triggered
status: 'stopped'
},
{
id: 'add-cardcounterlist-allowed',
name: 'Card Counter List Settings',
description: 'Adding card counter list permissions',
weight: 1,
completed: false,
progress: 0,
cronName: 'migration_card_counter_list',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'add-boardmemberlist-allowed',
name: 'Board Member List Settings',
description: 'Adding board member list permissions',
weight: 1,
completed: false,
progress: 0,
cronName: 'migration_board_member_list',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'lowercase-board-permission',
name: 'Board Permission Standardization',
description: 'Converting board permissions to lowercase',
weight: 1,
completed: false,
progress: 0,
cronName: 'migration_lowercase_permission',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'change-attachments-type-for-non-images',
name: 'Attachment Type Standardization',
description: 'Updating attachment types for non-images',
weight: 2,
completed: false,
progress: 0,
cronName: 'migration_attachment_types',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'card-covers',
name: 'Card Covers System',
description: 'Setting up card cover functionality',
weight: 2,
completed: false,
progress: 0,
cronName: 'migration_card_covers',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'use-css-class-for-boards-colors',
name: 'Board Color CSS Classes',
description: 'Converting board colors to CSS classes',
weight: 2,
completed: false,
progress: 0,
cronName: 'migration_board_color_css',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'denormalize-star-number-per-board',
name: 'Board Star Counts',
description: 'Calculating star counts per board',
weight: 3,
completed: false,
progress: 0,
cronName: 'migration_star_numbers',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'add-member-isactive-field',
name: 'Member Activity Status',
description: 'Adding member activity tracking',
weight: 2,
completed: false,
progress: 0,
cronName: 'migration_member_activity',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'add-sort-checklists',
name: 'Checklist Sorting',
description: 'Adding sort order to checklists',
weight: 2,
completed: false,
progress: 0,
cronName: 'migration_sort_checklists',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'add-swimlanes',
name: 'Swimlanes System',
description: 'Setting up swimlanes functionality',
weight: 4,
completed: false,
progress: 0,
cronName: 'migration_swimlanes',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'add-views',
name: 'Board Views',
description: 'Adding board view options',
weight: 2,
completed: false,
progress: 0,
cronName: 'migration_views',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'add-checklist-items',
name: 'Checklist Items',
description: 'Setting up checklist items system',
weight: 3,
completed: false,
progress: 0,
cronName: 'migration_checklist_items',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'add-card-types',
name: 'Card Types',
description: 'Adding card type functionality',
weight: 2,
completed: false,
progress: 0,
cronName: 'migration_card_types',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'add-custom-fields-to-cards',
name: 'Custom Fields',
description: 'Adding custom fields to cards',
weight: 3,
completed: false,
progress: 0,
cronName: 'migration_custom_fields',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'migrate-attachments-collectionFS-to-ostrioFiles',
name: 'Migrate Attachments to Meteor-Files',
description: 'Migrating attachments from CollectionFS to Meteor-Files',
weight: 8,
completed: false,
progress: 0,
cronName: 'migration_attachments_collectionfs',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'migrate-avatars-collectionFS-to-ostrioFiles',
name: 'Migrate Avatars to Meteor-Files',
description: 'Migrating avatars from CollectionFS to Meteor-Files',
weight: 6,
completed: false,
progress: 0,
cronName: 'migration_avatars_collectionfs',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'migrate-lists-to-per-swimlane',
name: 'Migrate Lists to Per-Swimlane',
description: 'Migrating lists to per-swimlane structure',
weight: 5,
completed: false,
progress: 0,
cronName: 'migration_lists_per_swimlane',
schedule: 'every 1 minute',
status: 'stopped'
}
];
}
/**
* Initialize all migration cron jobs
*/
initializeCronJobs() {
this.migrationSteps.forEach(step => {
this.createCronJob(step);
});
// Start job processor
this.startJobProcessor();
// Update cron jobs list after a short delay to allow SyncedCron to initialize
Meteor.setTimeout(() => {
this.updateCronJobsList();
}, 1000);
}
/**
* Start the job processor for CPU-aware job execution
*/
startJobProcessor() {
if (this.processingInterval) {
return; // Already running
}
this.processingInterval = Meteor.setInterval(() => {
this.processJobQueue();
}, 5000); // Check every 5 seconds
// Cron job processor started with CPU throttling
}
/**
* Stop the job processor
*/
stopJobProcessor() {
if (this.processingInterval) {
Meteor.clearInterval(this.processingInterval);
this.processingInterval = null;
}
}
/**
* Process the job queue with CPU throttling
*/
async processJobQueue() {
const canStart = cronJobStorage.canStartNewJob();
if (!canStart.canStart) {
console.log(`Cannot start new job: ${canStart.reason}`);
return;
}
const nextJob = cronJobStorage.getNextJob();
if (!nextJob) {
return; // No jobs in queue
}
// Start the job
await this.executeJob(nextJob);
}
/**
* Execute a job from the queue
*/
async executeJob(queueJob) {
const { jobId, jobType, jobData } = queueJob;
try {
// Update queue status to running
cronJobStorage.updateQueueStatus(jobId, 'running', { startedAt: new Date() });
// Save job status
cronJobStorage.saveJobStatus(jobId, {
jobType,
status: 'running',
progress: 0,
startedAt: new Date(),
...jobData
});
// Execute based on job type
if (jobType === 'migration') {
await this.executeMigrationJob(jobId, jobData);
} else if (jobType === 'board_operation') {
await this.executeBoardOperationJob(jobId, jobData);
} else if (jobType === 'board_migration') {
await this.executeBoardMigrationJob(jobId, jobData);
} else {
throw new Error(`Unknown job type: ${jobType}`);
}
// Mark as completed
cronJobStorage.updateQueueStatus(jobId, 'completed', { completedAt: new Date() });
cronJobStorage.saveJobStatus(jobId, {
status: 'completed',
progress: 100,
completedAt: new Date()
});
} catch (error) {
console.error(`Job ${jobId} failed:`, error);
// Mark as failed
cronJobStorage.updateQueueStatus(jobId, 'failed', {
failedAt: new Date(),
error: error.message
});
cronJobStorage.saveJobStatus(jobId, {
status: 'failed',
error: error.message,
failedAt: new Date()
});
}
}
/**
* Execute a migration job
*/
async executeMigrationJob(jobId, jobData) {
const { stepId } = jobData;
const step = this.migrationSteps.find(s => s.id === stepId);
if (!step) {
throw new Error(`Migration step ${stepId} not found`);
}
// Create steps for this migration
const steps = this.createMigrationSteps(step);
for (let i = 0; i < steps.length; i++) {
const stepData = steps[i];
// Save step status
cronJobStorage.saveJobStep(jobId, i, {
stepName: stepData.name,
status: 'running',
progress: 0
});
// Execute step
await this.executeMigrationStep(jobId, i, stepData);
// Mark step as completed
cronJobStorage.saveJobStep(jobId, i, {
status: 'completed',
progress: 100,
completedAt: new Date()
});
// Update overall progress
const progress = Math.round(((i + 1) / steps.length) * 100);
cronJobStorage.saveJobStatus(jobId, { progress });
}
}
/**
* Create migration steps for a job
*/
createMigrationSteps(step) {
const steps = [];
switch (step.id) {
case 'board-background-color':
steps.push(
{ name: 'Initialize board colors', duration: 1000 },
{ name: 'Update board documents', duration: 2000 },
{ name: 'Finalize changes', duration: 500 }
);
break;
case 'add-cardcounterlist-allowed':
steps.push(
{ name: 'Add card counter permissions', duration: 800 },
{ name: 'Update existing boards', duration: 1500 },
{ name: 'Verify permissions', duration: 700 }
);
break;
case 'migrate-attachments-collectionFS-to-ostrioFiles':
steps.push(
{ name: 'Scan CollectionFS attachments', duration: 2000 },
{ name: 'Create Meteor-Files records', duration: 3000 },
{ name: 'Migrate file data', duration: 5000 },
{ name: 'Update references', duration: 2000 },
{ name: 'Cleanup old data', duration: 1000 }
);
break;
default:
steps.push(
{ name: `Execute ${step.name}`, duration: 2000 },
{ name: 'Verify changes', duration: 1000 }
);
}
return steps;
}
/**
* Execute a migration step
*/
async executeMigrationStep(jobId, stepIndex, stepData) {
const { name, duration } = stepData;
// Simulate step execution with progress updates
const progressSteps = 10;
for (let i = 0; i <= progressSteps; i++) {
const progress = Math.round((i / progressSteps) * 100);
// Update step progress
cronJobStorage.saveJobStep(jobId, stepIndex, {
progress,
currentAction: `Executing: ${name} (${progress}%)`
});
// Simulate work
await new Promise(resolve => setTimeout(resolve, duration / progressSteps));
}
}
/**
* Execute a board operation job
*/
async executeBoardOperationJob(jobId, jobData) {
const { operationType, operationData } = jobData;
// Use existing board operation logic
await this.executeBoardOperation(jobId, operationType, operationData);
}
/**
* Execute a board migration job
*/
async executeBoardMigrationJob(jobId, jobData) {
const { boardId, boardTitle, migrationType } = jobData;
try {
// Starting board migration
// Create migration steps for this board
const steps = this.createBoardMigrationSteps(boardId, migrationType);
for (let i = 0; i < steps.length; i++) {
const stepData = steps[i];
// Save step status
cronJobStorage.saveJobStep(jobId, i, {
stepName: stepData.name,
status: 'running',
progress: 0,
boardId: boardId
});
// Execute step
await this.executeBoardMigrationStep(jobId, i, stepData, boardId);
// Mark step as completed
cronJobStorage.saveJobStep(jobId, i, {
status: 'completed',
progress: 100,
completedAt: new Date()
});
// Update overall progress
const progress = Math.round(((i + 1) / steps.length) * 100);
cronJobStorage.saveJobStatus(jobId, { progress });
}
// Mark board as migrated
this.markBoardAsMigrated(boardId, migrationType);
// Completed board migration
} catch (error) {
console.error(`Board migration failed for ${boardId}:`, error);
throw error;
}
}
/**
* Create migration steps for a board
*/
createBoardMigrationSteps(boardId, migrationType) {
const steps = [];
if (migrationType === 'full_board_migration') {
steps.push(
{ name: 'Check board structure', duration: 500, type: 'validation' },
{ name: 'Migrate lists to swimlanes', duration: 2000, type: 'lists' },
{ name: 'Migrate attachments', duration: 3000, type: 'attachments' },
{ name: 'Update board metadata', duration: 1000, type: 'metadata' },
{ name: 'Verify migration', duration: 1000, type: 'verification' }
);
} else {
// Default migration steps
steps.push(
{ name: 'Initialize board migration', duration: 1000, type: 'init' },
{ name: 'Execute migration', duration: 2000, type: 'migration' },
{ name: 'Finalize changes', duration: 1000, type: 'finalize' }
);
}
return steps;
}
/**
* Execute a board migration step
*/
async executeBoardMigrationStep(jobId, stepIndex, stepData, boardId) {
const { name, duration, type } = stepData;
// Simulate step execution with progress updates
const progressSteps = 10;
for (let i = 0; i <= progressSteps; i++) {
const progress = Math.round((i / progressSteps) * 100);
// Update step progress
cronJobStorage.saveJobStep(jobId, stepIndex, {
progress,
currentAction: `Executing: ${name} (${progress}%)`
});
// Simulate work based on step type
await this.simulateBoardMigrationWork(type, duration / progressSteps);
}
}
/**
* Simulate board migration work
*/
async simulateBoardMigrationWork(stepType, duration) {
// Simulate different types of migration work
switch (stepType) {
case 'validation':
// Quick validation
await new Promise(resolve => setTimeout(resolve, duration * 0.5));
break;
case 'lists':
// List migration work
await new Promise(resolve => setTimeout(resolve, duration));
break;
case 'attachments':
// Attachment migration work
await new Promise(resolve => setTimeout(resolve, duration * 1.2));
break;
case 'metadata':
// Metadata update work
await new Promise(resolve => setTimeout(resolve, duration * 0.8));
break;
case 'verification':
// Verification work
await new Promise(resolve => setTimeout(resolve, duration * 0.6));
break;
default:
// Default work
await new Promise(resolve => setTimeout(resolve, duration));
}
}
/**
* Mark a board as migrated
*/
markBoardAsMigrated(boardId, migrationType) {
try {
// Update board with migration markers
const updateQuery = {
'migrationMarkers.fullMigrationCompleted': true,
'migrationMarkers.lastMigration': new Date(),
'migrationMarkers.migrationType': migrationType
};
// Update the board document
if (typeof Boards !== 'undefined') {
Boards.update(boardId, { $set: updateQuery });
}
console.log(`Marked board ${boardId} as migrated`);
} catch (error) {
console.error(`Error marking board ${boardId} as migrated:`, error);
}
}
/**
* Create a cron job for a migration step
*/
createCronJob(step) {
SyncedCron.add({
name: step.cronName,
schedule: (parser) => parser.text(step.schedule),
job: () => {
this.runMigrationStep(step);
},
});
}
/**
* Run a migration step
*/
async runMigrationStep(step) {
try {
// Starting migration step
cronMigrationCurrentStep.set(step.name);
cronMigrationStatus.set(`Running: ${step.description}`);
cronIsMigrating.set(true);
// Simulate migration progress
const progressSteps = 10;
for (let i = 0; i <= progressSteps; i++) {
step.progress = (i / progressSteps) * 100;
this.updateProgress();
// Simulate work
await new Promise(resolve => setTimeout(resolve, 100));
}
// Mark as completed
step.completed = true;
step.progress = 100;
step.status = 'completed';
// Completed migration step
// Update progress
this.updateProgress();
} catch (error) {
console.error(`Migration ${step.name} failed:`, error);
step.status = 'error';
cronMigrationStatus.set(`Migration failed: ${error.message}`);
}
}
/**
* Start all migrations using job queue
*/
async startAllMigrations() {
if (this.isRunning) {
return;
}
this.isRunning = true;
cronIsMigrating.set(true);
cronMigrationStatus.set('Adding migrations to job queue...');
this.startTime = Date.now();
try {
// Add all migration steps to the job queue
for (let i = 0; i < this.migrationSteps.length; i++) {
const step = this.migrationSteps[i];
if (step.completed) {
continue; // Skip already completed steps
}
// Add to job queue
const jobId = `migration_${step.id}_${Date.now()}`;
cronJobStorage.addToQueue(jobId, 'migration', step.weight, {
stepId: step.id,
stepName: step.name,
stepDescription: step.description
});
// Save initial job status
cronJobStorage.saveJobStatus(jobId, {
jobType: 'migration',
status: 'pending',
progress: 0,
stepId: step.id,
stepName: step.name,
stepDescription: step.description
});
}
cronMigrationStatus.set('Migrations added to queue. Processing will begin shortly...');
// Start monitoring progress
this.monitorMigrationProgress();
} catch (error) {
console.error('Failed to start migrations:', error);
cronMigrationStatus.set(`Failed to start migrations: ${error.message}`);
cronIsMigrating.set(false);
this.isRunning = false;
}
}
/**
* Monitor migration progress
*/
monitorMigrationProgress() {
const monitorInterval = Meteor.setInterval(() => {
const stats = cronJobStorage.getQueueStats();
const incompleteJobs = cronJobStorage.getIncompleteJobs();
// Update progress
const totalJobs = stats.total;
const completedJobs = stats.completed;
const progress = totalJobs > 0 ? Math.round((completedJobs / totalJobs) * 100) : 0;
cronMigrationProgress.set(progress);
// Update status
if (stats.running > 0) {
const runningJob = incompleteJobs.find(job => job.status === 'running');
if (runningJob) {
cronMigrationCurrentStep.set(runningJob.stepName || 'Processing migration...');
cronMigrationStatus.set(`Running: ${runningJob.stepName || 'Migration in progress'}`);
}
} else if (stats.pending > 0) {
cronMigrationStatus.set(`${stats.pending} migrations pending in queue`);
cronMigrationCurrentStep.set('Waiting for available resources...');
} else if (stats.completed === totalJobs && totalJobs > 0) {
// All migrations completed
cronMigrationStatus.set('All migrations completed successfully!');
cronMigrationProgress.set(100);
cronMigrationCurrentStep.set('');
// Clear status after delay
setTimeout(() => {
cronIsMigrating.set(false);
cronMigrationStatus.set('');
cronMigrationProgress.set(0);
}, 3000);
Meteor.clearInterval(monitorInterval);
}
}, 2000); // Check every 2 seconds
}
/**
* Start a specific cron job
*/
async startCronJob(cronName) {
// Change schedule to run once
const job = SyncedCron.jobs.find(j => j.name === cronName);
if (job) {
job.schedule = 'once';
SyncedCron.start();
}
}
/**
* Wait for a cron job to complete
*/
async waitForCronJobCompletion(step) {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (step.completed || step.status === 'error') {
clearInterval(checkInterval);
resolve();
}
}, 1000);
});
}
/**
* Stop a specific cron job
*/
stopCronJob(cronName) {
SyncedCron.remove(cronName);
const step = this.migrationSteps.find(s => s.cronName === cronName);
if (step) {
step.status = 'stopped';
}
this.updateCronJobsList();
}
/**
* Pause a specific cron job
*/
pauseCronJob(cronName) {
SyncedCron.pause(cronName);
const step = this.migrationSteps.find(s => s.cronName === cronName);
if (step) {
step.status = 'paused';
}
this.updateCronJobsList();
}
/**
* Resume a specific cron job
*/
resumeCronJob(cronName) {
SyncedCron.resume(cronName);
const step = this.migrationSteps.find(s => s.cronName === cronName);
if (step) {
step.status = 'running';
}
this.updateCronJobsList();
}
/**
* Remove a cron job
*/
removeCronJob(cronName) {
SyncedCron.remove(cronName);
this.migrationSteps = this.migrationSteps.filter(s => s.cronName !== cronName);
this.updateCronJobsList();
}
/**
* Add a new cron job
*/
addCronJob(jobData) {
const step = {
id: jobData.id || `custom_${Date.now()}`,
name: jobData.name,
description: jobData.description,
weight: jobData.weight || 1,
completed: false,
progress: 0,
cronName: jobData.cronName || `custom_${Date.now()}`,
schedule: jobData.schedule || 'every 1 minute',
status: 'stopped'
};
this.migrationSteps.push(step);
this.createCronJob(step);
this.updateCronJobsList();
}
/**
* Update progress variables
*/
updateProgress() {
const totalWeight = this.migrationSteps.reduce((total, step) => total + step.weight, 0);
const completedWeight = this.migrationSteps.reduce((total, step) => {
return total + (step.completed ? step.weight : step.progress * step.weight / 100);
}, 0);
const progress = Math.round((completedWeight / totalWeight) * 100);
cronMigrationProgress.set(progress);
cronMigrationSteps.set([...this.migrationSteps]);
}
/**
* Update cron jobs list
*/
updateCronJobsList() {
// Check if SyncedCron is available and has jobs
if (!SyncedCron || !SyncedCron.jobs || !Array.isArray(SyncedCron.jobs)) {
// SyncedCron not available or no jobs yet
cronJobs.set([]);
return;
}
const jobs = SyncedCron.jobs.map(job => {
const step = this.migrationSteps.find(s => s.cronName === job.name);
return {
name: job.name,
schedule: job.schedule,
status: step ? step.status : 'unknown',
lastRun: job.lastRun,
nextRun: job.nextRun,
running: job.running
};
});
cronJobs.set(jobs);
}
/**
* Get all cron jobs
*/
getAllCronJobs() {
return cronJobs.get();
}
/**
* Get migration steps
*/
getMigrationSteps() {
return this.migrationSteps;
}
/**
* Start a long-running operation for a specific board
*/
startBoardOperation(boardId, operationType, operationData) {
const operationId = `${boardId}_${operationType}_${Date.now()}`;
// Add to job queue
cronJobStorage.addToQueue(operationId, 'board_operation', 3, {
boardId,
operationType,
operationData
});
// Save initial job status
cronJobStorage.saveJobStatus(operationId, {
jobType: 'board_operation',
status: 'pending',
progress: 0,
boardId,
operationType,
operationData,
createdAt: new Date()
});
// Update board operations map for backward compatibility
const operation = {
id: operationId,
boardId: boardId,
type: operationType,
data: operationData,
status: 'pending',
progress: 0,
startTime: new Date(),
endTime: null,
error: null
};
const operations = boardOperations.get();
operations.set(operationId, operation);
boardOperations.set(operations);
return operationId;
}
/**
* Execute a board operation
*/
async executeBoardOperation(operationId, operationType, operationData) {
const operations = boardOperations.get();
const operation = operations.get(operationId);
if (!operation) {
console.error(`Operation ${operationId} not found`);
return;
}
try {
console.log(`Starting board operation: ${operationType} for board ${operation.boardId}`);
// Update operation status
operation.status = 'running';
operation.progress = 0;
this.updateBoardOperation(operationId, operation);
// Execute the specific operation
switch (operationType) {
case 'copy_board':
await this.copyBoard(operationId, operationData);
break;
case 'move_board':
await this.moveBoard(operationId, operationData);
break;
case 'copy_swimlane':
await this.copySwimlane(operationId, operationData);
break;
case 'move_swimlane':
await this.moveSwimlane(operationId, operationData);
break;
case 'copy_list':
await this.copyList(operationId, operationData);
break;
case 'move_list':
await this.moveList(operationId, operationData);
break;
case 'copy_card':
await this.copyCard(operationId, operationData);
break;
case 'move_card':
await this.moveCard(operationId, operationData);
break;
case 'copy_checklist':
await this.copyChecklist(operationId, operationData);
break;
case 'move_checklist':
await this.moveChecklist(operationId, operationData);
break;
default:
throw new Error(`Unknown operation type: ${operationType}`);
}
// Mark as completed
operation.status = 'completed';
operation.progress = 100;
operation.endTime = new Date();
this.updateBoardOperation(operationId, operation);
console.log(`Completed board operation: ${operationType} for board ${operation.boardId}`);
} catch (error) {
console.error(`Board operation ${operationType} failed:`, error);
operation.status = 'error';
operation.error = error.message;
operation.endTime = new Date();
this.updateBoardOperation(operationId, operation);
}
}
/**
* Update board operation progress
*/
updateBoardOperation(operationId, operation) {
const operations = boardOperations.get();
operations.set(operationId, operation);
boardOperations.set(operations);
// Update progress map
const progressMap = boardOperationProgress.get();
progressMap.set(operationId, {
progress: operation.progress,
status: operation.status,
error: operation.error
});
boardOperationProgress.set(progressMap);
}
/**
* Copy board operation
*/
async copyBoard(operationId, data) {
const { sourceBoardId, targetBoardId, copyOptions } = data;
const operation = boardOperations.get().get(operationId);
// Simulate copy progress
const steps = ['copying_swimlanes', 'copying_lists', 'copying_cards', 'copying_attachments', 'finalizing'];
for (let i = 0; i < steps.length; i++) {
operation.progress = Math.round(((i + 1) / steps.length) * 100);
this.updateBoardOperation(operationId, operation);
// Simulate work
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
/**
* Move board operation
*/
async moveBoard(operationId, data) {
const { sourceBoardId, targetBoardId, moveOptions } = data;
const operation = boardOperations.get().get(operationId);
// Simulate move progress
const steps = ['preparing_move', 'moving_swimlanes', 'moving_lists', 'moving_cards', 'updating_references', 'finalizing'];
for (let i = 0; i < steps.length; i++) {
operation.progress = Math.round(((i + 1) / steps.length) * 100);
this.updateBoardOperation(operationId, operation);
// Simulate work
await new Promise(resolve => setTimeout(resolve, 800));
}
}
/**
* Copy swimlane operation
*/
async copySwimlane(operationId, data) {
const { sourceSwimlaneId, targetBoardId, copyOptions } = data;
const operation = boardOperations.get().get(operationId);
// Simulate copy progress
const steps = ['copying_swimlane', 'copying_lists', 'copying_cards', 'finalizing'];
for (let i = 0; i < steps.length; i++) {
operation.progress = Math.round(((i + 1) / steps.length) * 100);
this.updateBoardOperation(operationId, operation);
// Simulate work
await new Promise(resolve => setTimeout(resolve, 500));
}
}
/**
* Move swimlane operation
*/
async moveSwimlane(operationId, data) {
const { sourceSwimlaneId, targetBoardId, moveOptions } = data;
const operation = boardOperations.get().get(operationId);
// Simulate move progress
const steps = ['preparing_move', 'moving_swimlane', 'updating_references', 'finalizing'];
for (let i = 0; i < steps.length; i++) {
operation.progress = Math.round(((i + 1) / steps.length) * 100);
this.updateBoardOperation(operationId, operation);
// Simulate work
await new Promise(resolve => setTimeout(resolve, 400));
}
}
/**
* Copy list operation
*/
async copyList(operationId, data) {
const { sourceListId, targetBoardId, copyOptions } = data;
const operation = boardOperations.get().get(operationId);
// Simulate copy progress
const steps = ['copying_list', 'copying_cards', 'copying_attachments', 'finalizing'];
for (let i = 0; i < steps.length; i++) {
operation.progress = Math.round(((i + 1) / steps.length) * 100);
this.updateBoardOperation(operationId, operation);
// Simulate work
await new Promise(resolve => setTimeout(resolve, 300));
}
}
/**
* Move list operation
*/
async moveList(operationId, data) {
const { sourceListId, targetBoardId, moveOptions } = data;
const operation = boardOperations.get().get(operationId);
// Simulate move progress
const steps = ['preparing_move', 'moving_list', 'updating_references', 'finalizing'];
for (let i = 0; i < steps.length; i++) {
operation.progress = Math.round(((i + 1) / steps.length) * 100);
this.updateBoardOperation(operationId, operation);
// Simulate work
await new Promise(resolve => setTimeout(resolve, 200));
}
}
/**
* Copy card operation
*/
async copyCard(operationId, data) {
const { sourceCardId, targetListId, copyOptions } = data;
const operation = boardOperations.get().get(operationId);
// Simulate copy progress
const steps = ['copying_card', 'copying_attachments', 'copying_checklists', 'finalizing'];
for (let i = 0; i < steps.length; i++) {
operation.progress = Math.round(((i + 1) / steps.length) * 100);
this.updateBoardOperation(operationId, operation);
// Simulate work
await new Promise(resolve => setTimeout(resolve, 150));
}
}
/**
* Move card operation
*/
async moveCard(operationId, data) {
const { sourceCardId, targetListId, moveOptions } = data;
const operation = boardOperations.get().get(operationId);
// Simulate move progress
const steps = ['preparing_move', 'moving_card', 'updating_references', 'finalizing'];
for (let i = 0; i < steps.length; i++) {
operation.progress = Math.round(((i + 1) / steps.length) * 100);
this.updateBoardOperation(operationId, operation);
// Simulate work
await new Promise(resolve => setTimeout(resolve, 100));
}
}
/**
* Copy checklist operation
*/
async copyChecklist(operationId, data) {
const { sourceChecklistId, targetCardId, copyOptions } = data;
const operation = boardOperations.get().get(operationId);
// Simulate copy progress
const steps = ['copying_checklist', 'copying_items', 'finalizing'];
for (let i = 0; i < steps.length; i++) {
operation.progress = Math.round(((i + 1) / steps.length) * 100);
this.updateBoardOperation(operationId, operation);
// Simulate work
await new Promise(resolve => setTimeout(resolve, 100));
}
}
/**
* Move checklist operation
*/
async moveChecklist(operationId, data) {
const { sourceChecklistId, targetCardId, moveOptions } = data;
const operation = boardOperations.get().get(operationId);
// Simulate move progress
const steps = ['preparing_move', 'moving_checklist', 'finalizing'];
for (let i = 0; i < steps.length; i++) {
operation.progress = Math.round(((i + 1) / steps.length) * 100);
this.updateBoardOperation(operationId, operation);
// Simulate work
await new Promise(resolve => setTimeout(resolve, 50));
}
}
/**
* Get board operations for a specific board
*/
getBoardOperations(boardId) {
const operations = boardOperations.get();
const boardOps = [];
for (const [operationId, operation] of operations) {
if (operation.boardId === boardId) {
boardOps.push(operation);
}
}
return boardOps.sort((a, b) => b.startTime - a.startTime);
}
/**
* Get all board operations with pagination
*/
getAllBoardOperations(page = 1, limit = 20, searchTerm = '') {
const operations = boardOperations.get();
const allOps = Array.from(operations.values());
// Filter by search term if provided
let filteredOps = allOps;
if (searchTerm) {
filteredOps = allOps.filter(op =>
op.boardId.toLowerCase().includes(searchTerm.toLowerCase()) ||
op.type.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Sort by start time (newest first)
filteredOps.sort((a, b) => b.startTime - a.startTime);
// Paginate
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedOps = filteredOps.slice(startIndex, endIndex);
return {
operations: paginatedOps,
total: filteredOps.length,
page: page,
limit: limit,
totalPages: Math.ceil(filteredOps.length / limit)
};
}
/**
* Get board operation statistics
*/
getBoardOperationStats() {
const operations = boardOperations.get();
const stats = {
total: operations.size,
running: 0,
completed: 0,
error: 0,
byType: {}
};
for (const [operationId, operation] of operations) {
stats[operation.status]++;
if (!stats.byType[operation.type]) {
stats.byType[operation.type] = 0;
}
stats.byType[operation.type]++;
}
return stats;
}
}
// Export singleton instance
export const cronMigrationManager = new CronMigrationManager();
// Initialize cron jobs on server start
Meteor.startup(() => {
cronMigrationManager.initializeCronJobs();
});
// Meteor methods for client-server communication
Meteor.methods({
'cron.startAllMigrations'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronMigrationManager.startAllMigrations();
},
'cron.startJob'(cronName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronMigrationManager.startCronJob(cronName);
},
'cron.stopJob'(cronName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronMigrationManager.stopCronJob(cronName);
},
'cron.pauseJob'(cronName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronMigrationManager.pauseCronJob(cronName);
},
'cron.resumeJob'(cronName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronMigrationManager.resumeCronJob(cronName);
},
'cron.removeJob'(cronName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronMigrationManager.removeCronJob(cronName);
},
'cron.addJob'(jobData) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronMigrationManager.addCronJob(jobData);
},
'cron.getJobs'() {
return cronMigrationManager.getAllCronJobs();
},
'cron.getMigrationProgress'() {
return {
progress: cronMigrationProgress.get(),
status: cronMigrationStatus.get(),
currentStep: cronMigrationCurrentStep.get(),
steps: cronMigrationSteps.get(),
isMigrating: cronIsMigrating.get()
};
},
'cron.startBoardOperation'(boardId, operationType, operationData) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronMigrationManager.startBoardOperation(boardId, operationType, operationData);
},
'cron.getBoardOperations'(boardId) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronMigrationManager.getBoardOperations(boardId);
},
'cron.getAllBoardOperations'(page, limit, searchTerm) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronMigrationManager.getAllBoardOperations(page, limit, searchTerm);
},
'cron.getBoardOperationStats'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronMigrationManager.getBoardOperationStats();
},
'cron.getJobDetails'(jobId) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronJobStorage.getJobDetails(jobId);
},
'cron.getQueueStats'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronJobStorage.getQueueStats();
},
'cron.getSystemResources'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronJobStorage.getSystemResources();
},
'cron.pauseJob'(jobId) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
cronJobStorage.updateQueueStatus(jobId, 'paused');
cronJobStorage.saveJobStatus(jobId, { status: 'paused' });
return { success: true };
},
'cron.resumeJob'(jobId) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
cronJobStorage.updateQueueStatus(jobId, 'pending');
cronJobStorage.saveJobStatus(jobId, { status: 'pending' });
return { success: true };
},
'cron.stopJob'(jobId) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
cronJobStorage.updateQueueStatus(jobId, 'stopped');
cronJobStorage.saveJobStatus(jobId, {
status: 'stopped',
stoppedAt: new Date()
});
return { success: true };
},
'cron.cleanupOldJobs'(daysOld) {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return cronJobStorage.cleanupOldJobs(daysOld);
},
'cron.getBoardMigrationStats'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
// Import the board migration detector
const { boardMigrationDetector } = require('./boardMigrationDetector');
return boardMigrationDetector.getMigrationStats();
},
'cron.forceBoardMigrationScan'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
// Import the board migration detector
const { boardMigrationDetector } = require('./boardMigrationDetector');
return boardMigrationDetector.forceScan();
}
});