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 !
This commit is contained in:
Lauri Ojansivu 2025-10-11 19:41:09 +03:00
parent e90bc744d9
commit da68b01502
10 changed files with 2577 additions and 10 deletions

View file

@ -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';

View file

@ -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%;
}
}

View file

@ -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'}}

View file

@ -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);
};

View file

@ -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

View file

@ -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);
}
},

View file

@ -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;
}

View file

@ -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"
}

View file

@ -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';

View file

@ -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();
}
});