/**
* qBit Manage Web UI - Scheduler Control Component
* Handles dynamic scheduler management with real-time updates and persistence
*/
import { API } from '../api.js';
import { showToast } from '../utils/toast.js';
import { get, query, queryAll } from '../utils/dom.js';
import { CLOSE_ICON_SVG } from '../utils/icons.js';
class SchedulerControl {
constructor(options = {}) {
this.container = options.container;
this.onScheduleChange = options.onScheduleChange || (() => {});
this.api = new API();
this.currentStatus = {
current_schedule: null,
next_run: null,
next_run_str: null
};
// Validation patterns
// Simplified cron pattern that allows common formats including comma-separated values
this.cronPattern = /^(\*|\*\/\d+|\d+(-\d+)?(,\d+(-\d+)?)*) (\*|\*\/\d+|\d+(-\d+)?(,\d+(-\d+)?)*) (\*|\*\/\d+|\d+(-\d+)?(,\d+(-\d+)?)*) (\*|\*\/\d+|\d+(-\d+)?(,\d+(-\d+)?)*) (\*|\*\/\d+|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
this.intervalPattern = /^\d+$/;
this.init();
}
init() {
if (!this.container) {
console.error('SchedulerControl: Container element is required');
return;
}
this.render();
this.bindEvents();
this.loadCurrentStatus();
}
render() {
this.container.innerHTML = `
Current Schedule
-
-
-
-
Quick Presets
`;
}
bindEvents() {
const form = this.container.querySelector('#schedule-form');
const scheduleInput = this.container.querySelector('#schedule-input');
const validateBtn = this.container.querySelector('#validate-schedule-btn');
const resetBtn = this.container.querySelector('#reset-form-btn');
const deleteBtn = this.container.querySelector('#delete-schedule-btn');
const typeRadios = this.container.querySelectorAll('input[name="schedule-type"]');
const presetButtons = this.container.querySelectorAll('.preset-btn');
// Form submission
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
this.handleScheduleUpdate();
});
}
// Real-time validation on input
if (scheduleInput) {
scheduleInput.addEventListener('input', () => {
this.validateScheduleInput();
});
scheduleInput.addEventListener('blur', () => {
this.validateScheduleInput(true);
});
}
// Manual validation button
if (validateBtn) {
validateBtn.addEventListener('click', () => {
this.validateScheduleInput(true);
});
}
// Reset form
if (resetBtn) {
resetBtn.addEventListener('click', () => {
this.resetForm();
});
}
// Toggle persistent schedule (disable/enable without deleting file)
if (deleteBtn) {
deleteBtn.addEventListener('click', () => {
this.handlePersistenceToggle();
});
}
// Schedule type change
typeRadios.forEach(radio => {
radio.addEventListener('change', () => {
this.handleScheduleTypeChange();
});
});
// Preset buttons
presetButtons.forEach(btn => {
btn.addEventListener('click', () => {
this.applyPreset(btn.dataset.type, btn.dataset.value);
});
});
}
async loadCurrentStatus() {
try {
// Load complete scheduler status (now includes all persistence info)
const schedulerStatus = await this.api.get('/scheduler').catch(() => ({
current_schedule: null,
next_run: null,
next_run_str: null,
is_running: false,
source: null,
persistent: false,
file_exists: false
}));
this.updateStatus(schedulerStatus);
} catch (error) {
console.error('Failed to load scheduler status:', error);
this.showError('Failed to load current scheduler status');
}
}
updateStatus(status) {
this.currentStatus = status;
// Update current schedule info
const scheduleType = this.container.querySelector('#current-schedule-type');
const scheduleValue = this.container.querySelector('#current-schedule-value');
const scheduleSource = this.container.querySelector('#schedule-source');
const nextRunTime = this.container.querySelector('#next-run-time');
const deleteBtn = this.container.querySelector('#delete-schedule-btn');
if (scheduleType && scheduleValue && nextRunTime) {
if (status.current_schedule) {
scheduleType.textContent = status.current_schedule.type || '-';
scheduleValue.textContent = status.current_schedule.value || '-';
} else {
scheduleType.textContent = '-';
scheduleValue.textContent = '-';
}
if (status.next_run) {
const nextRun = new Date(status.next_run);
nextRunTime.textContent = nextRun.toLocaleString();
nextRunTime.title = nextRun.toISOString();
} else {
nextRunTime.textContent = '-';
nextRunTime.title = '';
}
}
// Update source info
if (scheduleSource) {
scheduleSource.textContent = status.source || '-';
}
// Show & update toggle button state
if (deleteBtn) {
const disabled = !!status.disabled;
const fileExists = !!status.file_exists;
// Show button if a file exists OR currently disabled (so user can re-enable)
deleteBtn.style.display = (fileExists || disabled) ? 'inline-block' : 'none';
if (disabled) {
deleteBtn.textContent = 'Enable Persistent Schedule';
deleteBtn.classList.remove('btn-danger');
deleteBtn.classList.add('btn-success');
} else {
deleteBtn.textContent = 'Disable Persistent Schedule';
deleteBtn.classList.add('btn-danger');
deleteBtn.classList.remove('btn-success');
}
}
// Pre-populate the form with current schedule values
this.populateFormWithCurrentSchedule(status);
// Notify parent component
this.onScheduleChange(status);
}
handleScheduleTypeChange() {
const cronRadio = this.container.querySelector('#schedule-type-cron');
const intervalRadio = this.container.querySelector('#schedule-type-interval');
const scheduleInput = this.container.querySelector('#schedule-input');
const helpCron = this.container.querySelector('.help-cron');
const helpInterval = this.container.querySelector('.help-interval');
const isCron = cronRadio?.checked;
// Update placeholder and help text
if (scheduleInput) {
scheduleInput.placeholder = isCron ? '*/15 * * * *' : '15';
// Only clear value if this is a manual type change, not during form population
if (!this._isPopulatingForm) {
scheduleInput.value = '';
}
}
// Toggle help text
if (helpCron && helpInterval) {
helpCron.style.display = isCron ? 'block' : 'none';
helpInterval.style.display = isCron ? 'none' : 'block';
}
// Clear validation state only if not populating form
if (!this._isPopulatingForm) {
this.clearValidation();
}
}
validateScheduleInput(showFeedback = false) {
const scheduleInput = this.container.querySelector('#schedule-input');
const cronRadio = this.container.querySelector('#schedule-type-cron');
const updateBtn = this.container.querySelector('#update-schedule-btn');
if (!scheduleInput || !cronRadio || !updateBtn) return false;
const value = scheduleInput.value.trim();
const isCron = cronRadio.checked;
// Clear previous validation state
this.clearValidation();
if (!value) {
if (showFeedback) {
this.showValidationError('Schedule value is required');
}
updateBtn.disabled = true;
return false;
}
let isValid = false;
let errorMessage = '';
let successMessage = '';
if (isCron) {
isValid = this.cronPattern.test(value);
if (!isValid) {
errorMessage = 'Invalid cron expression. Use format: minute hour day month weekday';
} else {
successMessage = 'Valid cron expression';
}
} else {
isValid = this.intervalPattern.test(value) && parseInt(value) > 0;
if (!isValid) {
errorMessage = 'Invalid interval. Must be a positive number (minutes)';
} else {
const minutes = parseInt(value);
successMessage = `Valid interval: ${minutes} minute${minutes !== 1 ? 's' : ''}`;
}
}
if (showFeedback) {
if (isValid) {
this.showValidationSuccess(successMessage);
} else {
this.showValidationError(errorMessage);
}
}
updateBtn.disabled = !isValid;
return isValid;
}
showValidationError(message) {
const errorDiv = this.container.querySelector('#schedule-error');
const successDiv = this.container.querySelector('#schedule-success');
const scheduleInput = this.container.querySelector('#schedule-input');
if (errorDiv) {
errorDiv.querySelector('.error-message').textContent = message;
errorDiv.style.display = 'flex';
}
if (successDiv) {
successDiv.style.display = 'none';
}
if (scheduleInput) {
scheduleInput.classList.add('error');
scheduleInput.setAttribute('aria-invalid', 'true');
}
}
showValidationSuccess(message) {
const errorDiv = this.container.querySelector('#schedule-error');
const successDiv = this.container.querySelector('#schedule-success');
const scheduleInput = this.container.querySelector('#schedule-input');
if (successDiv) {
successDiv.querySelector('.success-message').textContent = message;
successDiv.style.display = 'flex';
}
if (errorDiv) {
errorDiv.style.display = 'none';
}
if (scheduleInput) {
scheduleInput.classList.remove('error');
scheduleInput.setAttribute('aria-invalid', 'false');
}
}
clearValidation() {
const errorDiv = this.container.querySelector('#schedule-error');
const successDiv = this.container.querySelector('#schedule-success');
const scheduleInput = this.container.querySelector('#schedule-input');
if (errorDiv) {
errorDiv.style.display = 'none';
}
if (successDiv) {
successDiv.style.display = 'none';
}
if (scheduleInput) {
scheduleInput.classList.remove('error');
scheduleInput.setAttribute('aria-invalid', 'false');
}
}
async handleScheduleUpdate() {
const scheduleInput = this.container.querySelector('#schedule-input');
const cronRadio = this.container.querySelector('#schedule-type-cron');
const updateBtn = this.container.querySelector('#update-schedule-btn');
if (!this.validateScheduleInput(true)) {
return;
}
const value = scheduleInput.value.trim();
const isCron = cronRadio.checked;
// Show loading state
this.setButtonLoading(updateBtn, true);
try {
console.log('Updating persistent schedule:', { schedule: value, type: isCron ? 'cron' : 'interval' });
// Use the new persistent schedule API
const response = await this.api.put('/schedule', {
schedule: value,
type: isCron ? 'cron' : 'interval'
});
console.log('Schedule persistence response:', response);
if (response.success) {
showToast('Schedule saved successfully and will persist across restarts', 'success');
// Reload the current status to get the updated information
await this.loadCurrentStatus();
} else {
throw new Error(response.error || response.message || 'Failed to save schedule');
}
} catch (error) {
console.error('Failed to save schedule:', error);
const errorMessage = error.message || 'Failed to save schedule';
showToast(errorMessage, 'error');
this.showValidationError(errorMessage);
} finally {
this.setButtonLoading(updateBtn, false);
}
}
async handlePersistenceToggle() {
const status = this.currentStatus || {};
const currentlyDisabled = !!status.disabled;
const promptMsg = currentlyDisabled
? 'Re-enable persistent schedule (will resume using schedule.yml contents)?'
: 'Disable persistent schedule (file retained; environment fallback used if set)?';
if (!confirm(promptMsg)) {
return;
}
const btn = this.container.querySelector('#delete-schedule-btn');
this.setButtonLoading(btn, true);
try {
console.log('Toggling persistent schedule disabled_before=', currentlyDisabled);
// New endpoint replaces legacy DELETE /schedule?confirm=1
const response = await this.api.post('/schedule/persistence/toggle', {});
console.log('Persistence toggle response:', response);
if (response.success) {
const action = response.action || (response.disabled ? 'disabled' : 'enabled');
const toastMsg = action === 'disabled'
? 'Persistent schedule disabled (metadata retained)'
: 'Persistent schedule re-enabled';
showToast(toastMsg, 'success');
await this.loadCurrentStatus();
} else {
const msg = response.error || response.message || 'Failed to toggle persistence';
showToast(msg, 'error');
throw new Error(msg);
}
} catch (error) {
console.error('Failed to toggle persistent schedule:', error);
const errorMessage = error.message || 'Failed to toggle persistent schedule';
showToast(errorMessage, 'error');
} finally {
this.setButtonLoading(btn, false);
}
}
applyPreset(type, value) {
const cronRadio = this.container.querySelector('#schedule-type-cron');
const intervalRadio = this.container.querySelector('#schedule-type-interval');
const scheduleInput = this.container.querySelector('#schedule-input');
// Set the appropriate radio button
if (type === 'cron' && cronRadio) {
cronRadio.checked = true;
} else if (type === 'interval' && intervalRadio) {
intervalRadio.checked = true;
}
// Update the form based on type change
this.handleScheduleTypeChange();
// Set the value
if (scheduleInput) {
scheduleInput.value = value;
scheduleInput.focus();
}
// Validate the preset value
this.validateScheduleInput(true);
}
populateFormWithCurrentSchedule(status) {
if (!status.current_schedule) {
// No current schedule - leave form in default state (cron, empty value)
console.log('No current schedule to populate form with');
return;
}
const cronRadio = this.container.querySelector('#schedule-type-cron');
const intervalRadio = this.container.querySelector('#schedule-type-interval');
const scheduleInput = this.container.querySelector('#schedule-input');
if (!cronRadio || !intervalRadio || !scheduleInput) {
return;
}
// Set flag to prevent clearing values during type change
this._isPopulatingForm = true;
try {
const scheduleType = status.current_schedule.type;
const scheduleValue = status.current_schedule.value;
// Set the appropriate radio button
if (scheduleType === 'cron') {
cronRadio.checked = true;
} else if (scheduleType === 'interval') {
intervalRadio.checked = true;
}
// Update form based on type
this.handleScheduleTypeChange();
// Set the value
scheduleInput.value = scheduleValue;
// Validate the current value
this.validateScheduleInput();
} finally {
// Clear the flag
this._isPopulatingForm = false;
}
}
resetForm() {
const cronRadio = this.container.querySelector('#schedule-type-cron');
const scheduleInput = this.container.querySelector('#schedule-input');
const updateBtn = this.container.querySelector('#update-schedule-btn');
if (cronRadio) {
cronRadio.checked = true;
}
if (scheduleInput) {
scheduleInput.value = '';
}
if (updateBtn) {
updateBtn.disabled = true;
}
this.handleScheduleTypeChange();
this.clearValidation();
}
setButtonLoading(button, loading) {
if (!button) return;
const btnText = button.querySelector('.btn-text');
const btnLoading = button.querySelector('.btn-loading');
if (loading) {
button.disabled = true;
if (btnText) btnText.style.display = 'none';
if (btnLoading) btnLoading.style.display = 'inline-flex';
} else {
button.disabled = false;
if (btnText) btnText.style.display = 'inline';
if (btnLoading) btnLoading.style.display = 'none';
}
}
showError(message) {
showToast(message, 'error');
}
show() {
if (this.container) {
this.container.style.display = 'block';
}
}
hide() {
if (this.container) {
this.container.style.display = 'none';
}
}
destroy() {
if (this.container) {
this.container.innerHTML = '';
}
}
getCurrentStatus() {
return this.currentStatus;
}
}
export { SchedulerControl };