Files
pulse/public/assets/pages/scheduler.js
T
jaredandClaude Fable 5.1 68d0a906a6 feat(ui): TDS migration stage 2 — six page views and page modules
- Dashboard, Workers, Workflows, Executions, Quick Command, Scheduler pages on the
  shared layout, all behaviour via Pulse delegated actions (no inline handlers)
- Executions: server-driven Manual/Automated views, compare mode, detail modal with all
  log types, ?open= deep link, cross-page re-run via sessionStorage
- Workflows: run modal (dry-run available for every workflow), create modal pre-filled
  with the example definition
- Fix EJS comment in page-header partial that broke rendering

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
2026-09-08 21:57:33 -04:00

287 lines
11 KiB
JavaScript

/* =====================================================================
PULSE — Scheduler page (WP-F)
Owns DOM id prefix `sc-` and action namespace `sc:*`.
===================================================================== */
'use strict';
(function () {
const esc = Pulse.esc;
const fmt = Pulse.fmt;
let _schedules = [];
let _workers = [];
/* -------------------------------------------------------------------
Helpers
------------------------------------------------------------------- */
function parseWorkerIds(raw) {
if (Array.isArray(raw)) return raw;
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : raw.split(',').filter(Boolean);
} catch (e) {
return raw.split(',').filter(Boolean);
}
}
return [];
}
function scheduleDescription(s) {
if (s.schedule_type === 'interval') return 'Every ' + esc(s.schedule_value) + ' minutes';
if (s.schedule_type === 'hourly') return 'Every ' + esc(s.schedule_value) + ' hour(s)';
if (s.schedule_type === 'daily') return 'Daily at ' + esc(s.schedule_value);
if (s.schedule_type === 'cron') return 'Cron: ' + esc(s.schedule_value);
return esc(s.schedule_value || '');
}
function workerNamesHtml(ids) {
if (!ids.length) return '<span class="lt-text-dim">&mdash;</span>';
return ids.map(id => {
const w = _workers.find(worker => worker.id === id);
const label = w ? w.name : String(id).slice(0, 8);
return '<span class="lt-badge">' + esc(label) + '</span>';
}).join(' ');
}
function nextRunCountdown(nextRun) {
const d = fmt.safeDate(nextRun);
if (!d) return '';
const secs = Math.round((d.getTime() - Date.now()) / 1000);
if (secs <= 0) return 'now';
if (secs < 60) return secs + 's';
if (secs < 3600) return Math.round(secs / 60) + 'm';
return Math.round(secs / 3600) + 'h';
}
/* -------------------------------------------------------------------
Table rendering
------------------------------------------------------------------- */
function rowHtml(s) {
const workerIds = parseWorkerIds(s.worker_ids);
const lastRun = s.last_run ? esc(fmt.dateTime(s.last_run)) : 'Never';
const nextRunDate = fmt.safeDate(s.next_run);
const countdown = nextRunCountdown(s.next_run);
const nextRun = nextRunDate
? esc(fmt.dateTime(s.next_run)) + (countdown ? ' <span class="sc-countdown" data-next-run="' + esc(s.next_run) + '">(in ' + esc(countdown) + ')</span>' : '')
: 'Not scheduled';
const statusBadge = s.enabled
? '<span class="lt-badge lt-badge-green">ENABLED</span>'
: '<span class="lt-badge lt-badge-red">DISABLED</span>';
const adminActions = Pulse.isAdmin
? '<div class="lt-btn-group">' +
'<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="sc:toggle" data-id="' + esc(s.id) + '" data-enabled="' + (s.enabled ? '1' : '0') + '">' +
(s.enabled ? '&#x23f8; Disable' : '&#x25b6; Enable') +
'</button>' +
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="sc:delete" data-id="' + esc(s.id) + '" data-name="' + esc(s.name || '') + '">&#x1f5d1; Delete</button>' +
'</div>'
: '';
return (
'<tr class="' + (s.enabled ? '' : 'sc-row-disabled') + '">' +
'<td data-label="Name">' + esc(s.name || '') + '</td>' +
'<td data-label="Command"><code>' + esc(s.command || '') + '</code></td>' +
'<td data-label="Schedule">' + scheduleDescription(s) + '</td>' +
'<td data-label="Workers">' + workerNamesHtml(workerIds) + '</td>' +
'<td data-label="Last run">' + lastRun + '</td>' +
'<td data-label="Next run">' + nextRun + '</td>' +
'<td data-label="Status">' + statusBadge + '</td>' +
'<td data-label="Actions">' + adminActions + '</td>' +
'</tr>'
);
}
function render() {
const wrap = document.getElementById('sc-wrap');
if (!wrap) return;
if (_schedules.length === 0) {
wrap.innerHTML = '<div class="lt-empty-state"><div class="lt-empty-state-title">No scheduled commands yet</div></div>';
return;
}
wrap.innerHTML =
'<table class="lt-table lt-table-responsive">' +
'<thead><tr><th>Name</th><th>Command</th><th>Schedule</th><th>Workers</th><th>Last run</th><th>Next run</th><th>Status</th><th>Actions</th></tr></thead>' +
'<tbody>' + _schedules.map(rowHtml).join('') + '</tbody>' +
'</table>';
}
function renderError() {
const wrap = document.getElementById('sc-wrap');
if (!wrap) return;
wrap.innerHTML =
'<div class="lt-alert lt-alert--error">' +
'<span class="lt-alert-icon">&#x2715;</span>' +
'<div class="lt-alert-body"><div class="lt-alert-title">Failed to load schedules</div></div>' +
'</div>';
}
function onTick() {
document.querySelectorAll('#sc-wrap .sc-countdown[data-next-run]').forEach(el => {
const countdown = nextRunCountdown(el.getAttribute('data-next-run'));
el.textContent = countdown ? '(in ' + countdown + ')' : '';
});
}
/* -------------------------------------------------------------------
Create modal
------------------------------------------------------------------- */
function renderCreateWorkerList() {
const wrap = document.getElementById('sc-worker-list');
if (!wrap) return;
if (_workers.length === 0) {
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No workers available</div></div>';
return;
}
wrap.innerHTML = _workers.map(w =>
'<label class="qc-worker-row' + (w.status === 'online' ? ' qc-worker-row--online' : '') + '">' +
'<input type="checkbox" class="lt-checkbox" name="sc-worker-cb" value="' + esc(w.id) + '">' +
'<span class="' + fmt.status(w.status) + '">' + (w.status === 'online' ? '&#x25cf;' : '&#x25cb;') + '</span>' +
'<strong>' + esc(w.name) + '</strong>' +
'</label>'
).join('');
}
function updateValueInput() {
const type = document.getElementById('sc-type').value;
const container = document.getElementById('sc-value-container');
if (!container) return;
if (type === 'interval') {
container.innerHTML =
'<label class="lt-label" for="sc-value">Interval (minutes)</label>' +
'<input type="number" id="sc-value" class="lt-input" placeholder="e.g. 30" min="1">';
} else if (type === 'hourly') {
container.innerHTML =
'<label class="lt-label" for="sc-value">Every X Hours</label>' +
'<input type="number" id="sc-value" class="lt-input" placeholder="e.g. 2" min="1" max="24">';
} else if (type === 'daily') {
container.innerHTML =
'<label class="lt-label" for="sc-value">Time (HH:MM)</label>' +
'<input type="time" id="sc-value" class="lt-input">';
}
}
function resetCreateForm() {
const form = document.getElementById('sc-create-form');
if (form) form.reset();
const type = document.getElementById('sc-type');
if (type) type.value = 'interval';
updateValueInput();
renderCreateWorkerList();
}
async function submitCreate() {
const name = document.getElementById('sc-name').value.trim();
const command = document.getElementById('sc-command').value.trim();
const type = document.getElementById('sc-type').value;
const valueEl = document.getElementById('sc-value');
const value = valueEl ? valueEl.value : '';
const workerIds = Array.from(document.querySelectorAll('input[name="sc-worker-cb"]:checked')).map(cb => cb.value);
if (!name || !command || !value || workerIds.length === 0) {
Pulse.toast.error('Please fill in all fields and select at least one worker');
return;
}
try {
await Pulse.api.post('/api/scheduled-commands', {
name: name,
command: command,
worker_ids: workerIds,
schedule_type: type,
schedule_value: value,
});
if (window.lt && lt.modal) lt.modal.close('sc-create-modal');
Pulse.toast.success('Schedule created successfully');
resetCreateForm();
await load();
} catch (e) {
Pulse.toast.error('Failed to create schedule: ' + (e.message || 'unknown error'));
}
}
async function toggleSchedule(id) {
try {
const data = await Pulse.api.put('/api/scheduled-commands/' + encodeURIComponent(id) + '/toggle');
Pulse.toast.success('Schedule ' + (data && data.enabled ? 'enabled' : 'disabled'));
await load();
} catch (e) {
Pulse.toast.error('Failed to toggle schedule: ' + (e.message || 'unknown error'));
}
}
async function deleteSchedule(id, name) {
const ok = await Pulse.confirm({
title: 'Delete schedule',
message: 'Delete scheduled command: ' + name + '?',
type: 'error',
confirmLabel: 'DELETE',
});
if (!ok) return;
try {
await Pulse.api.delete('/api/scheduled-commands/' + encodeURIComponent(id));
Pulse.toast.success('Schedule deleted');
await load();
} catch (e) {
Pulse.toast.error('Failed to delete schedule: ' + (e.message || 'unknown error'));
}
}
/* -------------------------------------------------------------------
Data loading
------------------------------------------------------------------- */
async function loadWorkers() {
try {
_workers = await Pulse.api.get('/api/workers') || [];
} catch (e) {
_workers = [];
console.error('[Pulse:scheduler] failed to load workers', e);
}
return _workers;
}
async function loadSchedules() {
try {
_schedules = await Pulse.api.get('/api/scheduled-commands') || [];
render();
} catch (e) {
_schedules = [];
console.error('[Pulse:scheduler] failed to load schedules', e);
renderError();
}
return _schedules;
}
async function load() {
await loadWorkers();
await loadSchedules();
}
async function refresh() {
await load();
}
function init() {
Pulse.actions.registerAll({
'sc:create-open': () => {
resetCreateForm();
if (window.lt && lt.modal) lt.modal.open('sc-create-modal');
},
'sc:create-submit': submitCreate,
'sc:type': updateValueInput,
'sc:toggle': (el) => toggleSchedule(el.getAttribute('data-id')),
'sc:delete': (el) => deleteSchedule(el.getAttribute('data-id'), el.getAttribute('data-name')),
});
Pulse.events.on('tick', onTick);
return load();
}
function onEvent(type) {
if (type === 'worker_update') {
loadWorkers().then(render);
return true;
}
return false;
}
Pulse.registerPage({ name: 'scheduler', init, refresh, onEvent });
})();