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
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
/* =====================================================================
|
||||
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 });
|
||||
})();
|
||||
Reference in New Issue
Block a user