From a31a615da6911a2db22d4db86875b31fc951ae96 Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Wed, 21 Jan 2026 00:56:42 +0200 Subject: [PATCH] Fix DB migration from 8.19 to 8.21 stuck forever. Thanks to MaccabeeY and xet7 ! Fixes #6078 --- client/components/settings/cronSettings.css | 40 ++ client/components/settings/cronSettings.jade | 57 +- client/components/settings/settingBody.jade | 31 +- client/components/settings/settingBody.js | 89 ++- imports/cronMigrationClient.js | 4 + imports/i18n/data/en.i18n.json | 22 + server/cronJobStorage.js | 61 ++ server/cronMigrationManager.js | 553 ++++++++++++++++++- server/migrations/ensureValidSwimlaneIds.js | 83 ++- 9 files changed, 869 insertions(+), 71 deletions(-) diff --git a/client/components/settings/cronSettings.css b/client/components/settings/cronSettings.css index 95c0d70ff..0213157b3 100644 --- a/client/components/settings/cronSettings.css +++ b/client/components/settings/cronSettings.css @@ -862,3 +862,43 @@ max-width: 100%; } } + +/* Progress bar styles for #cron-setting section */ +#cron-setting .progress-section { + margin-top: 15px; +} + +#cron-setting .step-counter { + margin-bottom: 8px; + font-weight: 600; + color: #333; + font-size: 14px; +} + +#cron-setting .progress { + height: 30px; + background-color: #e9ecef; + border-radius: 4px; + overflow: visible; + margin-bottom: 5px; + max-width: calc(100% - 40px); +} + +#cron-setting .progress-bar { + height: 30px; + line-height: 30px; + background-color: #28a745; + color: white; + font-weight: 600; + font-size: 14px; + text-align: center; + transition: width 0.3s ease; + border-radius: 4px; +} + +#cron-setting .progress-text { + font-size: 13px; + color: #666; + margin-top: 5px; + max-width: calc(100% - 40px); +} diff --git a/client/components/settings/cronSettings.jade b/client/components/settings/cronSettings.jade index b1b71a0d1..2d229c8c3 100644 --- a/client/components/settings/cronSettings.jade +++ b/client/components/settings/cronSettings.jade @@ -3,21 +3,52 @@ template(name="cronSettings") li h3 {{_ 'cron-migrations'}} .form-group - label {{_ 'migration-status'}} - .status-indicator - span.status-label {{_ 'status'}}: - span.status-value {{migrationStatus}} - .progress-section - .progress - .progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100") - | {{migrationProgress}}% - .progress-text - | {{migrationProgress}}% {{_ 'complete'}} + label {{_ 'select-migration'}} + select.js-migration-select.wekan-form-control + option(value="0") 0 - {{_ 'all-migrations'}} + each migrationStepsWithIndex + option(value="{{index}}") {{index}} - {{name}} .form-group - button.js-start-all-migrations.btn.btn-primary {{_ 'start-all-migrations'}} - button.js-pause-all-migrations.btn.btn-warning {{_ 'pause-all-migrations'}} - button.js-stop-all-migrations.btn.btn-danger {{_ 'stop-all-migrations'}} + label {{_ 'migration-status'}} + .status-indicator + span.status-value {{migrationStatus}} + if isMigrating + .progress-section + .step-counter + | Step {{migrationCurrentStepNum}}/{{migrationTotalSteps}} + .progress + .progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100") + | {{migrationProgress}}% + .progress-text + | {{migrationProgress}}% {{_ 'complete'}} + + .form-group + button.js-start-migration.btn.btn-primary(disabled="{{#if isMigrating}}disabled{{/if}}") {{_ 'start'}} + button.js-pause-migration.btn.btn-warning(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'pause'}} + button.js-stop-migration.btn.btn-danger(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'stop'}} + + .form-group.migration-errors-section + h4 {{_ 'cron-migration-errors'}} + if hasErrors + .error-actions + button.js-clear-all-errors.btn.btn-sm.btn-warning {{_ 'cron-clear-errors'}} + .errors-list + each migrationErrors + .error-item(class="error-{{severity}}") + .error-header + span.error-severity(class="severity-{{severity}}") {{severity}} + span.error-time {{formatDateTime createdAt}} + if stepId + span.error-step {{stepId}} + .error-message {{errorMessage}} + if context + .error-context + each contextValue context + span.context-item {{this}} + else + .no-errors + | {{_ 'cron-no-errors'}} li h3 {{_ 'board-operations'}} diff --git a/client/components/settings/settingBody.jade b/client/components/settings/settingBody.jade index 5653d84a9..bcf538b43 100644 --- a/client/components/settings/settingBody.jade +++ b/client/components/settings/settingBody.jade @@ -170,25 +170,22 @@ template(name="setting") label {{_ 'migration-status'}} .status-indicator span.status-label {{_ 'status'}}: - span.status-value {{#if isMigrating}}{{migrationStatus}}{{else}}{{_ 'idle'}}{{/if}} - .current-step(class="{{#unless migrationCurrentStep}}hide{{/unless}}") - span.step-label {{_ 'current-step'}}: - span.step-value {{migrationCurrentStep}} - .progress-section - .progress - .progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100") - | {{migrationProgress}}% - .progress-text - | {{migrationProgress}}% {{_ 'complete'}} + span.status-value + if isMigrating + i.fa.fa-spinner.fa-spin(style="margin-right: 8px;") + | {{#if isMigrating}}{{migrationStatus}}{{else}}{{_ 'idle'}}{{/if}} + if isMigrating + .progress-section + .progress + .progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100") + | {{migrationProgress}}% + .progress-text + | {{migrationProgress}}% {{_ 'complete'}} .form-group - button.js-start-all-migrations.btn.btn-primary {{#if isMigrating}}disabled{{/if}} {{_ 'start-all-migrations'}} - button.js-pause-all-migrations.btn.btn-warning {{#unless isMigrating}}disabled{{/unless}} {{_ 'pause-all-migrations'}} - button.js-stop-all-migrations.btn.btn-danger {{#unless isMigrating}}disabled{{/unless}} {{_ 'stop-all-migrations'}} - - li - h3 {{_ 'migration-steps'}} - p Migration steps section temporarily removed + button.js-start-all-migrations.btn.btn-primary(disabled="{{#if isMigrating}}disabled{{/if}}") {{_ 'start-all-migrations'}} + button.js-pause-all-migrations.btn.btn-warning(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'pause-all-migrations'}} + button.js-stop-all-migrations.btn.btn-danger(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'stop-all-migrations'}} li h3 {{_ 'board-operations'}} diff --git a/client/components/settings/settingBody.js b/client/components/settings/settingBody.js index bad99ebfa..2616fd976 100644 --- a/client/components/settings/settingBody.js +++ b/client/components/settings/settingBody.js @@ -8,7 +8,9 @@ import { cronMigrationCurrentStep, cronMigrationSteps, cronIsMigrating, - cronJobs + cronJobs, + cronMigrationCurrentStepNum, + cronMigrationTotalSteps } from '/imports/cronMigrationClient'; @@ -27,6 +29,7 @@ BlazeComponent.extendComponent({ this.webhookSetting = new ReactiveVar(false); this.attachmentSettings = new ReactiveVar(false); this.cronSettings = new ReactiveVar(false); + this.migrationErrorsList = new ReactiveVar([]); Meteor.subscribe('setting'); Meteor.subscribe('mailServer'); @@ -36,6 +39,23 @@ BlazeComponent.extendComponent({ Meteor.subscribe('accessibilitySettings'); Meteor.subscribe('globalwebhooks'); Meteor.subscribe('lockoutSettings'); + + // Poll for migration errors + this.errorPollInterval = Meteor.setInterval(() => { + if (this.cronSettings.get()) { + Meteor.call('cron.getAllMigrationErrors', 50, (error, result) => { + if (!error && result) { + this.migrationErrorsList.set(result); + } + }); + } + }, 5000); // Poll every 5 seconds + }, + + onDestroyed() { + if (this.errorPollInterval) { + Meteor.clearInterval(this.errorPollInterval); + } }, @@ -142,10 +162,40 @@ BlazeComponent.extendComponent({ return cronMigrationSteps.get() || []; }, + migrationStepsWithIndex() { + const steps = cronMigrationSteps.get() || []; + return steps.map((step, idx) => ({ + ...step, + index: idx + 1 + })); + }, + cronJobs() { return cronJobs.get() || []; }, + migrationCurrentStepNum() { + return cronMigrationCurrentStepNum.get() || 0; + }, + + migrationTotalSteps() { + return cronMigrationTotalSteps.get() || 0; + }, + + migrationErrors() { + return this.migrationErrorsList ? this.migrationErrorsList.get() : []; + }, + + hasErrors() { + const errors = this.migrationErrors(); + return errors && errors.length > 0; + }, + + formatDateTime(date) { + if (!date) return ''; + return moment(date).format('YYYY-MM-DD HH:mm:ss'); + }, + setLoading(w) { this.loading.set(w); }, @@ -187,20 +237,35 @@ BlazeComponent.extendComponent({ }, // Event handlers for cron settings - 'click button.js-start-all-migrations'(event) { + 'click button.js-start-migration'(event) { event.preventDefault(); this.setLoading(true); - Meteor.call('cron.startAllMigrations', (error, result) => { - this.setLoading(false); - if (error) { - alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason); - } else { - alert(TAPi18n.__('migration-started')); - } - }); + const selectedIndex = parseInt($('.js-migration-select').val() || '0', 10); + + if (selectedIndex === 0) { + // Run all migrations + Meteor.call('cron.startAllMigrations', (error, result) => { + this.setLoading(false); + if (error) { + alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason); + } else { + alert(TAPi18n.__('migration-started')); + } + }); + } else { + // Run specific migration + Meteor.call('cron.startSpecificMigration', selectedIndex - 1, (error, result) => { + this.setLoading(false); + if (error) { + alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason); + } else { + alert(TAPi18n.__('migration-started')); + } + }); + } }, - 'click button.js-pause-all-migrations'(event) { + 'click button.js-pause-migration'(event) { event.preventDefault(); this.setLoading(true); Meteor.call('cron.pauseAllMigrations', (error, result) => { @@ -213,7 +278,7 @@ BlazeComponent.extendComponent({ }); }, - 'click button.js-stop-all-migrations'(event) { + 'click button.js-stop-migration'(event) { event.preventDefault(); if (confirm(TAPi18n.__('migration-stop-confirm'))) { this.setLoading(true); diff --git a/imports/cronMigrationClient.js b/imports/cronMigrationClient.js index aa28a009e..613f9287e 100644 --- a/imports/cronMigrationClient.js +++ b/imports/cronMigrationClient.js @@ -7,6 +7,8 @@ export const cronMigrationCurrentStep = new ReactiveVar(''); export const cronMigrationSteps = new ReactiveVar([]); export const cronIsMigrating = new ReactiveVar(false); export const cronJobs = new ReactiveVar([]); +export const cronMigrationCurrentStepNum = new ReactiveVar(0); +export const cronMigrationTotalSteps = new ReactiveVar(0); function fetchProgress() { Meteor.call('cron.getMigrationProgress', (err, res) => { @@ -17,6 +19,8 @@ function fetchProgress() { cronMigrationCurrentStep.set(res.currentStep || ''); cronMigrationSteps.set(res.steps || []); cronIsMigrating.set(res.isMigrating || false); + cronMigrationCurrentStepNum.set(res.currentStepNum || 0); + cronMigrationTotalSteps.set(res.totalSteps || 0); }); } diff --git a/imports/i18n/data/en.i18n.json b/imports/i18n/data/en.i18n.json index b405bb78b..d577d63d0 100644 --- a/imports/i18n/data/en.i18n.json +++ b/imports/i18n/data/en.i18n.json @@ -1389,9 +1389,31 @@ "cron-job-deleted": "Scheduled job deleted successfully", "cron-job-pause-failed": "Failed to pause scheduled job", "cron-job-paused": "Scheduled job paused successfully", + "cron-migration-errors": "Migration Errors", + "cron-migration-warnings": "Migration Warnings", + "cron-no-errors": "No errors to display", + "cron-error-severity": "Severity", + "cron-error-time": "Time", + "cron-error-message": "Error Message", + "cron-error-details": "Details", + "cron-clear-errors": "Clear All Errors", + "cron-retry-failed": "Retry Failed Migrations", + "cron-resume-paused": "Resume Paused Migrations", + "cron-errors-cleared": "All errors cleared successfully", + "cron-no-failed-migrations": "No failed migrations to retry", + "cron-no-paused-migrations": "No paused migrations to resume", + "cron-migrations-resumed": "Migrations resumed successfully", + "cron-migrations-retried": "Failed migrations retried successfully", + "complete": "Complete", + "idle": "Idle", "filesystem-path-description": "Base path for file storage", "gridfs-enabled": "GridFS Enabled", "gridfs-enabled-description": "Use MongoDB GridFS for file storage", + "all-migrations": "All Migrations", + "select-migration": "Select Migration", + "start": "Start", + "pause": "Pause", + "stop": "Stop", "migration-pause-failed": "Failed to pause migrations", "migration-paused": "Migrations paused successfully", "migration-progress": "Migration Progress", diff --git a/server/cronJobStorage.js b/server/cronJobStorage.js index 18c0b0150..028198df8 100644 --- a/server/cronJobStorage.js +++ b/server/cronJobStorage.js @@ -10,6 +10,7 @@ import { Mongo } from 'meteor/mongo'; export const CronJobStatus = new Mongo.Collection('cronJobStatus'); export const CronJobSteps = new Mongo.Collection('cronJobSteps'); export const CronJobQueue = new Mongo.Collection('cronJobQueue'); +export const CronJobErrors = new Mongo.Collection('cronJobErrors'); // Indexes for performance if (Meteor.isServer) { @@ -29,6 +30,12 @@ if (Meteor.isServer) { CronJobQueue._collection.createIndex({ priority: 1, createdAt: 1 }); CronJobQueue._collection.createIndex({ status: 1 }); CronJobQueue._collection.createIndex({ jobType: 1 }); + + // Index for job errors queries + CronJobErrors._collection.createIndex({ jobId: 1, createdAt: -1 }); + CronJobErrors._collection.createIndex({ stepId: 1 }); + CronJobErrors._collection.createIndex({ severity: 1 }); + CronJobErrors._collection.createIndex({ createdAt: -1 }); }); } @@ -146,6 +153,59 @@ class CronJobStorage { }, { sort: { stepIndex: 1 } }).fetch(); } + /** + * Save job error to persistent storage + */ + saveJobError(jobId, errorData) { + const now = new Date(); + const { stepId, stepIndex, error, severity = 'error', context = {} } = errorData; + + CronJobErrors.insert({ + jobId, + stepId, + stepIndex, + errorMessage: typeof error === 'string' ? error : error.message || 'Unknown error', + errorStack: error.stack || null, + severity, + context, + createdAt: now + }); + } + + /** + * Get job errors from persistent storage + */ + getJobErrors(jobId, options = {}) { + const { limit = 100, severity = null } = options; + + const query = { jobId }; + if (severity) { + query.severity = severity; + } + + return CronJobErrors.find(query, { + sort: { createdAt: -1 }, + limit + }).fetch(); + } + + /** + * Get all recent errors across all jobs + */ + getAllRecentErrors(limit = 50) { + return CronJobErrors.find({}, { + sort: { createdAt: -1 }, + limit + }).fetch(); + } + + /** + * Clear errors for a specific job + */ + clearJobErrors(jobId) { + return CronJobErrors.remove({ jobId }); + } + /** * Add job to queue */ @@ -379,6 +439,7 @@ class CronJobStorage { CronJobStatus.remove({}); CronJobSteps.remove({}); CronJobQueue.remove({}); + CronJobErrors.remove({}); console.log('All cron job data cleared from storage'); return { success: true, message: 'All cron job data cleared' }; diff --git a/server/cronMigrationManager.js b/server/cronMigrationManager.js index 2b9d1777b..ffb5801bc 100644 --- a/server/cronMigrationManager.js +++ b/server/cronMigrationManager.js @@ -6,7 +6,13 @@ import { Meteor } from 'meteor/meteor'; import { SyncedCron } from 'meteor/quave:synced-cron'; import { ReactiveVar } from 'meteor/reactive-var'; -import { cronJobStorage } from './cronJobStorage'; +import { check, Match } from 'meteor/check'; +import { ReactiveCache } from '/imports/reactiveCache'; +import { cronJobStorage, CronJobStatus } from './cronJobStorage'; +import Users from '/models/users'; +import Boards from '/models/boards'; +import { runEnsureValidSwimlaneIdsMigration } from './migrations/ensureValidSwimlaneIds'; + // Server-side reactive variables for cron migration progress export const cronMigrationProgress = new ReactiveVar(0); @@ -15,6 +21,8 @@ export const cronMigrationCurrentStep = new ReactiveVar(''); export const cronMigrationSteps = new ReactiveVar([]); export const cronIsMigrating = new ReactiveVar(false); export const cronJobs = new ReactiveVar([]); +export const cronMigrationCurrentStepNum = new ReactiveVar(0); +export const cronMigrationTotalSteps = new ReactiveVar(0); // Board-specific operation tracking export const boardOperations = new ReactiveVar(new Map()); @@ -28,6 +36,7 @@ class CronMigrationManager { this.isRunning = false; this.jobProcessor = null; this.processingInterval = null; + this.monitorInterval = null; this.pausedJobs = new Map(); // Store paused job configs for per-job pause/resume } @@ -135,6 +144,17 @@ class CronMigrationManager { schedule: 'every 1 minute', status: 'stopped' }, + { + id: 'ensure-valid-swimlane-ids', + name: 'Validate Swimlane IDs', + description: 'Ensuring all cards and lists have valid swimlaneId references', + weight: 2, + completed: false, + progress: 0, + cronName: 'migration_swimlane_ids', + schedule: 'every 1 minute', + status: 'stopped' + }, { id: 'add-sort-checklists', name: 'Checklist Sorting', @@ -447,6 +467,18 @@ class CronMigrationManager { async executeMigrationStep(jobId, stepIndex, stepData, stepId) { const { name, duration } = stepData; + // Check if this is the star count migration that needs real implementation + if (stepId === 'denormalize-star-number-per-board') { + await this.executeDenormalizeStarCount(jobId, stepIndex, stepData); + return; + } + + // Check if this is the swimlane validation migration + if (stepId === 'ensure-valid-swimlane-ids') { + await this.executeEnsureValidSwimlaneIds(jobId, stepIndex, stepData); + return; + } + // Simulate step execution with progress updates for other migrations const progressSteps = 10; for (let i = 0; i <= progressSteps; i++) { @@ -463,6 +495,173 @@ class CronMigrationManager { } } + /** + * Execute the denormalize star count migration + */ + async executeDenormalizeStarCount(jobId, stepIndex, stepData) { + try { + const { name } = stepData; + + // Update progress: Starting + cronJobStorage.saveJobStep(jobId, stepIndex, { + progress: 0, + currentAction: 'Counting starred boards across all users...' + }); + + // Build a map of boardId -> star count + const starCounts = new Map(); + + // Get all users with starred boards + const users = Users.find( + { 'profile.starredBoards': { $exists: true, $ne: [] } }, + { fields: { 'profile.starredBoards': 1 } } + ).fetch(); + + // Update progress: Counting + cronJobStorage.saveJobStep(jobId, stepIndex, { + progress: 20, + currentAction: `Analyzing ${users.length} users with starred boards...` + }); + + // Count stars for each board + users.forEach(user => { + const starredBoards = (user.profile && user.profile.starredBoards) || []; + starredBoards.forEach(boardId => { + starCounts.set(boardId, (starCounts.get(boardId) || 0) + 1); + }); + }); + + // Update progress: Updating boards + cronJobStorage.saveJobStep(jobId, stepIndex, { + progress: 50, + currentAction: `Updating star counts for ${starCounts.size} boards...` + }); + + // Update all boards with their star counts + let updatedCount = 0; + const totalBoards = starCounts.size; + + for (const [boardId, count] of starCounts.entries()) { + try { + Boards.update(boardId, { $set: { stars: count } }); + updatedCount++; + + // Update progress periodically + if (updatedCount % 10 === 0 || updatedCount === totalBoards) { + const progress = 50 + Math.round((updatedCount / totalBoards) * 40); + cronJobStorage.saveJobStep(jobId, stepIndex, { + progress, + currentAction: `Updated ${updatedCount}/${totalBoards} boards...` + }); + } + } catch (error) { + console.error(`Failed to update star count for board ${boardId}:`, error); + // Store error in database + cronJobStorage.saveJobError(jobId, { + stepId: 'denormalize-star-number-per-board', + stepIndex, + error, + severity: 'warning', + context: { boardId, operation: 'update_star_count' } + }); + } + } + + // Also set stars to 0 for boards that have no stars + cronJobStorage.saveJobStep(jobId, stepIndex, { + progress: 90, + currentAction: 'Initializing boards with no stars...' + }); + + const boardsWithoutStars = Boards.find( + { + $or: [ + { stars: { $exists: false } }, + { stars: null } + ] + }, + { fields: { _id: 1 } } + ).fetch(); + + boardsWithoutStars.forEach(board => { + // Only set to 0 if not already counted + if (!starCounts.has(board._id)) { + try { + Boards.update(board._id, { $set: { stars: 0 } }); + } catch (error) { + console.error(`Failed to initialize star count for board ${board._id}:`, error); + // Store error in database + cronJobStorage.saveJobError(jobId, { + stepId: 'denormalize-star-number-per-board', + stepIndex, + error, + severity: 'warning', + context: { boardId: board._id, operation: 'initialize_star_count' } + }); + } + } + }); + + // Complete + cronJobStorage.saveJobStep(jobId, stepIndex, { + progress: 100, + currentAction: `Migration complete: Updated ${updatedCount} boards with star counts` + }); + + console.log(`Star count migration completed: ${updatedCount} boards updated, ${boardsWithoutStars.length} initialized to 0`); + + } catch (error) { + console.error('Error executing denormalize star count migration:', error); + // Store error in database + cronJobStorage.saveJobError(jobId, { + stepId: 'denormalize-star-number-per-board', + stepIndex, + error, + severity: 'error', + context: { operation: 'denormalize_star_count_migration' } + }); + throw error; + } + } + + /** + * Execute the ensure valid swimlane IDs migration + */ + async executeEnsureValidSwimlaneIds(jobId, stepIndex, stepData) { + try { + const { name } = stepData; + + // Update progress: Starting + cronJobStorage.saveJobStep(jobId, stepIndex, { + progress: 0, + currentAction: 'Starting swimlane ID validation...' + }); + + // Run the migration function + const result = await runEnsureValidSwimlaneIdsMigration(); + + // Update progress: Complete + cronJobStorage.saveJobStep(jobId, stepIndex, { + progress: 100, + currentAction: `Migration complete: Fixed ${result.cardsFixed || 0} cards, ${result.listsFixed || 0} lists, rescued ${result.cardsRescued || 0} orphaned cards` + }); + + console.log(`Swimlane ID validation migration completed:`, result); + + } catch (error) { + console.error('Error executing swimlane ID validation migration:', error); + // Store error in database + cronJobStorage.saveJobError(jobId, { + stepId: 'ensure-valid-swimlane-ids', + stepIndex, + error, + severity: 'error', + context: { operation: 'ensure_valid_swimlane_ids_migration' } + }); + throw error; + } + } + /** * Execute a board operation job @@ -697,7 +896,10 @@ class CronMigrationManager { this.isRunning = true; cronIsMigrating.set(true); - cronMigrationStatus.set('Adding migrations to job queue...'); + cronMigrationStatus.set('Starting...'); + cronMigrationProgress.set(0); + cronMigrationCurrentStepNum.set(0); + cronMigrationTotalSteps.set(0); this.startTime = Date.now(); try { @@ -737,7 +939,7 @@ class CronMigrationManager { }); } - cronMigrationStatus.set('Migrations added to queue. Processing will begin shortly...'); + // Status will be updated by monitorMigrationProgress // Start monitoring progress this.monitorMigrationProgress(); @@ -750,45 +952,119 @@ class CronMigrationManager { } } + /** + * Start a specific migration by index + */ + async startSpecificMigration(migrationIndex) { + if (this.isRunning) { + return; + } + + const step = this.migrationSteps[migrationIndex]; + if (!step) { + throw new Meteor.Error('invalid-migration', 'Migration not found'); + } + + this.isRunning = true; + cronIsMigrating.set(true); + cronMigrationStatus.set('Starting...'); + cronMigrationProgress.set(0); + cronMigrationCurrentStepNum.set(1); + cronMigrationTotalSteps.set(1); + this.startTime = Date.now(); + + try { + // Remove cron job to prevent conflicts + try { + SyncedCron.remove(step.cronName); + } catch (error) { + // Ignore errors if cron job doesn't exist + } + + // Add single migration step to the 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 + }); + + // Status will be updated by monitorMigrationProgress + + // Start monitoring progress + this.monitorMigrationProgress(); + + } catch (error) { + console.error('Failed to start migration:', error); + cronMigrationStatus.set(`Failed to start migration: ${error.message}`); + cronIsMigrating.set(false); + this.isRunning = false; + } + } + /** * Monitor migration progress */ monitorMigrationProgress() { - const monitorInterval = Meteor.setInterval(() => { + // Clear any existing monitor interval + if (this.monitorInterval) { + Meteor.clearInterval(this.monitorInterval); + } + + this.monitorInterval = Meteor.setInterval(() => { const stats = cronJobStorage.getQueueStats(); const incompleteJobs = cronJobStorage.getIncompleteJobs(); - // Update progress + // Check if all migrations are completed first const totalJobs = stats.total; const completedJobs = stats.completed; - const progress = totalJobs > 0 ? Math.round((completedJobs / totalJobs) * 100) : 0; + if (stats.completed === totalJobs && totalJobs > 0 && stats.running === 0) { + // All migrations completed - immediately clear isMigrating to hide progress + cronIsMigrating.set(false); + cronMigrationStatus.set('All migrations completed successfully!'); + cronMigrationProgress.set(0); + cronMigrationCurrentStep.set(''); + cronMigrationCurrentStepNum.set(0); + cronMigrationTotalSteps.set(0); + + // Clear status message after delay + setTimeout(() => { + cronMigrationStatus.set(''); + }, 5000); + + Meteor.clearInterval(this.monitorInterval); + this.monitorInterval = null; + return; // Exit early to avoid setting progress to 100% + } + + // Update progress for active migrations + const progress = totalJobs > 0 ? Math.round((completedJobs / totalJobs) * 100) : 0; cronMigrationProgress.set(progress); + cronMigrationTotalSteps.set(totalJobs); + const currentStepNum = completedJobs + (stats.running > 0 ? 1 : 0); + cronMigrationCurrentStepNum.set(currentStepNum); // 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'}`); + cronMigrationStatus.set(`Running: ${currentStepNum}/${totalJobs} ${runningJob.stepName || 'Migration in progress'}`); + cronMigrationCurrentStep.set(''); } } 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 } @@ -1380,6 +1656,120 @@ class CronMigrationManager { } } + /** + * Pause all migrations + */ + pauseAllMigrations() { + this.isRunning = false; + cronIsMigrating.set(false); + cronMigrationStatus.set('Migrations paused'); + + // Update all pending jobs in queue to paused + const pendingJobs = cronJobStorage.getIncompleteJobs(); + pendingJobs.forEach(job => { + if (job.status === 'pending' || job.status === 'running') { + cronJobStorage.updateQueueStatus(job.jobId, 'paused'); + cronJobStorage.saveJobStatus(job.jobId, { status: 'paused' }); + } + }); + + return { success: true, message: 'All migrations paused' }; + } + + /** + * Resume all paused migrations + */ + resumeAllMigrations() { + // Find all paused jobs and resume them + const pausedJobs = CronJobStatus.find({ status: 'paused' }).fetch(); + + if (pausedJobs.length === 0) { + return { success: false, message: 'No paused migrations to resume' }; + } + + pausedJobs.forEach(job => { + cronJobStorage.updateQueueStatus(job.jobId, 'pending'); + cronJobStorage.saveJobStatus(job.jobId, { status: 'pending' }); + }); + + this.isRunning = true; + cronIsMigrating.set(true); + cronMigrationStatus.set('Resuming migrations...'); + + // Restart monitoring + this.monitorMigrationProgress(); + + return { success: true, message: `Resumed ${pausedJobs.length} migrations` }; + } + + /** + * Retry failed migrations + */ + retryFailedMigrations() { + const failedJobs = CronJobStatus.find({ status: 'failed' }).fetch(); + + if (failedJobs.length === 0) { + return { success: false, message: 'No failed migrations to retry' }; + } + + // Clear errors for failed jobs + failedJobs.forEach(job => { + cronJobStorage.clearJobErrors(job.jobId); + cronJobStorage.updateQueueStatus(job.jobId, 'pending'); + cronJobStorage.saveJobStatus(job.jobId, { + status: 'pending', + progress: 0, + error: null + }); + }); + + if (!this.isRunning) { + this.isRunning = true; + cronIsMigrating.set(true); + cronMigrationStatus.set('Retrying failed migrations...'); + this.monitorMigrationProgress(); + } + + return { success: true, message: `Retrying ${failedJobs.length} failed migrations` }; + } + + /** + * Get all migration errors + */ + getAllMigrationErrors(limit = 50) { + return cronJobStorage.getAllRecentErrors(limit); + } + + /** + * Get errors for a specific job + */ + getJobErrors(jobId, options = {}) { + return cronJobStorage.getJobErrors(jobId, options); + } + + /** + * Get migration stats including errors + */ + getMigrationStats() { + const queueStats = cronJobStorage.getQueueStats(); + const allErrors = cronJobStorage.getAllRecentErrors(100); + const errorsByJob = {}; + + allErrors.forEach(error => { + if (!errorsByJob[error.jobId]) { + errorsByJob[error.jobId] = []; + } + errorsByJob[error.jobId].push(error); + }); + + return { + ...queueStats, + totalErrors: allErrors.length, + errorsByJob, + recentErrors: allErrors.slice(0, 10) + }; + } + } // Export singleton instance @@ -1405,6 +1795,20 @@ Meteor.methods({ return cronMigrationManager.startAllMigrations(); }, + 'cron.startSpecificMigration'(migrationIndex) { + check(migrationIndex, Number); + const userId = this.userId; + if (!userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + const user = ReactiveCache.getUser(userId); + if (!user || !user.isAdmin) { + throw new Meteor.Error('not-authorized', 'Admin access required'); + } + + return cronMigrationManager.startSpecificMigration(migrationIndex); + }, + 'cron.startJob'(cronName) { const userId = this.userId; if (!userId) { @@ -1511,10 +1915,95 @@ Meteor.methods({ status: cronMigrationStatus.get(), currentStep: cronMigrationCurrentStep.get(), steps: cronMigrationSteps.get(), - isMigrating: cronIsMigrating.get() + isMigrating: cronIsMigrating.get(), + currentStepNum: cronMigrationCurrentStepNum.get(), + totalSteps: cronMigrationTotalSteps.get() }; }, + 'cron.pauseAllMigrations'() { + const userId = this.userId; + if (!userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + const user = ReactiveCache.getUser(userId); + if (!user || !user.isAdmin) { + throw new Meteor.Error('not-authorized', 'Admin access required'); + } + + return cronMigrationManager.pauseAllMigrations(); + }, + + 'cron.resumeAllMigrations'() { + const userId = this.userId; + if (!userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + const user = ReactiveCache.getUser(userId); + if (!user || !user.isAdmin) { + throw new Meteor.Error('not-authorized', 'Admin access required'); + } + + return cronMigrationManager.resumeAllMigrations(); + }, + + 'cron.retryFailedMigrations'() { + const userId = this.userId; + if (!userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + const user = ReactiveCache.getUser(userId); + if (!user || !user.isAdmin) { + throw new Meteor.Error('not-authorized', 'Admin access required'); + } + + return cronMigrationManager.retryFailedMigrations(); + }, + + 'cron.getAllMigrationErrors'(limit = 50) { + check(limit, Match.Optional(Number)); + + const userId = this.userId; + if (!userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + const user = ReactiveCache.getUser(userId); + if (!user || !user.isAdmin) { + throw new Meteor.Error('not-authorized', 'Admin access required'); + } + + return cronMigrationManager.getAllMigrationErrors(limit); + }, + + 'cron.getJobErrors'(jobId, options = {}) { + check(jobId, String); + check(options, Match.Optional(Object)); + + const userId = this.userId; + if (!userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + const user = ReactiveCache.getUser(userId); + if (!user || !user.isAdmin) { + throw new Meteor.Error('not-authorized', 'Admin access required'); + } + + return cronMigrationManager.getJobErrors(jobId, options); + }, + + 'cron.getMigrationStats'() { + const userId = this.userId; + if (!userId) { + throw new Meteor.Error('not-authorized', 'Must be logged in'); + } + const user = ReactiveCache.getUser(userId); + if (!user || !user.isAdmin) { + throw new Meteor.Error('not-authorized', 'Admin access required'); + } + + return cronMigrationManager.getMigrationStats(); + }, + 'cron.startBoardOperation'(boardId, operationType, operationData) { const userId = this.userId; if (!userId) { @@ -1747,6 +2236,12 @@ Meteor.methods({ throw new Meteor.Error('not-authorized', 'Admin access required'); } + // Clear monitor interval first to prevent status override + if (cronMigrationManager.monitorInterval) { + Meteor.clearInterval(cronMigrationManager.monitorInterval); + cronMigrationManager.monitorInterval = null; + } + // Stop all running and pending jobs const incompleteJobs = cronJobStorage.getIncompleteJobs(); incompleteJobs.forEach(job => { @@ -1757,11 +2252,19 @@ Meteor.methods({ }); }); - // Reset migration state + // Reset migration state immediately + cronMigrationManager.isRunning = false; cronIsMigrating.set(false); - cronMigrationStatus.set('All migrations stopped'); cronMigrationProgress.set(0); cronMigrationCurrentStep.set(''); + cronMigrationCurrentStepNum.set(0); + cronMigrationTotalSteps.set(0); + cronMigrationStatus.set('All migrations stopped'); + + // Clear status message after delay + setTimeout(() => { + cronMigrationStatus.set(''); + }, 3000); return { success: true, message: 'All migrations stopped' }; }, diff --git a/server/migrations/ensureValidSwimlaneIds.js b/server/migrations/ensureValidSwimlaneIds.js index 7c2a7ba5e..d37831914 100644 --- a/server/migrations/ensureValidSwimlaneIds.js +++ b/server/migrations/ensureValidSwimlaneIds.js @@ -12,6 +12,9 @@ // Helper collection to track migrations - must be defined first const Migrations = new Mongo.Collection('migrations'); +// DISABLED: This migration now runs from Admin Panel / Cron / Run All Migrations +// Instead of running automatically on startup +/* Meteor.startup(() => { // Only run on server if (!Meteor.isServer) return; @@ -26,11 +29,16 @@ Meteor.startup(() => { } console.log(`Running migration: ${MIGRATION_NAME} v${MIGRATION_VERSION}`); +*/ - /** - * Get or create a "Rescued Data" swimlane for a board - */ - function getOrCreateRescuedSwimlane(boardId) { +// Export migration functions for use by cron migration manager +export const MIGRATION_NAME = 'ensure-valid-swimlane-ids'; +export const MIGRATION_VERSION = 1; + +/** + * Get or create a "Rescued Data" swimlane for a board + */ +function getOrCreateRescuedSwimlane(boardId) { const board = Boards.findOne(boardId); if (!board) return null; @@ -243,6 +251,72 @@ Meteor.startup(() => { }); } + // Exported function to run the migration from cron + export function runEnsureValidSwimlaneIdsMigration() { + const existingMigration = Migrations.findOne({ name: MIGRATION_NAME }); + if (existingMigration && existingMigration.version >= MIGRATION_VERSION) { + console.log(`Migration ${MIGRATION_NAME} already completed`); + return { alreadyCompleted: true, ...existingMigration.results }; + } + + console.log(`Running migration: ${MIGRATION_NAME} v${MIGRATION_VERSION}`); + + try { + // Run all fix operations + const cardResults = fixCardsWithoutSwimlaneId(); + const listResults = fixListsWithoutSwimlaneId(); + const rescueResults = rescueOrphanedCards(); + + console.log('Migration results:'); + console.log(`- Fixed ${cardResults.fixedCount} cards without swimlaneId`); + console.log(`- Fixed ${listResults.fixedCount} lists without swimlaneId`); + console.log(`- Rescued ${rescueResults.rescuedCount} orphaned cards`); + + // Record migration completion + Migrations.upsert( + { name: MIGRATION_NAME }, + { + $set: { + name: MIGRATION_NAME, + version: MIGRATION_VERSION, + completedAt: new Date(), + results: { + cardsFixed: cardResults.fixedCount, + listsFixed: listResults.fixedCount, + cardsRescued: rescueResults.rescuedCount, + }, + }, + } + ); + + console.log(`Migration ${MIGRATION_NAME} completed successfully`); + + return { + success: true, + cardsFixed: cardResults.fixedCount, + listsFixed: listResults.fixedCount, + cardsRescued: rescueResults.rescuedCount, + }; + } catch (error) { + console.error(`Migration ${MIGRATION_NAME} failed:`, error); + throw error; + } + } + +// Install validation hooks on startup (always run these for data integrity) +Meteor.startup(() => { + if (!Meteor.isServer) return; + + try { + addSwimlaneIdValidationHooks(); + console.log('SwimlaneId validation hooks installed'); + } catch (error) { + console.error('Failed to install swimlaneId validation hooks:', error); + } +}); + +/* + // OLD AUTO-RUN CODE - DISABLED try { // Run all fix operations const cardResults = fixCardsWithoutSwimlaneId(); @@ -284,3 +358,4 @@ Meteor.startup(() => { console.error('Failed to install swimlaneId validation hooks:', error); } }); +*/