From da68b01502afc9d5d9ea1267bee9fc98bb08b611 Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Sat, 11 Oct 2025 19:41:09 +0300 Subject: [PATCH] Added Cron Manager to Admin Panel for long running jobs, like running migrations when opening board, copying or moving boards swimlanes lists cards etc. Thanks to xet7 ! --- client/00-startup.js | 3 + client/components/settings/cronSettings.css | 806 +++++++++++++++ client/components/settings/cronSettings.jade | 271 +++++ client/components/settings/cronSettings.js | 446 +++++++++ client/components/settings/settingBody.jade | 6 + client/components/settings/settingBody.js | 2 + client/lib/migrationManager.js | 18 +- imports/i18n/en.i18n.json | 50 +- server/00checkStartup.js | 3 + server/cronMigrationManager.js | 982 +++++++++++++++++++ 10 files changed, 2577 insertions(+), 10 deletions(-) create mode 100644 client/components/settings/cronSettings.css create mode 100644 client/components/settings/cronSettings.jade create mode 100644 client/components/settings/cronSettings.js create mode 100644 server/cronMigrationManager.js diff --git a/client/00-startup.js b/client/00-startup.js index 4f22c0868..0a98d76ef 100644 --- a/client/00-startup.js +++ b/client/00-startup.js @@ -12,3 +12,6 @@ import '/imports/components/boardConversionProgress'; // Import migration manager and progress UI import '/imports/lib/migrationManager'; import '/imports/components/migrationProgress'; + +// Import cron settings +import '/imports/components/settings/cronSettings'; diff --git a/client/components/settings/cronSettings.css b/client/components/settings/cronSettings.css new file mode 100644 index 000000000..342148b24 --- /dev/null +++ b/client/components/settings/cronSettings.css @@ -0,0 +1,806 @@ +/* Cron Settings Styles */ +.cron-settings-content { + min-height: 600px; +} + +.cron-migrations { + padding: 20px; +} + +.migration-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 2px solid #e0e0e0; +} + +.migration-header h2 { + margin: 0; + color: #333; + font-size: 24px; + font-weight: 600; +} + +.migration-header h2 i { + margin-right: 10px; + color: #667eea; +} + +.migration-controls { + display: flex; + gap: 10px; +} + +.migration-controls .btn { + padding: 8px 16px; + font-size: 14px; + border-radius: 4px; + border: none; + cursor: pointer; + transition: all 0.3s ease; +} + +.migration-controls .btn-primary { + background-color: #28a745; + color: white; +} + +.migration-controls .btn-primary:hover { + background-color: #218838; +} + +.migration-controls .btn-warning { + background-color: #ffc107; + color: #212529; +} + +.migration-controls .btn-warning:hover { + background-color: #e0a800; +} + +.migration-controls .btn-danger { + background-color: #dc3545; + color: white; +} + +.migration-controls .btn-danger:hover { + background-color: #c82333; +} + +.migration-progress { + background: #f8f9fa; + padding: 20px; + border-radius: 8px; + margin-bottom: 30px; + border-left: 4px solid #667eea; +} + +.progress-overview { + margin-bottom: 20px; +} + +.progress-bar { + width: 100%; + height: 12px; + background-color: #e0e0e0; + border-radius: 6px; + overflow: hidden; + margin-bottom: 8px; + position: relative; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea, #764ba2); + border-radius: 6px; + transition: width 0.3s ease; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.4), + transparent + ); + animation: shimmer 2s infinite; +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +.progress-text { + text-align: center; + font-weight: 700; + color: #667eea; + font-size: 18px; +} + +.progress-label { + text-align: center; + color: #666; + font-size: 14px; + margin-top: 4px; +} + +.current-step { + text-align: center; + color: #333; + font-size: 16px; + font-weight: 500; + margin-bottom: 16px; +} + +.current-step i { + margin-right: 8px; + color: #667eea; +} + +.migration-status { + text-align: center; + color: #333; + font-size: 16px; + background-color: #e3f2fd; + padding: 12px 16px; + border-radius: 6px; + border: 1px solid #bbdefb; +} + +.migration-status i { + margin-right: 8px; + color: #2196f3; +} + +.migration-steps { + margin-top: 30px; +} + +.migration-steps h3 { + margin: 0 0 20px 0; + color: #333; + font-size: 20px; + font-weight: 600; +} + +.steps-list { + max-height: 400px; + overflow-y: auto; + border: 1px solid #e0e0e0; + border-radius: 8px; +} + +.migration-step { + padding: 16px 20px; + border-bottom: 1px solid #f0f0f0; + transition: all 0.3s ease; +} + +.migration-step:last-child { + border-bottom: none; +} + +.migration-step.completed { + background-color: #d4edda; + border-left: 4px solid #28a745; +} + +.migration-step.current { + background-color: #cce7ff; + border-left: 4px solid #667eea; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.4); + } + 70% { + box-shadow: 0 0 0 10px rgba(102, 126, 234, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(102, 126, 234, 0); + } +} + +.step-header { + display: flex; + align-items: center; + margin-bottom: 8px; +} + +.step-icon { + margin-right: 12px; + font-size: 18px; + width: 24px; + text-align: center; +} + +.step-icon i.fa-check-circle { + color: #28a745; +} + +.step-icon i.fa-cog.fa-spin { + color: #667eea; +} + +.step-icon i.fa-circle-o { + color: #ccc; +} + +.step-info { + flex: 1; +} + +.step-name { + font-weight: 600; + color: #333; + font-size: 14px; + margin-bottom: 2px; +} + +.step-description { + color: #666; + font-size: 12px; + line-height: 1.3; +} + +.step-progress { + text-align: right; + min-width: 40px; +} + +.step-progress .progress-text { + font-size: 12px; + font-weight: 600; +} + +.step-progress-bar { + width: 100%; + height: 4px; + background-color: #e0e0e0; + border-radius: 2px; + overflow: hidden; + margin-top: 8px; +} + +.step-progress-bar .progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea, #764ba2); + border-radius: 2px; + transition: width 0.3s ease; +} + +/* Cron Jobs Styles */ +.cron-jobs { + padding: 20px; +} + +.jobs-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 2px solid #e0e0e0; +} + +.jobs-header h2 { + margin: 0; + color: #333; + font-size: 24px; + font-weight: 600; +} + +.jobs-header h2 i { + margin-right: 10px; + color: #667eea; +} + +.jobs-controls .btn { + padding: 8px 16px; + font-size: 14px; + border-radius: 4px; + border: none; + cursor: pointer; + transition: all 0.3s ease; +} + +.jobs-controls .btn-success { + background-color: #28a745; + color: white; +} + +.jobs-controls .btn-success:hover { + background-color: #218838; +} + +.jobs-list { + margin-top: 20px; +} + +.table { + width: 100%; + border-collapse: collapse; + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.table thead { + background-color: #f8f9fa; +} + +.table th, +.table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid #e0e0e0; +} + +.table th { + font-weight: 600; + color: #333; + font-size: 14px; +} + +.table td { + font-size: 14px; + color: #666; +} + +.status-badge { + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.status-badge.status-running { + background-color: #d4edda; + color: #155724; +} + +.status-badge.status-stopped { + background-color: #f8d7da; + color: #721c24; +} + +.status-badge.status-paused { + background-color: #fff3cd; + color: #856404; +} + +.status-badge.status-completed { + background-color: #d1ecf1; + color: #0c5460; +} + +.status-badge.status-error { + background-color: #f8d7da; + color: #721c24; +} + +.btn-group { + display: flex; + gap: 4px; +} + +.btn-group .btn { + padding: 4px 8px; + font-size: 12px; + border-radius: 3px; + border: none; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-group .btn-success { + background-color: #28a745; + color: white; +} + +.btn-group .btn-success:hover { + background-color: #218838; +} + +.btn-group .btn-warning { + background-color: #ffc107; + color: #212529; +} + +.btn-group .btn-warning:hover { + background-color: #e0a800; +} + +.btn-group .btn-danger { + background-color: #dc3545; + color: white; +} + +.btn-group .btn-danger:hover { + background-color: #c82333; +} + +/* Add Job Form Styles */ +.cron-add-job { + padding: 20px; +} + +.add-job-header { + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 2px solid #e0e0e0; +} + +.add-job-header h2 { + margin: 0; + color: #333; + font-size: 24px; + font-weight: 600; +} + +.add-job-header h2 i { + margin-right: 10px; + color: #667eea; +} + +.add-job-form { + max-width: 600px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #333; + font-size: 14px; +} + +.form-control { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + transition: border-color 0.3s ease; +} + +.form-control:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); +} + +.form-control[type="number"] { + width: 100px; +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 30px; +} + +.form-actions .btn { + padding: 10px 20px; + font-size: 14px; + border-radius: 4px; + border: none; + cursor: pointer; + transition: all 0.3s ease; +} + +.form-actions .btn-primary { + background-color: #667eea; + color: white; +} + +.form-actions .btn-primary:hover { + background-color: #5a6fd8; +} + +.form-actions .btn-default { + background-color: #6c757d; + color: white; +} + +.form-actions .btn-default:hover { + background-color: #5a6268; +} + +/* Board Operations Styles */ +.cron-board-operations { + padding: 20px; +} + +.board-operations-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 2px solid #e0e0e0; +} + +.board-operations-header h2 { + margin: 0; + color: #333; + font-size: 24px; + font-weight: 600; +} + +.board-operations-header h2 i { + margin-right: 10px; + color: #667eea; +} + +.board-operations-controls { + display: flex; + gap: 10px; +} + +.board-operations-controls .btn { + padding: 8px 16px; + font-size: 14px; + border-radius: 4px; + border: none; + cursor: pointer; + transition: all 0.3s ease; +} + +.board-operations-controls .btn-success { + background-color: #28a745; + color: white; +} + +.board-operations-controls .btn-success:hover { + background-color: #218838; +} + +.board-operations-controls .btn-primary { + background-color: #667eea; + color: white; +} + +.board-operations-controls .btn-primary:hover { + background-color: #5a6fd8; +} + +.board-operations-stats { + background: #f8f9fa; + padding: 20px; + border-radius: 8px; + margin-bottom: 30px; + border-left: 4px solid #667eea; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 20px; +} + +.stat-item { + text-align: center; +} + +.stat-value { + font-size: 32px; + font-weight: 700; + color: #667eea; + margin-bottom: 4px; +} + +.stat-label { + font-size: 14px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.board-operations-search { + margin-bottom: 30px; +} + +.search-box { + position: relative; + max-width: 400px; +} + +.search-box .form-control { + padding-right: 40px; +} + +.search-icon { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: #999; + font-size: 16px; +} + +.board-operations-list { + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.operations-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + background: #f8f9fa; + border-bottom: 1px solid #e0e0e0; +} + +.operations-header h3 { + margin: 0; + color: #333; + font-size: 18px; + font-weight: 600; +} + +.pagination-info { + color: #666; + font-size: 14px; +} + +.operations-table { + overflow-x: auto; +} + +.operations-table .table { + margin: 0; + border: none; +} + +.operations-table .table th { + background-color: #f8f9fa; + border-bottom: 2px solid #e0e0e0; + font-weight: 600; + color: #333; + white-space: nowrap; +} + +.operations-table .table td { + vertical-align: middle; + border-bottom: 1px solid #f0f0f0; +} + +.board-id { + font-family: monospace; + font-size: 12px; + color: #666; + background: #f8f9fa; + padding: 4px 8px; + border-radius: 4px; + display: inline-block; +} + +.operation-type { + font-weight: 500; + color: #333; + text-transform: capitalize; +} + +.progress-container { + display: flex; + align-items: center; + gap: 8px; + min-width: 120px; +} + +.progress-container .progress-bar { + flex: 1; + height: 8px; + background-color: #e0e0e0; + border-radius: 4px; + overflow: hidden; +} + +.progress-container .progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea, #764ba2); + border-radius: 4px; + transition: width 0.3s ease; +} + +.progress-container .progress-text { + font-size: 12px; + font-weight: 600; + color: #667eea; + min-width: 35px; + text-align: right; +} + +.pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + background: #f8f9fa; + border-top: 1px solid #e0e0e0; +} + +.pagination .btn { + padding: 6px 12px; + font-size: 12px; + border-radius: 4px; + border: 1px solid #ddd; + background: white; + color: #333; + cursor: pointer; + transition: all 0.3s ease; +} + +.pagination .btn:hover { + background: #f8f9fa; + border-color: #667eea; +} + +.pagination .btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.page-info { + color: #666; + font-size: 14px; +} + +/* Responsive design */ +@media (max-width: 768px) { + .migration-header, + .jobs-header { + flex-direction: column; + align-items: flex-start; + gap: 15px; + } + + .migration-controls, + .jobs-controls { + width: 100%; + justify-content: center; + } + + .table { + font-size: 12px; + } + + .table th, + .table td { + padding: 8px 12px; + } + + .btn-group { + flex-direction: column; + } + + .add-job-form { + max-width: 100%; + } +} diff --git a/client/components/settings/cronSettings.jade b/client/components/settings/cronSettings.jade new file mode 100644 index 000000000..ef922cd8a --- /dev/null +++ b/client/components/settings/cronSettings.jade @@ -0,0 +1,271 @@ +template(name="cronSettings") + .setting-content.cron-settings-content + unless currentUser.isAdmin + | {{_ 'error-notAuthorized'}} + else + .content-body + .side-menu + ul + li + a.js-cron-migrations(data-id="cron-migrations") + i.fa.fa-database + | {{_ 'cron-migrations'}} + li + a.js-cron-board-operations(data-id="cron-board-operations") + i.fa.fa-tasks + | {{_ 'board-operations'}} + li + a.js-cron-jobs(data-id="cron-jobs") + i.fa.fa-clock-o + | {{_ 'cron-jobs'}} + li + a.js-cron-add(data-id="cron-add") + i.fa.fa-plus + | {{_ 'add-cron-job'}} + + .main-body + if loading.get + +spinner + else if showMigrations.get + +cronMigrations + else if showBoardOperations.get + +cronBoardOperations + else if showJobs.get + +cronJobs + else if showAddJob.get + +cronAddJob + +template(name="cronMigrations") + .cron-migrations + .migration-header + h2 + i.fa.fa-database + | {{_ 'database-migrations'}} + .migration-controls + button.btn.btn-primary.js-start-all-migrations + i.fa.fa-play + | {{_ 'start-all-migrations'}} + button.btn.btn-warning.js-pause-all-migrations + i.fa.fa-pause + | {{_ 'pause-all-migrations'}} + button.btn.btn-danger.js-stop-all-migrations + i.fa.fa-stop + | {{_ 'stop-all-migrations'}} + + .migration-progress + .progress-overview + .progress-bar + .progress-fill(style="width: {{migrationProgress}}%") + .progress-text {{migrationProgress}}% + .progress-label {{_ 'overall-progress'}} + + .current-step + i.fa.fa-cog.fa-spin + | {{migrationCurrentStep}} + + .migration-status + i.fa.fa-info-circle + | {{migrationStatus}} + + .migration-steps + h3 {{_ 'migration-steps'}} + .steps-list + each migrationSteps + .migration-step(class="{{#if completed}}completed{{/if}}" class="{{#if isCurrentStep}}current{{/if}}") + .step-header + .step-icon + if completed + i.fa.fa-check-circle + else if isCurrentStep + i.fa.fa-cog.fa-spin + else + i.fa.fa-circle-o + .step-info + .step-name {{name}} + .step-description {{description}} + .step-progress + if completed + .progress-text 100% + else if isCurrentStep + .progress-text {{progress}}% + else + .progress-text 0% + if isCurrentStep + .step-progress-bar + .progress-fill(style="width: {{progress}}%") + +template(name="cronBoardOperations") + .cron-board-operations + .board-operations-header + h2 + i.fa.fa-tasks + | {{_ 'board-operations'}} + .board-operations-controls + button.btn.btn-success.js-refresh-board-operations + i.fa.fa-refresh + | {{_ 'refresh'}} + button.btn.btn-primary.js-start-test-operation + i.fa.fa-play + | {{_ 'start-test-operation'}} + + .board-operations-stats + .stats-grid + .stat-item + .stat-value {{operationStats.total}} + .stat-label {{_ 'total-operations'}} + .stat-item + .stat-value {{operationStats.running}} + .stat-label {{_ 'running'}} + .stat-item + .stat-value {{operationStats.completed}} + .stat-label {{_ 'completed'}} + .stat-item + .stat-value {{operationStats.error}} + .stat-label {{_ 'errors'}} + + .board-operations-search + .search-box + input.form-control.js-search-board-operations(type="text" placeholder="{{_ 'search-boards-or-operations'}}") + i.fa.fa-search.search-icon + + .board-operations-list + .operations-header + h3 {{_ 'board-operations'}} ({{pagination.total}}) + .pagination-info + | {{_ 'showing'}} {{pagination.start}} - {{pagination.end}} {{_ 'of'}} {{pagination.total}} + + .operations-table + table.table.table-striped + thead + tr + th {{_ 'board-id'}} + th {{_ 'operation-type'}} + th {{_ 'status'}} + th {{_ 'progress'}} + th {{_ 'start-time'}} + th {{_ 'duration'}} + th {{_ 'actions'}} + tbody + each boardOperations + tr + td + .board-id {{boardId}} + td + .operation-type {{operationType}} + td + span.status-badge(class="status-{{status}}") {{status}} + td + .progress-container + .progress-bar + .progress-fill(style="width: {{progress}}%") + .progress-text {{progress}}% + td {{formatDateTime startTime}} + td {{formatDuration startTime endTime}} + td + .btn-group + if isRunning + button.btn.btn-sm.btn-warning.js-pause-operation(data-operation="{{id}}") + i.fa.fa-pause + else + button.btn.btn-sm.btn-success.js-resume-operation(data-operation="{{id}}") + i.fa.fa-play + button.btn.btn-sm.btn-danger.js-stop-operation(data-operation="{{id}}") + i.fa.fa-stop + button.btn.btn-sm.btn-info.js-view-details(data-operation="{{id}}") + i.fa.fa-info-circle + + .pagination + if pagination.hasPrev + button.btn.btn-sm.btn-default.js-prev-page + i.fa.fa-chevron-left + | {{_ 'previous'}} + .page-info + | {{_ 'page'}} {{pagination.page}} {{_ 'of'}} {{pagination.totalPages}} + if pagination.hasNext + button.btn.btn-sm.btn-default.js-next-page + | {{_ 'next'}} + i.fa.fa-chevron-right + +template(name="cronJobs") + .cron-jobs + .jobs-header + h2 + i.fa.fa-clock-o + | {{_ 'cron-jobs'}} + .jobs-controls + button.btn.btn-success.js-refresh-jobs + i.fa.fa-refresh + | {{_ 'refresh'}} + + .jobs-list + table.table.table-striped + thead + tr + th {{_ 'job-name'}} + th {{_ 'schedule'}} + th {{_ 'status'}} + th {{_ 'last-run'}} + th {{_ 'next-run'}} + th {{_ 'actions'}} + tbody + each cronJobs + tr + td {{name}} + td {{schedule}} + td + span.status-badge(class="status-{{status}}") {{status}} + td {{formatDate lastRun}} + td {{formatDate nextRun}} + td + .btn-group + if isRunning + button.btn.btn-sm.btn-warning.js-pause-job(data-job="{{name}}") + i.fa.fa-pause + else + button.btn.btn-sm.btn-success.js-start-job(data-job="{{name}}") + i.fa.fa-play + button.btn.btn-sm.btn-danger.js-stop-job(data-job="{{name}}") + i.fa.fa-stop + button.btn.btn-sm.btn-danger.js-remove-job(data-job="{{name}}") + i.fa.fa-trash + +template(name="cronAddJob") + .cron-add-job + .add-job-header + h2 + i.fa.fa-plus + | {{_ 'add-cron-job'}} + + .add-job-form + form.js-add-cron-job-form + .form-group + label(for="job-name") {{_ 'job-name'}} + input.form-control#job-name(type="text" name="name" required) + + .form-group + label(for="job-description") {{_ 'job-description'}} + textarea.form-control#job-description(name="description" rows="3") + + .form-group + label(for="job-schedule") {{_ 'schedule'}} + select.form-control#job-schedule(name="schedule") + option(value="every 1 minute") {{_ 'every-1-minute'}} + option(value="every 5 minutes") {{_ 'every-5-minutes'}} + option(value="every 10 minutes") {{_ 'every-10-minutes'}} + option(value="every 30 minutes") {{_ 'every-30-minutes'}} + option(value="every 1 hour") {{_ 'every-1-hour'}} + option(value="every 6 hours") {{_ 'every-6-hours'}} + option(value="every 1 day") {{_ 'every-1-day'}} + option(value="once") {{_ 'run-once'}} + + .form-group + label(for="job-weight") {{_ 'weight'}} + input.form-control#job-weight(type="number" name="weight" value="1" min="1" max="10") + + .form-actions + button.btn.btn-primary(type="submit") + i.fa.fa-plus + | {{_ 'add-job'}} + button.btn.btn-default.js-cancel-add-job + i.fa.fa-times + | {{_ 'cancel'}} diff --git a/client/components/settings/cronSettings.js b/client/components/settings/cronSettings.js new file mode 100644 index 000000000..944458d62 --- /dev/null +++ b/client/components/settings/cronSettings.js @@ -0,0 +1,446 @@ +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from '/imports/i18n'; + +// Reactive variables for cron settings +const migrationProgress = new ReactiveVar(0); +const migrationStatus = new ReactiveVar(''); +const migrationCurrentStep = new ReactiveVar(''); +const migrationSteps = new ReactiveVar([]); +const isMigrating = new ReactiveVar(false); +const cronJobs = new ReactiveVar([]); + +Template.cronSettings.onCreated(function() { + this.loading = new ReactiveVar(true); + this.showMigrations = new ReactiveVar(true); + this.showBoardOperations = new ReactiveVar(false); + this.showJobs = new ReactiveVar(false); + this.showAddJob = new ReactiveVar(false); + + // Board operations pagination + this.currentPage = new ReactiveVar(1); + this.pageSize = new ReactiveVar(20); + this.searchTerm = new ReactiveVar(''); + this.boardOperations = new ReactiveVar([]); + this.operationStats = new ReactiveVar({}); + this.pagination = new ReactiveVar({}); + + // Load initial data + this.loadCronData(); +}); + +Template.cronSettings.helpers({ + loading() { + return Template.instance().loading.get(); + }, + + showMigrations() { + return Template.instance().showMigrations.get(); + }, + + showBoardOperations() { + return Template.instance().showBoardOperations.get(); + }, + + showJobs() { + return Template.instance().showJobs.get(); + }, + + showAddJob() { + return Template.instance().showAddJob.get(); + }, + + migrationProgress() { + return migrationProgress.get(); + }, + + migrationStatus() { + return migrationStatus.get(); + }, + + migrationCurrentStep() { + return migrationCurrentStep.get(); + }, + + migrationSteps() { + const steps = migrationSteps.get(); + const currentStep = migrationCurrentStep.get(); + + return steps.map(step => ({ + ...step, + isCurrentStep: step.name === currentStep + })); + }, + + cronJobs() { + return cronJobs.get(); + }, + + formatDate(date) { + if (!date) return '-'; + return new Date(date).toLocaleString(); + }, + + boardOperations() { + return Template.instance().boardOperations.get(); + }, + + operationStats() { + return Template.instance().operationStats.get(); + }, + + pagination() { + return Template.instance().pagination.get(); + }, + + formatDateTime(date) { + if (!date) return '-'; + return new Date(date).toLocaleString(); + }, + + formatDuration(startTime, endTime) { + if (!startTime) return '-'; + const start = new Date(startTime); + const end = endTime ? new Date(endTime) : new Date(); + const diffMs = end - start; + const diffMins = Math.floor(diffMs / 60000); + const diffSecs = Math.floor((diffMs % 60000) / 1000); + + if (diffMins > 0) { + return `${diffMins}m ${diffSecs}s`; + } else { + return `${diffSecs}s`; + } + } +}); + +Template.cronSettings.events({ + 'click .js-cron-migrations'(event) { + event.preventDefault(); + const instance = Template.instance(); + instance.showMigrations.set(true); + instance.showJobs.set(false); + instance.showAddJob.set(false); + }, + + 'click .js-cron-board-operations'(event) { + event.preventDefault(); + const instance = Template.instance(); + instance.showMigrations.set(false); + instance.showBoardOperations.set(true); + instance.showJobs.set(false); + instance.showAddJob.set(false); + instance.loadBoardOperations(); + }, + + 'click .js-cron-jobs'(event) { + event.preventDefault(); + const instance = Template.instance(); + instance.showMigrations.set(false); + instance.showBoardOperations.set(false); + instance.showJobs.set(true); + instance.showAddJob.set(false); + instance.loadCronJobs(); + }, + + 'click .js-cron-add'(event) { + event.preventDefault(); + const instance = Template.instance(); + instance.showMigrations.set(false); + instance.showJobs.set(false); + instance.showAddJob.set(true); + }, + + 'click .js-start-all-migrations'(event) { + event.preventDefault(); + Meteor.call('cron.startAllMigrations', (error, result) => { + if (error) { + console.error('Failed to start migrations:', error); + alert('Failed to start migrations: ' + error.message); + } else { + console.log('Migrations started successfully'); + Template.instance().pollMigrationProgress(); + } + }); + }, + + 'click .js-pause-all-migrations'(event) { + event.preventDefault(); + // Pause all migration cron jobs + const jobs = cronJobs.get(); + jobs.forEach(job => { + if (job.name.startsWith('migration_')) { + Meteor.call('cron.pauseJob', job.name); + } + }); + }, + + 'click .js-stop-all-migrations'(event) { + event.preventDefault(); + // Stop all migration cron jobs + const jobs = cronJobs.get(); + jobs.forEach(job => { + if (job.name.startsWith('migration_')) { + Meteor.call('cron.stopJob', job.name); + } + }); + }, + + 'click .js-refresh-jobs'(event) { + event.preventDefault(); + Template.instance().loadCronJobs(); + }, + + 'click .js-start-job'(event) { + event.preventDefault(); + const jobName = $(event.currentTarget).data('job'); + Meteor.call('cron.startJob', jobName, (error, result) => { + if (error) { + console.error('Failed to start job:', error); + alert('Failed to start job: ' + error.message); + } else { + console.log('Job started successfully'); + Template.instance().loadCronJobs(); + } + }); + }, + + 'click .js-pause-job'(event) { + event.preventDefault(); + const jobName = $(event.currentTarget).data('job'); + Meteor.call('cron.pauseJob', jobName, (error, result) => { + if (error) { + console.error('Failed to pause job:', error); + alert('Failed to pause job: ' + error.message); + } else { + console.log('Job paused successfully'); + Template.instance().loadCronJobs(); + } + }); + }, + + 'click .js-stop-job'(event) { + event.preventDefault(); + const jobName = $(event.currentTarget).data('job'); + Meteor.call('cron.stopJob', jobName, (error, result) => { + if (error) { + console.error('Failed to stop job:', error); + alert('Failed to stop job: ' + error.message); + } else { + console.log('Job stopped successfully'); + Template.instance().loadCronJobs(); + } + }); + }, + + 'click .js-remove-job'(event) { + event.preventDefault(); + const jobName = $(event.currentTarget).data('job'); + if (confirm('Are you sure you want to remove this job?')) { + Meteor.call('cron.removeJob', jobName, (error, result) => { + if (error) { + console.error('Failed to remove job:', error); + alert('Failed to remove job: ' + error.message); + } else { + console.log('Job removed successfully'); + Template.instance().loadCronJobs(); + } + }); + } + }, + + 'submit .js-add-cron-job-form'(event) { + event.preventDefault(); + const form = event.currentTarget; + const formData = new FormData(form); + + const jobData = { + name: formData.get('name'), + description: formData.get('description'), + schedule: formData.get('schedule'), + weight: parseInt(formData.get('weight')) + }; + + Meteor.call('cron.addJob', jobData, (error, result) => { + if (error) { + console.error('Failed to add job:', error); + alert('Failed to add job: ' + error.message); + } else { + console.log('Job added successfully'); + form.reset(); + Template.instance().showJobs.set(true); + Template.instance().showAddJob.set(false); + Template.instance().loadCronJobs(); + } + }); + }, + + 'click .js-cancel-add-job'(event) { + event.preventDefault(); + const instance = Template.instance(); + instance.showJobs.set(true); + instance.showAddJob.set(false); + }, + + 'click .js-refresh-board-operations'(event) { + event.preventDefault(); + Template.instance().loadBoardOperations(); + }, + + 'click .js-start-test-operation'(event) { + event.preventDefault(); + const testBoardId = 'test-board-' + Date.now(); + const operationData = { + sourceBoardId: 'source-board', + targetBoardId: 'target-board', + copyOptions: { includeCards: true, includeAttachments: true } + }; + + Meteor.call('cron.startBoardOperation', testBoardId, 'copy_board', operationData, (error, result) => { + if (error) { + console.error('Failed to start test operation:', error); + alert('Failed to start test operation: ' + error.message); + } else { + console.log('Test operation started:', result); + Template.instance().loadBoardOperations(); + } + }); + }, + + 'input .js-search-board-operations'(event) { + const searchTerm = $(event.currentTarget).val(); + const instance = Template.instance(); + instance.searchTerm.set(searchTerm); + instance.currentPage.set(1); + instance.loadBoardOperations(); + }, + + 'click .js-prev-page'(event) { + event.preventDefault(); + const instance = Template.instance(); + const currentPage = instance.currentPage.get(); + if (currentPage > 1) { + instance.currentPage.set(currentPage - 1); + instance.loadBoardOperations(); + } + }, + + 'click .js-next-page'(event) { + event.preventDefault(); + const instance = Template.instance(); + const currentPage = instance.currentPage.get(); + const pagination = instance.pagination.get(); + if (currentPage < pagination.totalPages) { + instance.currentPage.set(currentPage + 1); + instance.loadBoardOperations(); + } + }, + + 'click .js-pause-operation'(event) { + event.preventDefault(); + const operationId = $(event.currentTarget).data('operation'); + // Implementation for pausing operation + console.log('Pause operation:', operationId); + }, + + 'click .js-resume-operation'(event) { + event.preventDefault(); + const operationId = $(event.currentTarget).data('operation'); + // Implementation for resuming operation + console.log('Resume operation:', operationId); + }, + + 'click .js-stop-operation'(event) { + event.preventDefault(); + const operationId = $(event.currentTarget).data('operation'); + if (confirm('Are you sure you want to stop this operation?')) { + // Implementation for stopping operation + console.log('Stop operation:', operationId); + } + }, + + 'click .js-view-details'(event) { + event.preventDefault(); + const operationId = $(event.currentTarget).data('operation'); + // Implementation for viewing operation details + console.log('View details for operation:', operationId); + } +}); + +Template.cronSettings.prototype.loadCronData = function() { + this.loading.set(true); + + // Load migration progress + Meteor.call('cron.getMigrationProgress', (error, result) => { + if (result) { + migrationProgress.set(result.progress); + migrationStatus.set(result.status); + migrationCurrentStep.set(result.currentStep); + migrationSteps.set(result.steps); + isMigrating.set(result.isMigrating); + } + }); + + // Load cron jobs + this.loadCronJobs(); + + this.loading.set(false); +}; + +Template.cronSettings.prototype.loadCronJobs = function() { + Meteor.call('cron.getJobs', (error, result) => { + if (result) { + cronJobs.set(result); + } + }); +}; + +Template.cronSettings.prototype.loadBoardOperations = function() { + const instance = this; + const page = instance.currentPage.get(); + const limit = instance.pageSize.get(); + const searchTerm = instance.searchTerm.get(); + + Meteor.call('cron.getAllBoardOperations', page, limit, searchTerm, (error, result) => { + if (result) { + instance.boardOperations.set(result.operations); + instance.pagination.set({ + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + start: ((result.page - 1) * result.limit) + 1, + end: Math.min(result.page * result.limit, result.total), + hasPrev: result.page > 1, + hasNext: result.page < result.totalPages + }); + } + }); + + // Load operation stats + Meteor.call('cron.getBoardOperationStats', (error, result) => { + if (result) { + instance.operationStats.set(result); + } + }); +}; + +Template.cronSettings.prototype.pollMigrationProgress = function() { + const pollInterval = setInterval(() => { + Meteor.call('cron.getMigrationProgress', (error, result) => { + if (result) { + migrationProgress.set(result.progress); + migrationStatus.set(result.status); + migrationCurrentStep.set(result.currentStep); + migrationSteps.set(result.steps); + isMigrating.set(result.isMigrating); + + // Stop polling if migration is complete + if (!result.isMigrating && result.progress === 100) { + clearInterval(pollInterval); + } + } + }); + }, 1000); +}; diff --git a/client/components/settings/settingBody.jade b/client/components/settings/settingBody.jade index deefd98fc..a612b3ccc 100644 --- a/client/components/settings/settingBody.jade +++ b/client/components/settings/settingBody.jade @@ -46,6 +46,10 @@ template(name="setting") a.js-setting-menu(data-id="attachment-settings") i.fa.fa-paperclip | {{_ 'attachment-settings'}} + li + a.js-setting-menu(data-id="cron-settings") + i.fa.fa-clock-o + | {{_ 'cron-settings'}} .main-body if loading.get +spinner @@ -68,6 +72,8 @@ template(name="setting") +webhookSettings else if attachmentSettings.get +attachmentSettings + else if cronSettings.get + +cronSettings template(name="webhookSettings") span diff --git a/client/components/settings/settingBody.js b/client/components/settings/settingBody.js index 566c4f99a..088b66bfa 100644 --- a/client/components/settings/settingBody.js +++ b/client/components/settings/settingBody.js @@ -17,6 +17,7 @@ BlazeComponent.extendComponent({ this.layoutSetting = new ReactiveVar(false); this.webhookSetting = new ReactiveVar(false); this.attachmentSettings = new ReactiveVar(false); + this.cronSettings = new ReactiveVar(false); Meteor.subscribe('setting'); Meteor.subscribe('mailServer'); @@ -115,6 +116,7 @@ BlazeComponent.extendComponent({ this.layoutSetting.set('layout-setting' === targetID); this.webhookSetting.set('webhook-setting' === targetID); this.attachmentSettings.set('attachment-settings' === targetID); + this.cronSettings.set('cron-settings' === targetID); this.tableVisibilityModeSetting.set('tableVisibilityMode-setting' === targetID); } }, diff --git a/client/lib/migrationManager.js b/client/lib/migrationManager.js index 0f9e2b6b5..11a4b9f5b 100644 --- a/client/lib/migrationManager.js +++ b/client/lib/migrationManager.js @@ -624,7 +624,7 @@ class MigrationManager { } /** - * Start migration process + * Start migration process using cron system */ async startMigration() { if (isMigrating.get()) { @@ -636,17 +636,17 @@ class MigrationManager { this.startTime = Date.now(); try { - // Start server-side migration - Meteor.call('migration.start', (error, result) => { + // Start server-side cron migrations + Meteor.call('cron.startAllMigrations', (error, result) => { if (error) { - console.error('Failed to start migration:', error); + console.error('Failed to start cron migrations:', error); migrationStatus.set(`Migration failed: ${error.message}`); isMigrating.set(false); } }); // Poll for progress updates - this.pollMigrationProgress(); + this.pollCronMigrationProgress(); } catch (error) { console.error('Migration failed:', error); @@ -656,13 +656,13 @@ class MigrationManager { } /** - * Poll for migration progress updates + * Poll for cron migration progress updates */ - pollMigrationProgress() { + pollCronMigrationProgress() { const pollInterval = setInterval(() => { - Meteor.call('migration.getProgress', (error, result) => { + Meteor.call('cron.getMigrationProgress', (error, result) => { if (error) { - console.error('Failed to get migration progress:', error); + console.error('Failed to get cron migration progress:', error); clearInterval(pollInterval); return; } diff --git a/imports/i18n/en.i18n.json b/imports/i18n/en.i18n.json index ceb81e4cb..594412e46 100644 --- a/imports/i18n/en.i18n.json +++ b/imports/i18n/en.i18n.json @@ -96,5 +96,53 @@ "overall-progress": "Overall Progress", "migration-steps": "Migration Steps", "migration-info-text": "Database migrations are performed once and improve system performance. The process continues in the background even if you close your browser.", - "migration-warning-text": "Please do not close your browser during migration. The process will continue in the background but may take longer to complete." + "migration-warning-text": "Please do not close your browser during migration. The process will continue in the background but may take longer to complete.", + "cron-settings": "Cron Settings", + "cron-migrations": "Database Migrations", + "cron-jobs": "Cron Jobs", + "add-cron-job": "Add Cron Job", + "database-migrations": "Database Migrations", + "start-all-migrations": "Start All Migrations", + "pause-all-migrations": "Pause All Migrations", + "stop-all-migrations": "Stop All Migrations", + "migration-steps": "Migration Steps", + "cron-jobs": "Cron Jobs", + "refresh": "Refresh", + "job-name": "Job Name", + "schedule": "Schedule", + "status": "Status", + "last-run": "Last Run", + "next-run": "Next Run", + "actions": "Actions", + "add-cron-job": "Add Cron Job", + "job-description": "Job Description", + "weight": "Weight", + "every-1-minute": "Every 1 minute", + "every-5-minutes": "Every 5 minutes", + "every-10-minutes": "Every 10 minutes", + "every-30-minutes": "Every 30 minutes", + "every-1-hour": "Every 1 hour", + "every-6-hours": "Every 6 hours", + "every-1-day": "Every 1 day", + "run-once": "Run once", + "add-job": "Add Job", + "cancel": "Cancel", + "board-operations": "Board Operations", + "total-operations": "Total Operations", + "running": "Running", + "completed": "Completed", + "errors": "Errors", + "search-boards-or-operations": "Search boards or operations...", + "board-id": "Board ID", + "operation-type": "Operation Type", + "progress": "Progress", + "start-time": "Start Time", + "duration": "Duration", + "actions": "Actions", + "showing": "Showing", + "of": "of", + "page": "Page", + "previous": "Previous", + "next": "Next", + "start-test-operation": "Start Test Operation" } diff --git a/server/00checkStartup.js b/server/00checkStartup.js index 30ed97de5..5670c541e 100644 --- a/server/00checkStartup.js +++ b/server/00checkStartup.js @@ -27,3 +27,6 @@ if (errors.length > 0) { // Import migration runner for on-demand migrations import './migrationRunner'; + +// Import cron migration manager for cron-based migrations +import './cronMigrationManager'; diff --git a/server/cronMigrationManager.js b/server/cronMigrationManager.js new file mode 100644 index 000000000..904635ffc --- /dev/null +++ b/server/cronMigrationManager.js @@ -0,0 +1,982 @@ +/** + * 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'; + +// 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; + } + + /** + * 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); + }); + + // Update cron jobs list + this.updateCronJobsList(); + } + + /** + * 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 { + console.log(`Starting migration: ${step.name}`); + + 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'; + + console.log(`Completed migration: ${step.name}`); + + // 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 in sequence + */ + async startAllMigrations() { + if (this.isRunning) { + return; + } + + this.isRunning = true; + cronIsMigrating.set(true); + cronMigrationStatus.set('Starting all migrations...'); + this.startTime = Date.now(); + + try { + for (let i = 0; i < this.migrationSteps.length; i++) { + const step = this.migrationSteps[i]; + this.currentStepIndex = i; + + if (step.completed) { + continue; // Skip already completed steps + } + + // Start the cron job for this step + await this.startCronJob(step.cronName); + + // Wait for completion + await this.waitForCronJobCompletion(step); + } + + // 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); + + } catch (error) { + console.error('Migration process failed:', error); + cronMigrationStatus.set(`Migration process failed: ${error.message}`); + cronIsMigrating.set(false); + } finally { + this.isRunning = false; + } + } + + /** + * 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() { + 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()}`; + const operation = { + id: operationId, + boardId: boardId, + type: operationType, + data: operationData, + status: 'running', + progress: 0, + startTime: new Date(), + endTime: null, + error: null + }; + + // Update board operations map + const operations = boardOperations.get(); + operations.set(operationId, operation); + boardOperations.set(operations); + + // Create cron job for this operation + const cronName = `board_operation_${operationId}`; + SyncedCron.add({ + name: cronName, + schedule: (parser) => parser.text('once'), + job: () => { + this.executeBoardOperation(operationId, operationType, operationData); + }, + }); + + // Start the cron job + SyncedCron.start(); + + 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(); + } +});