Sfoglia il codice sorgente

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 !
Lauri Ojansivu 1 settimana fa
parent
commit
da68b01502

+ 3 - 0
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';

+ 806 - 0
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%;
+  }
+}

+ 271 - 0
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'}}

+ 446 - 0
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);
+};

+ 6 - 0
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

+ 2 - 0
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);
     }
   },

+ 9 - 9
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;
         }

+ 49 - 1
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"
 }

+ 3 - 0
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';

+ 982 - 0
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();
+  }
+});