mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-11 15:11:59 +00:00
- 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.
545 lines
20 KiB
JavaScript
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());
|
|
}
|