Files
Bjorn/web/js/pages/scheduler.js
Fabien POLLY eb20b168a6 Add RLUtils class for managing RL/AI dashboard endpoints
- Implemented methods for fetching AI stats, training history, and recent experiences.
- Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling.
- Included helper methods for querying the database and sending JSON responses.
- Integrated model metadata extraction for visualization purposes.
2026-02-18 22:36:10 +01:00

545 lines
20 KiB
JavaScript

/**
* Scheduler page module.
* Kanban-style board with 6 lanes, live refresh, countdown timers, search, history modal.
* Endpoints: GET /action_queue, POST /queue_cmd, GET /attempt_history
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, $$, empty } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'scheduler';
const PAGE_SIZE = 100;
const LANES = ['running', 'pending', 'upcoming', 'success', 'failed', 'cancelled'];
const LANE_LABELS = {
running: t('sched.running'),
pending: t('sched.pending'),
upcoming: t('sched.upcoming'),
success: t('sched.success'),
failed: t('sched.failed'),
cancelled: t('sched.cancelled')
};
/* ── state ── */
let tracker = null;
let poller = null;
let clockTimer = null;
let LIVE = true;
let FOCUS = false;
let COMPACT = false;
let COLLAPSED = false;
let INCLUDE_SUPERSEDED = false;
let lastBuckets = null;
let showCount = null;
let lastFilterKey = '';
let iconCache = new Map();
/* ── lifecycle ── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
container.appendChild(buildShell());
tracker.trackEventListener(window, 'keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
await tick();
setLive(true);
}
export function unmount() {
if (poller) { poller.stop(); poller = null; }
if (clockTimer) { clearInterval(clockTimer); clockTimer = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
lastBuckets = null;
showCount = null;
iconCache.clear();
}
/* ── shell ── */
function buildShell() {
return el('div', { class: 'scheduler-container' }, [
el('div', { id: 'sched-errorBar', class: 'notice', style: 'display:none' }),
el('div', { class: 'controls' }, [
el('input', {
type: 'text', id: 'sched-search', placeholder: 'Filter (action, MAC, IP, host, service, port...)',
oninput: onSearch
}),
pill('sched-liveBtn', t('common.on'), true, () => setLive(!LIVE)),
pill('sched-refBtn', t('common.refresh'), false, () => tick()),
pill('sched-focBtn', 'Focus active', false, () => { FOCUS = !FOCUS; $('#sched-focBtn')?.classList.toggle('active', FOCUS); lastFilterKey = ''; tick(); }),
pill('sched-cmpBtn', 'Compact', false, () => { COMPACT = !COMPACT; $('#sched-cmpBtn')?.classList.toggle('active', COMPACT); lastFilterKey = ''; tick(); }),
pill('sched-colBtn', 'Collapse', false, toggleCollapse),
pill('sched-supBtn', INCLUDE_SUPERSEDED ? '- superseded' : '+ superseded', false, toggleSuperseded),
el('span', { id: 'sched-stats', class: 'stats' }),
]),
el('div', { id: 'sched-boardWrap', class: 'boardWrap' }, [
el('div', { id: 'sched-board', class: 'board' }),
]),
/* history modal */
el('div', {
id: 'sched-histModal', class: 'modalOverlay', style: 'display:none', 'aria-hidden': 'true',
onclick: (e) => { if (e.target.id === 'sched-histModal') closeModal(); }
}, [
el('div', { class: 'modal' }, [
el('div', { class: 'modalHeader' }, [
el('div', { class: 'title' }, [t('sched.history')]),
el('div', { id: 'sched-histTitle', class: 'muted' }),
el('div', { class: 'spacer' }),
el('button', { class: 'xBtn', onclick: closeModal }, [t('common.close')]),
]),
el('div', { id: 'sched-histBody', class: 'modalBody' }),
el('div', { class: 'modalFooter' }, [
el('small', {}, ['Rows are color-coded by status.']),
]),
]),
]),
]);
}
function pill(id, text, active, onclick) {
return el('span', { id, class: `pill ${active ? 'active' : ''}`, onclick }, [text]);
}
/* ── data fetch ── */
async function fetchQueue() {
const data = await api.get('/action_queue', { timeout: 8000 });
const rawRows = Array.isArray(data) ? data : (data?.rows || []);
return rawRows.map(normalizeRow);
}
function normalizeRow(r) {
const status = (r.status || '').toLowerCase() === 'expired' ? 'failed' : (r.status || '').toLowerCase();
const scheduled_ms = isoToMs(r.scheduled_for);
const created_ms = isoToMs(r.created_at) || Date.now();
const started_ms = isoToMs(r.started_at);
const completed_ms = isoToMs(r.completed_at);
let _computed_status = status;
if (status === 'scheduled') _computed_status = 'upcoming';
else if (status === 'pending' && scheduled_ms > Date.now()) _computed_status = 'upcoming';
const tags = dedupeArr(toArray(r.tags));
const metadata = typeof r.metadata === 'string' ? parseJSON(r.metadata, {}) : (r.metadata || {});
return {
...r, status, scheduled_ms, created_ms, started_ms, completed_ms,
_computed_status, tags, metadata,
mac: r.mac || r.mac_address || '',
priority_effective: r.priority_effective ?? r.priority ?? 0,
};
}
/* ── tick / render ── */
async function tick() {
try {
const rows = await fetchQueue();
render(rows);
} catch (e) {
showError('Queue fetch error: ' + e.message);
}
}
function render(rows) {
const q = ($('#sched-search')?.value || '').toLowerCase();
/* filter */
let filtered = rows;
if (q) {
filtered = filtered.filter(r => {
const bag = `${r.action_name} ${r.mac} ${r.ip} ${r.hostname} ${r.service} ${r.port} ${(r.tags || []).join(' ')}`.toLowerCase();
return bag.includes(q);
});
}
if (FOCUS) filtered = filtered.filter(r => ['upcoming', 'pending', 'running'].includes(r._computed_status));
/* superseded filter */
if (!INCLUDE_SUPERSEDED) {
const activeKeys = new Set();
filtered.forEach(r => {
if (['upcoming', 'pending', 'running'].includes(r._computed_status)) {
activeKeys.add(`${r.action_name}|${r.mac}|${r.port || 0}`);
}
});
filtered = filtered.filter(r => {
if (r._computed_status !== 'failed') return true;
const key = `${r.action_name}|${r.mac}|${r.port || 0}`;
return !activeKeys.has(key);
});
}
/* dedupe failed: keep highest retry per key */
const failMap = new Map();
filtered.filter(r => r._computed_status === 'failed').forEach(r => {
const key = `${r.action_name}|${r.mac}|${r.port || 0}`;
const prev = failMap.get(key);
if (!prev || (r.retry_count || 0) > (prev.retry_count || 0) || r.created_ms > prev.created_ms) failMap.set(key, r);
});
const failIds = new Set(Array.from(failMap.values()).map(r => r.id));
filtered = filtered.filter(r => r._computed_status !== 'failed' || failIds.has(r.id));
/* bucket */
const buckets = {};
LANES.forEach(l => buckets[l] = []);
filtered.forEach(r => {
const lane = buckets[r._computed_status];
if (lane) lane.push(r);
});
/* sort per lane */
const byNewest = (a, b) => Math.max(b.completed_ms, b.started_ms, b.created_ms) - Math.max(a.completed_ms, a.started_ms, a.created_ms);
const byPrio = (a, b) => (b.priority_effective - a.priority_effective) || byNewest(a, b);
buckets.running.sort(byPrio);
buckets.pending.sort((a, b) => byPrio(a, b) || (a.scheduled_ms || a.created_ms) - (b.scheduled_ms || b.created_ms));
buckets.upcoming.sort((a, b) => (a.scheduled_ms || Infinity) - (b.scheduled_ms || Infinity));
buckets.success.sort((a, b) => (b.completed_ms || b.started_ms || b.created_ms) - (a.completed_ms || a.started_ms || a.created_ms));
buckets.failed.sort((a, b) => (b.completed_ms || b.started_ms || b.created_ms) - (a.completed_ms || a.started_ms || a.created_ms));
buckets.cancelled.sort(byPrio);
if (COMPACT) {
LANES.forEach(l => {
buckets[l] = keepLatest(buckets[l], r => `${r.action_name}|${r.mac}|${r.port || 0}`, r => Math.max(r.completed_ms, r.started_ms, r.created_ms));
});
}
/* stats */
const total = filtered.length;
const statsEl = $('#sched-stats');
if (statsEl) statsEl.textContent = `${total} entries | R:${buckets.running.length} P:${buckets.pending.length} U:${buckets.upcoming.length} S:${buckets.success.length} F:${buckets.failed.length}`;
/* pagination */
const fk = filterKey(q);
if (fk !== lastFilterKey) { showCount = {}; LANES.forEach(l => showCount[l] = PAGE_SIZE); lastFilterKey = fk; }
if (!showCount) { showCount = {}; LANES.forEach(l => showCount[l] = PAGE_SIZE); }
lastBuckets = buckets;
renderBoard(buckets);
}
function renderBoard(buckets) {
const board = $('#sched-board');
if (!board) return;
empty(board);
LANES.forEach(lane => {
const items = buckets[lane] || [];
const visible = items.slice(0, showCount?.[lane] || PAGE_SIZE);
const hasMore = items.length > visible.length;
const laneEl = el('div', { class: `lane status-${lane}` }, [
el('div', { class: 'laneHeader' }, [
el('span', { class: 'dot' }),
el('strong', {}, [LANE_LABELS[lane]]),
el('span', { class: 'count' }, [String(items.length)]),
]),
el('div', { class: 'laneBody' },
visible.length === 0
? [el('div', { class: 'empty' }, ['No entries'])]
: [
...visible.map(r => cardEl(r)),
...(hasMore ? [el('button', {
class: 'moreBtn', onclick: () => {
showCount[lane] = (showCount[lane] || PAGE_SIZE) + PAGE_SIZE;
if (lastBuckets) renderBoard(lastBuckets);
}
}, ['Display more\u2026'])] : []),
]
),
]);
board.appendChild(laneEl);
});
if (COLLAPSED) $$('.card', board).forEach(c => c.classList.add('collapsed'));
/* restart countdown clock */
if (clockTimer) clearInterval(clockTimer);
clockTimer = setInterval(updateCountdowns, 1000);
}
/* ── card ── */
function cardEl(r) {
const cs = r._computed_status;
const children = [];
/* info button */
children.push(el('button', {
class: 'infoBtn', title: t('sched.history'),
onclick: () => openHistory(r.action_name, r.mac, r.port || 0)
}, ['i']));
/* header */
children.push(el('div', { class: 'cardHeader' }, [
el('div', { class: 'actionIconWrap' }, [
el('img', {
class: 'actionIcon', src: resolveIconSync(r.action_name),
width: '80', height: '80', onerror: (e) => { e.target.src = '/actions/actions_icons/default.png'; }
}),
]),
el('div', { class: 'actionName' }, [
el('span', { class: 'chip', style: `--h:${hashHue(r.action_name)}` }, [r.action_name]),
]),
el('span', { class: `badge status-${cs}` }, [cs]),
]));
/* chips */
const chips = [];
if (r.hostname) chips.push(chipEl(r.hostname, 195));
if (r.ip) chips.push(chipEl(r.ip, 195));
if (r.port) chips.push(chipEl(`Port ${r.port}`, 210, 'Port'));
if (r.mac) chips.push(chipEl(r.mac, 195));
if (chips.length) children.push(el('div', { class: 'chips' }, chips));
/* service kv */
if (r.service) children.push(el('div', { class: 'kv' }, [el('span', {}, [`Svc: ${r.service}`])]));
/* tags */
if (r.tags?.length) {
children.push(el('div', { class: 'tags' },
r.tags.map(tag => el('span', { class: 'tag' }, [tag]))));
}
/* timer */
if ((cs === 'upcoming' || (cs === 'pending' && r.scheduled_ms > Date.now())) && r.scheduled_ms) {
children.push(el('div', { class: 'timer', 'data-type': 'start', 'data-ts': String(r.scheduled_ms) }, [
'Eligible in ', el('span', { class: 'cd' }, ['-']),
]));
children.push(el('div', { class: 'progress' }, [
el('div', { class: 'bar', 'data-start': String(r.created_ms), 'data-end': String(r.scheduled_ms), style: 'width:0%' }),
]));
} else if (cs === 'running' && r.started_ms) {
children.push(el('div', { class: 'timer', 'data-type': 'elapsed', 'data-ts': String(r.started_ms) }, [
'Elapsed ', el('span', { class: 'cd' }, ['-']),
]));
}
/* meta */
const meta = [el('span', {}, [`created: ${fmt(r.created_at)}`])];
if (r.started_at) meta.push(el('span', {}, [`started: ${fmt(r.started_at)}`]));
if (r.completed_at) meta.push(el('span', {}, [`done: ${fmt(r.completed_at)}`]));
if (r.retry_count > 0) meta.push(el('span', { class: 'chip', style: '--h:30' }, [
`retries ${r.retry_count}${r.max_retries != null ? '/' + r.max_retries : ''}`]));
if (r.priority_effective) meta.push(el('span', {}, [`prio: ${r.priority_effective}`]));
children.push(el('div', { class: 'meta' }, meta));
/* buttons */
const btns = [];
if (['upcoming', 'scheduled', 'pending', 'running'].includes(r.status)) {
btns.push(el('button', { class: 'btn warn', onclick: () => queueCmd(r.id, 'cancel') }, ['Cancel']));
}
if (!['running', 'pending', 'scheduled'].includes(r.status)) {
btns.push(el('button', { class: 'btn danger', onclick: () => queueCmd(r.id, 'delete') }, ['Delete']));
}
if (btns.length) children.push(el('div', { class: 'btns' }, btns));
/* error / result */
if (r.error_message) children.push(el('div', { class: 'notice error' }, [r.error_message]));
if (r.result_summary) children.push(el('div', { class: 'notice success' }, [r.result_summary]));
return el('div', { class: `card status-${cs}` }, children);
}
function chipEl(text, hue, prefix) {
const parts = [];
if (prefix) parts.push(el('span', { class: 'k' }, [prefix]), '\u00A0');
parts.push(text);
return el('span', { class: 'chip', style: `--h:${hue}` }, parts);
}
/* ── countdown / progress ── */
function updateCountdowns() {
const now = Date.now();
$$('.timer').forEach(timer => {
const type = timer.dataset.type;
const ts = parseInt(timer.dataset.ts);
const cd = timer.querySelector('.cd');
if (!cd || !ts) return;
if (type === 'start') {
const diff = ts - now;
cd.textContent = diff <= 0 ? 'due' : ms2str(diff);
} else if (type === 'elapsed') {
cd.textContent = ms2str(now - ts);
}
});
$$('.progress .bar').forEach(bar => {
const start = parseInt(bar.dataset.start);
const end = parseInt(bar.dataset.end);
if (!start || !end || end <= start) return;
const pct = Math.min(100, Math.max(0, ((now - start) / (end - start)) * 100));
bar.style.width = pct + '%';
});
}
/* ── queue command ── */
async function queueCmd(id, cmd) {
try {
await api.post('/queue_cmd', { id, cmd });
tick();
} catch (e) {
showError('Command failed: ' + e.message);
}
}
/* ── history modal ── */
async function openHistory(action, mac, port) {
const modal = $('#sched-histModal');
const title = $('#sched-histTitle');
const body = $('#sched-histBody');
if (!modal || !body) return;
if (title) title.textContent = `\u2014 ${action} \u00B7 ${mac}${port && port !== 0 ? ` \u00B7 port ${port}` : ''}`;
empty(body);
body.appendChild(el('div', { class: 'empty' }, ['Loading\u2026']));
modal.style.display = 'flex';
modal.setAttribute('aria-hidden', 'false');
try {
const url = `/attempt_history?action=${encodeURIComponent(action)}&mac=${encodeURIComponent(mac)}&port=${encodeURIComponent(port)}&limit=100`;
const data = await api.get(url, { timeout: 8000 });
const rows = Array.isArray(data) ? data : (data?.rows || data || []);
empty(body);
if (!rows.length) {
body.appendChild(el('div', { class: 'empty' }, ['No history']));
return;
}
const norm = rows.map(x => ({
status: (x.status || '').toLowerCase(),
retry_count: Number(x.retry_count || 0),
max_retries: x.max_retries,
ts: x.ts || x.completed_at || x.started_at || x.scheduled_for || x.created_at || '',
})).sort((a, b) => (b.ts > a.ts ? 1 : -1));
norm.forEach(hr => {
const st = hr.status || 'unknown';
const retry = (hr.retry_count || hr.max_retries != null)
? el('span', { style: 'color:var(--ink)' }, [`retry ${hr.retry_count}${hr.max_retries != null ? '/' + hr.max_retries : ''}`])
: null;
body.appendChild(el('div', { class: `histRow hist-${st}` }, [
el('span', { class: 'ts' }, [fmt(hr.ts)]),
retry,
el('span', { style: 'margin-left:auto' }),
el('span', { class: 'st' }, [st]),
].filter(Boolean)));
});
} catch (e) {
empty(body);
body.appendChild(el('div', { class: 'empty' }, [`Error: ${e.message}`]));
}
}
function closeModal() {
const modal = $('#sched-histModal');
if (!modal) return;
modal.style.display = 'none';
modal.setAttribute('aria-hidden', 'true');
}
/* ── controls ── */
function setLive(on) {
LIVE = on;
const btn = $('#sched-liveBtn');
if (btn) btn.classList.toggle('active', LIVE);
if (poller) { poller.stop(); poller = null; }
if (LIVE) {
poller = new Poller(tick, 2500, { immediate: false });
poller.start();
}
}
function toggleCollapse() {
COLLAPSED = !COLLAPSED;
const btn = $('#sched-colBtn');
if (btn) btn.textContent = COLLAPSED ? 'Expand' : 'Collapse';
$$('#sched-board .card').forEach(c => c.classList.toggle('collapsed', COLLAPSED));
}
function toggleSuperseded() {
INCLUDE_SUPERSEDED = !INCLUDE_SUPERSEDED;
const btn = $('#sched-supBtn');
if (btn) {
btn.classList.toggle('active', INCLUDE_SUPERSEDED);
btn.textContent = INCLUDE_SUPERSEDED ? '- superseded' : '+ superseded';
}
lastFilterKey = '';
tick();
}
let searchDeb = null;
function onSearch() {
clearTimeout(searchDeb);
searchDeb = setTimeout(() => { lastFilterKey = ''; tick(); }, 180);
}
function showError(msg) {
const bar = $('#sched-errorBar');
if (!bar) return;
bar.textContent = msg;
bar.style.display = 'block';
setTimeout(() => { bar.style.display = 'none'; }, 5000);
}
/* ── icon resolution ── */
function resolveIconSync(name) {
if (iconCache.has(name)) return iconCache.get(name);
/* async resolve, return default for now */
resolveIconAsync(name);
return '/actions/actions_icons/default.png';
}
async function resolveIconAsync(name) {
if (iconCache.has(name)) return;
const candidates = [
`/actions/actions_icons/${name}.png`,
`/resources/images/status/${name}/${name}.bmp`,
];
for (const url of candidates) {
try {
const r = await fetch(url, { method: 'HEAD', cache: 'force-cache' });
if (r.ok) { iconCache.set(name, url); updateIconsInDOM(name, url); return; }
} catch { /* next */ }
}
iconCache.set(name, '/actions/actions_icons/default.png');
}
function updateIconsInDOM(name, url) {
$$(`img.actionIcon`).forEach(img => {
if (img.closest('.cardHeader')?.querySelector('.actionName')?.textContent?.trim() === name) {
if (img.src !== url) img.src = url;
}
});
}
/* ── helpers ── */
function isoToMs(ts) { if (!ts) return 0; return new Date(ts + (ts.includes('Z') || ts.includes('+') ? '' : 'Z')).getTime() || 0; }
function fmt(ts) { if (!ts) return '-'; try { return new Date(ts + (ts.includes('Z') || ts.includes('+') ? '' : 'Z')).toLocaleString(); } catch { return ts; } }
function ms2str(ms) {
if (ms < 0) ms = 0;
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m ${String(sec).padStart(2, '0')}s`;
if (m > 0) return `${m}m ${String(sec).padStart(2, '0')}s`;
return `${sec}s`;
}
function toArray(v) {
if (!v) return [];
if (Array.isArray(v)) return v.map(String).filter(Boolean);
try { const p = JSON.parse(v); if (Array.isArray(p)) return p.map(String).filter(Boolean); } catch { /* noop */ }
return String(v).split(',').map(s => s.trim()).filter(Boolean);
}
function dedupeArr(a) { return [...new Set(a)]; }
function parseJSON(s, fb) { try { return JSON.parse(s); } catch { return fb; } }
function hashHue(str) { let h = 0; for (let i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0; return ((h % 360) + 360) % 360; }
function filterKey(q) { return `${q}|${FOCUS}|${COMPACT}|${INCLUDE_SUPERSEDED}`; }
function keepLatest(rows, keyFn, dateFn) {
const map = new Map();
rows.forEach(r => {
const k = keyFn(r);
const prev = map.get(k);
if (!prev || dateFn(r) > dateFn(prev)) map.set(k, r);
});
return Array.from(map.values());
}