- 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
287 lines
11 KiB
JavaScript
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">—</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 ? '⏸ Disable' : '▶ 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 || '') + '">🗑 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">✕</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' ? '●' : '○') + '</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 });
|
|
})();
|