Upload files to "public/js"
This commit is contained in:
@@ -0,0 +1,527 @@
|
|||||||
|
import { CONFIG } from './config.js';
|
||||||
|
import { getApi } from './api.js';
|
||||||
|
import {
|
||||||
|
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
|
||||||
|
} from './format.js';
|
||||||
|
|
||||||
|
const api = await getApi();
|
||||||
|
|
||||||
|
// ---------- Domain ----------
|
||||||
|
// rank drives default sort: worst first
|
||||||
|
const STATUS = {
|
||||||
|
failed: { label: 'Failed', icon: '✕', rank: 0 },
|
||||||
|
warning: { label: 'Warning', icon: '!', rank: 1 },
|
||||||
|
unknown: { label: 'Unknown', icon: '?', rank: 2 },
|
||||||
|
running: { label: 'Running', icon: '↻', rank: 3 },
|
||||||
|
success: { label: 'Success', icon: '✓', rank: 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Anything the backend sends that we don't recognise becomes "unknown" rather than breaking the UI
|
||||||
|
function normalizeStatus(raw) {
|
||||||
|
const s = String(raw ?? '').toLowerCase();
|
||||||
|
return Object.hasOwn(STATUS, s) ? s : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJob(j) {
|
||||||
|
return {
|
||||||
|
id: String(j.id),
|
||||||
|
company: j.company_name ?? '',
|
||||||
|
name: j.job_name ?? '',
|
||||||
|
status: normalizeStatus(j.status),
|
||||||
|
message: j.message ?? '',
|
||||||
|
runningSince: parseDate(j.running_since),
|
||||||
|
// When the most recent run *finished*; a job that's running keeps showing its previous finish
|
||||||
|
lastRun: parseDate(j.last_run_at),
|
||||||
|
nextRun: parseDate(j.next_run_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A running job isn't silently dead, so it's never overdue (a long run can pass its next slot)
|
||||||
|
const isOverdue = (job, now = Date.now()) =>
|
||||||
|
job.status !== 'running' && !!job.nextRun && job.nextRun.getTime() + CONFIG.overdueGraceMinutes * 60_000 < now;
|
||||||
|
|
||||||
|
// ---------- State ----------
|
||||||
|
const state = {
|
||||||
|
jobs: [],
|
||||||
|
filter: 'all',
|
||||||
|
search: '',
|
||||||
|
sort: { key: 'status', dir: 1 },
|
||||||
|
lastSuccess: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- DOM ----------
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
const dom = {
|
||||||
|
tbody: $('#jobs-body'),
|
||||||
|
empty: $('#empty-state'),
|
||||||
|
search: $('#search'),
|
||||||
|
refreshBtn: $('#refresh-btn'),
|
||||||
|
lastUpdated: $('#last-updated'),
|
||||||
|
errorBanner: $('#error-banner'),
|
||||||
|
dialog: $('#detail-dialog'),
|
||||||
|
detailTitle: $('#detail-title'),
|
||||||
|
detailSubtitle: $('#detail-subtitle'),
|
||||||
|
detailBody: $('#detail-body'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tiny element builder. All text goes through append() → text nodes, so API data can never inject HTML.
|
||||||
|
function el(tag, props = {}, ...children) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(props)) {
|
||||||
|
if (v == null || v === false) continue;
|
||||||
|
if (k === 'class') node.className = v;
|
||||||
|
else if (k === 'dataset') Object.assign(node.dataset, v);
|
||||||
|
else node.setAttribute(k, v === true ? '' : v);
|
||||||
|
}
|
||||||
|
node.append(...children.flat().filter((c) => c != null && c !== false));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Rendering ----------
|
||||||
|
function statusBadge(status, { asButton = false, opensDialog = false, dataset = null, title = null } = {}) {
|
||||||
|
const { label, icon } = STATUS[status];
|
||||||
|
const content = [el('span', { class: 'badge__icon', 'aria-hidden': 'true' }, icon), label];
|
||||||
|
return asButton
|
||||||
|
? el('button', {
|
||||||
|
type: 'button',
|
||||||
|
class: `badge badge--${status}`,
|
||||||
|
'aria-haspopup': opensDialog ? 'dialog' : null,
|
||||||
|
'aria-label': `${label} – view details`,
|
||||||
|
title,
|
||||||
|
dataset,
|
||||||
|
}, content)
|
||||||
|
: el('span', { class: `badge badge--${status}`, title }, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningTitle = (since) => (since ? `Running since ${formatDateTime(since)}` : null);
|
||||||
|
|
||||||
|
function timeCell(date, extra = null) {
|
||||||
|
if (!date) return el('td', {}, el('span', { class: 'muted' }, 'Never'));
|
||||||
|
return el('td', {},
|
||||||
|
el('time', { class: 'time', datetime: date.toISOString(), title: date.toString() },
|
||||||
|
el('span', {}, formatDateTime(date), extra),
|
||||||
|
el('span', { class: 'time__rel' }, formatRelative(date))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRow(job) {
|
||||||
|
const overdueTag = isOverdue(job) ? el('span', { class: 'tag-overdue' }, 'Overdue') : null;
|
||||||
|
return el('tr', {},
|
||||||
|
el('td', {}, job.company),
|
||||||
|
el('td', {}, job.name),
|
||||||
|
el('td', {}, statusBadge(job.status, {
|
||||||
|
asButton: true, opensDialog: true, dataset: { jobId: job.id }, title: runningTitle(job.runningSince),
|
||||||
|
})),
|
||||||
|
timeCell(job.lastRun),
|
||||||
|
timeCell(job.nextRun, overdueTag));
|
||||||
|
}
|
||||||
|
|
||||||
|
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
|
||||||
|
|
||||||
|
function cmpDate(a, b) {
|
||||||
|
if (!a && !b) return 0;
|
||||||
|
if (!a) return 1;
|
||||||
|
if (!b) return -1;
|
||||||
|
return a - b;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORTERS = {
|
||||||
|
company: (a, b) => collator.compare(a.company, b.company),
|
||||||
|
name: (a, b) => collator.compare(a.name, b.name),
|
||||||
|
status: (a, b) => STATUS[a.status].rank - STATUS[b.status].rank,
|
||||||
|
lastRun: (a, b) => cmpDate(a.lastRun, b.lastRun),
|
||||||
|
nextRun: (a, b) => cmpDate(a.nextRun, b.nextRun),
|
||||||
|
};
|
||||||
|
|
||||||
|
function visibleJobs() {
|
||||||
|
const q = state.search.trim().toLowerCase();
|
||||||
|
const now = Date.now();
|
||||||
|
const { key, dir } = state.sort;
|
||||||
|
return state.jobs
|
||||||
|
.filter((j) => {
|
||||||
|
if (state.filter === 'overdue') return isOverdue(j, now);
|
||||||
|
return state.filter === 'all' || j.status === state.filter;
|
||||||
|
})
|
||||||
|
.filter((j) => !q || j.company.toLowerCase().includes(q) || j.name.toLowerCase().includes(q))
|
||||||
|
// Stable tie-breakers keep row order from jumping around on each refresh
|
||||||
|
.sort((a, b) => SORTERS[key](a, b) * dir || SORTERS.company(a, b) || SORTERS.name(a, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
const now = Date.now();
|
||||||
|
const counts = { all: state.jobs.length, failed: 0, warning: 0, running: 0, success: 0, overdue: 0 };
|
||||||
|
for (const j of state.jobs) {
|
||||||
|
if (j.status in counts) counts[j.status]++;
|
||||||
|
if (isOverdue(j, now)) counts.overdue++;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(counts)) {
|
||||||
|
const node = document.querySelector(`[data-count="${k}"]`);
|
||||||
|
if (node) node.textContent = v;
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.summary-card').forEach((card) => {
|
||||||
|
card.setAttribute('aria-pressed', String(card.dataset.filter === state.filter));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSortHeaders() {
|
||||||
|
document.querySelectorAll('th[aria-sort]').forEach((th) => {
|
||||||
|
const key = th.querySelector('.sort').dataset.sort;
|
||||||
|
th.setAttribute('aria-sort',
|
||||||
|
key === state.sort.key ? (state.sort.dir === 1 ? 'ascending' : 'descending') : 'none');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const rows = visibleJobs();
|
||||||
|
dom.tbody.replaceChildren(...rows.map(renderRow));
|
||||||
|
dom.empty.hidden = rows.length > 0;
|
||||||
|
renderSummary();
|
||||||
|
renderSortHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Data loading ----------
|
||||||
|
let inFlight = null;
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (inFlight) return inFlight; // de-dupe overlapping manual + scheduled refreshes
|
||||||
|
dom.refreshBtn.disabled = true;
|
||||||
|
inFlight = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.listJobs();
|
||||||
|
state.jobs = data.jobs.map(normalizeJob);
|
||||||
|
state.lastSuccess = new Date();
|
||||||
|
dom.errorBanner.hidden = true;
|
||||||
|
dom.lastUpdated.textContent = `Updated ${formatTime(state.lastSuccess)}`;
|
||||||
|
} catch (err) {
|
||||||
|
// Keep showing stale data, but make it obvious it's stale
|
||||||
|
dom.errorBanner.textContent = state.lastSuccess
|
||||||
|
? `Couldn't refresh (${err.message}). Showing data from ${formatTime(state.lastSuccess)}.`
|
||||||
|
: `Couldn't load backup status (${err.message}).`;
|
||||||
|
dom.errorBanner.hidden = false;
|
||||||
|
dom.lastUpdated.textContent = 'Update failed';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
render();
|
||||||
|
dom.refreshBtn.disabled = false;
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll() {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = setTimeout(async () => {
|
||||||
|
await refresh();
|
||||||
|
schedulePoll();
|
||||||
|
}, CONFIG.refreshIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't poll from background tabs (e.g. a wall display that's been switched away); catch up on return
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) clearTimeout(pollTimer);
|
||||||
|
else refresh().then(schedulePoll);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Detail modal ----------
|
||||||
|
// Every async result checks reqId first, so a slow response for job A can never paint over job B.
|
||||||
|
const modal = {
|
||||||
|
reqId: 0,
|
||||||
|
job: null,
|
||||||
|
detail: null,
|
||||||
|
runs: null, // null = loading, [] = none
|
||||||
|
runsError: null,
|
||||||
|
dayFilter: null, // local YYYY-MM-DD, or null for all days
|
||||||
|
selectedRunId: null, // null = the job's latest finished run (from the detail call)
|
||||||
|
runMessage: null, // { status, text, startedAt } for the selected run
|
||||||
|
runMessageError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Colour of a day cell: the worst thing that happened that day
|
||||||
|
const DAY_SEVERITY = ['failed', 'warning', 'unknown', 'running', 'success'];
|
||||||
|
|
||||||
|
const pad2 = (n) => String(n).padStart(2, '0');
|
||||||
|
const localDayKey = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||||
|
const dayLabelFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||||
|
const dayTickFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
|
||||||
|
function normalizeRun(r) {
|
||||||
|
return {
|
||||||
|
id: String(r.id),
|
||||||
|
status: normalizeStatus(r.status),
|
||||||
|
startedAt: parseDate(r.started_at),
|
||||||
|
finishedAt: parseDate(r.finished_at),
|
||||||
|
duration: r.duration_seconds,
|
||||||
|
size: r.size_bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDays(runs, count) {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const days = [];
|
||||||
|
for (let i = count - 1; i >= 0; i--) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
days.push({ key: localDayKey(date), date, counts: {}, total: 0 });
|
||||||
|
}
|
||||||
|
const byKey = new Map(days.map((d) => [d.key, d]));
|
||||||
|
for (const run of runs) {
|
||||||
|
const day = byKey.get(localDayKey(run.startedAt));
|
||||||
|
if (!day) continue;
|
||||||
|
day.counts[run.status] = (day.counts[run.status] ?? 0) + 1;
|
||||||
|
day.total++;
|
||||||
|
}
|
||||||
|
for (const day of days) day.status = day.total ? DAY_SEVERITY.find((s) => day.counts[s]) : 'none';
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeCounts(counts) {
|
||||||
|
return DAY_SEVERITY.filter((s) => counts[s])
|
||||||
|
.map((s) => `${counts[s]} ${STATUS[s].label.toLowerCase()}`)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailRow(label, value) {
|
||||||
|
if (value == null || value === '') return [];
|
||||||
|
return [el('dt', {}, label), el('dd', {}, value)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummarySection() {
|
||||||
|
const { job, detail } = modal;
|
||||||
|
const status = detail ? normalizeStatus(detail.status) : job.status;
|
||||||
|
const runningSince = detail ? parseDate(detail.running_since) : job.runningSince;
|
||||||
|
const lastStatus = detail?.last_status ? normalizeStatus(detail.last_status) : null;
|
||||||
|
|
||||||
|
dom.detailTitle.textContent = detail?.job_name ?? job.name;
|
||||||
|
dom.detailSubtitle.textContent = detail?.company_name ?? job.company;
|
||||||
|
|
||||||
|
return el('section', {},
|
||||||
|
el('div', { class: 'detail-status' },
|
||||||
|
statusBadge(status),
|
||||||
|
runningSince ? el('span', { class: 'muted' }, `since ${formatDateTime(runningSince)}`) : null),
|
||||||
|
el('dl', { class: 'detail-grid' },
|
||||||
|
// While a job is running, its "last run" is the previous one — say how that went
|
||||||
|
status === 'running' && lastStatus ? detailRow('Previous result', STATUS[lastStatus].label) : [],
|
||||||
|
detailRow('Last finished', formatDateTime(detail ? parseDate(detail.last_run_at) : job.lastRun)),
|
||||||
|
detailRow('Next scheduled', formatDateTime(detail ? parseDate(detail.next_run_at) : job.nextRun)),
|
||||||
|
detailRow('Duration', formatDuration(detail?.duration_seconds)),
|
||||||
|
detailRow('Size', formatBytes(detail?.size_bytes)),
|
||||||
|
detailRow('Source', detail?.source_system)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistorySection() {
|
||||||
|
const section = el('section', { id: 'detail-history' },
|
||||||
|
el('h3', { class: 'detail-section-title' }, `Last ${CONFIG.historyDays} days`));
|
||||||
|
|
||||||
|
if (modal.runsError) {
|
||||||
|
section.append(el('p', { class: 'banner banner--error' }, `Couldn't load run history: ${modal.runsError}`));
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
if (!modal.runs) {
|
||||||
|
section.append(el('p', { class: 'muted' }, 'Loading history…'));
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = buildDays(modal.runs, CONFIG.historyDays);
|
||||||
|
const finished = modal.runs.filter((r) => r.finishedAt);
|
||||||
|
const ok = finished.filter((r) => r.status === 'success').length;
|
||||||
|
const totals = {};
|
||||||
|
for (const r of modal.runs) totals[r.status] = (totals[r.status] ?? 0) + 1;
|
||||||
|
|
||||||
|
section.append(
|
||||||
|
el('p', { class: 'history-stats' },
|
||||||
|
modal.runs.length
|
||||||
|
? `${modal.runs.length} runs · ${finished.length ? Math.round((ok / finished.length) * 100) : 0}% successful · ${describeCounts(totals)}`
|
||||||
|
: 'No runs recorded in this period.'),
|
||||||
|
el('div', { class: 'day-strip', role: 'group', 'aria-label': 'Daily results, oldest to newest. Select a day to filter runs.' },
|
||||||
|
days.map((d) => {
|
||||||
|
const label = `${dayLabelFmt.format(d.date)}: ${d.total ? describeCounts(d.counts) : 'no runs'}`;
|
||||||
|
return el('button', {
|
||||||
|
type: 'button',
|
||||||
|
class: `day day--${d.status}`,
|
||||||
|
title: label,
|
||||||
|
'aria-label': label,
|
||||||
|
'aria-pressed': String(modal.dayFilter === d.key),
|
||||||
|
disabled: !d.total,
|
||||||
|
dataset: { day: d.key },
|
||||||
|
});
|
||||||
|
})),
|
||||||
|
el('div', { class: 'day-strip__ticks muted', 'aria-hidden': 'true' },
|
||||||
|
el('span', {}, dayTickFmt.format(days[0].date)),
|
||||||
|
el('span', {}, 'Today')),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = modal.dayFilter
|
||||||
|
? modal.runs.filter((r) => localDayKey(r.startedAt) === modal.dayFilter)
|
||||||
|
: modal.runs;
|
||||||
|
if (!rows.length) return section;
|
||||||
|
|
||||||
|
if (modal.dayFilter) {
|
||||||
|
const date = days.find((d) => d.key === modal.dayFilter)?.date;
|
||||||
|
section.append(el('p', { class: 'muted history-filter' },
|
||||||
|
`Showing ${dayLabelFmt.format(date)} · `,
|
||||||
|
el('button', { type: 'button', class: 'link', dataset: { day: modal.dayFilter } }, 'Show all days')));
|
||||||
|
}
|
||||||
|
|
||||||
|
section.append(el('div', { class: 'runs-wrap' },
|
||||||
|
el('table', { class: 'runs' },
|
||||||
|
el('thead', {}, el('tr', {},
|
||||||
|
['Status', 'Started', 'Finished', 'Duration', 'Size'].map((h) => el('th', { scope: 'col' }, h)))),
|
||||||
|
el('tbody', {}, rows.map((r) => el('tr', { class: r.id === modal.selectedRunId ? 'is-selected' : null },
|
||||||
|
el('td', {}, statusBadge(r.status, { asButton: true, dataset: { runId: r.id } })),
|
||||||
|
el('td', {}, formatDateTime(r.startedAt)),
|
||||||
|
el('td', {}, r.finishedAt ? formatDateTime(r.finishedAt) : el('span', { class: 'muted' }, 'In progress')),
|
||||||
|
el('td', {}, formatDuration(r.duration) ?? '—'),
|
||||||
|
el('td', {}, formatBytes(r.size) ?? '—')))))));
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessageSection() {
|
||||||
|
const { detail, job } = modal;
|
||||||
|
let status;
|
||||||
|
let text;
|
||||||
|
let heading = 'Message';
|
||||||
|
if (modal.selectedRunId) {
|
||||||
|
const run = modal.runs?.find((r) => r.id === modal.selectedRunId);
|
||||||
|
heading = `Message · run started ${formatDateTime(run?.startedAt)}`;
|
||||||
|
status = run?.status ?? 'unknown';
|
||||||
|
text = modal.runMessageError
|
||||||
|
? `Couldn't load this run: ${modal.runMessageError}`
|
||||||
|
: modal.runMessage?.text ?? 'Loading…';
|
||||||
|
} else {
|
||||||
|
status = detail?.last_status ? normalizeStatus(detail.last_status) : job.status;
|
||||||
|
text = detail?.message ?? job.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extras = !modal.selectedRunId && detail?.details && typeof detail.details === 'object'
|
||||||
|
? Object.entries(detail.details).flatMap(([k, v]) =>
|
||||||
|
detailRow(k.replace(/_/g, ' '), typeof v === 'object' ? JSON.stringify(v) : String(v)))
|
||||||
|
: modal.runMessage?.extras ?? [];
|
||||||
|
|
||||||
|
return el('section', { id: 'detail-message' },
|
||||||
|
el('h3', { class: 'detail-section-title' }, heading),
|
||||||
|
el('pre', { class: `message message--${status}` }, text || 'No message reported.'),
|
||||||
|
extras.length ? el('h3', { class: 'detail-section-title' }, 'Additional details') : null,
|
||||||
|
extras.length ? el('dl', { class: 'detail-grid' }, extras) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModal({ loading = false, error = null } = {}) {
|
||||||
|
dom.detailBody.replaceChildren(...[
|
||||||
|
renderSummarySection(),
|
||||||
|
loading ? el('p', { class: 'muted' }, 'Loading full details…') : null,
|
||||||
|
error ? el('p', { class: 'banner banner--error' }, `Couldn't load full details: ${error}`) : null,
|
||||||
|
renderMessageSection(),
|
||||||
|
renderHistorySection(),
|
||||||
|
].filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render just the history section, keeping the runs table's scroll position
|
||||||
|
function renderHistory() {
|
||||||
|
const wrap = dom.detailBody.querySelector('.runs-wrap');
|
||||||
|
const scrollTop = wrap?.scrollTop ?? 0;
|
||||||
|
dom.detailBody.querySelector('#detail-history')?.replaceWith(renderHistorySection());
|
||||||
|
const newWrap = dom.detailBody.querySelector('.runs-wrap');
|
||||||
|
if (newWrap) newWrap.scrollTop = scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessage() {
|
||||||
|
dom.detailBody.querySelector('#detail-message')?.replaceWith(renderMessageSection());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(jobId) {
|
||||||
|
const job = state.jobs.find((j) => j.id === jobId);
|
||||||
|
if (!job) return;
|
||||||
|
const reqId = ++modal.reqId;
|
||||||
|
Object.assign(modal, {
|
||||||
|
job, detail: null, runs: null, runsError: null, dayFilter: null,
|
||||||
|
selectedRunId: null, runMessage: null, runMessageError: null,
|
||||||
|
});
|
||||||
|
renderModal({ loading: true }); // show what we have immediately
|
||||||
|
if (!dom.dialog.open) dom.dialog.showModal();
|
||||||
|
|
||||||
|
const current = () => reqId === modal.reqId && dom.dialog.open;
|
||||||
|
const detailP = api.getJob(jobId).then(
|
||||||
|
(detail) => { if (current()) { modal.detail = detail; renderModal(); } },
|
||||||
|
(err) => { if (current()) renderModal({ error: err.message }); },
|
||||||
|
);
|
||||||
|
const runsP = api.listRuns(jobId, CONFIG.historyDays).then(
|
||||||
|
(data) => {
|
||||||
|
if (!current()) return;
|
||||||
|
modal.runs = (data.runs ?? []).map(normalizeRun).filter((r) => r.startedAt);
|
||||||
|
renderHistory();
|
||||||
|
},
|
||||||
|
(err) => { if (current()) { modal.runsError = err.message; renderHistory(); } },
|
||||||
|
);
|
||||||
|
await Promise.all([detailP, runsP]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectRun(runId) {
|
||||||
|
const reqId = modal.reqId;
|
||||||
|
modal.selectedRunId = runId;
|
||||||
|
modal.runMessage = null;
|
||||||
|
modal.runMessageError = null;
|
||||||
|
renderHistory();
|
||||||
|
renderMessage();
|
||||||
|
try {
|
||||||
|
const run = await api.getRun(runId);
|
||||||
|
if (reqId !== modal.reqId || modal.selectedRunId !== runId) return;
|
||||||
|
modal.runMessage = {
|
||||||
|
text: run.message,
|
||||||
|
extras: Object.entries(run.details ?? {}).flatMap(([k, v]) =>
|
||||||
|
detailRow(k.replace(/_/g, ' '), typeof v === 'object' ? JSON.stringify(v) : String(v))),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (reqId !== modal.reqId || modal.selectedRunId !== runId) return;
|
||||||
|
modal.runMessageError = err.message;
|
||||||
|
}
|
||||||
|
renderMessage();
|
||||||
|
dom.detailBody.querySelector('#detail-message')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Events ----------
|
||||||
|
dom.tbody.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button[data-job-id]');
|
||||||
|
if (btn) openDetail(btn.dataset.jobId);
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.dialog.addEventListener('click', (e) => {
|
||||||
|
// Click on the backdrop (the dialog element itself, outside its content) or the close button
|
||||||
|
if (e.target === dom.dialog || e.target.closest('[data-close]')) dom.dialog.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.detailBody.addEventListener('click', (e) => {
|
||||||
|
const day = e.target.closest('button[data-day]');
|
||||||
|
if (day) {
|
||||||
|
modal.dayFilter = modal.dayFilter === day.dataset.day ? null : day.dataset.day;
|
||||||
|
renderHistory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const run = e.target.closest('button[data-run-id]');
|
||||||
|
if (run) selectRun(run.dataset.runId);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('.summary').addEventListener('click', (e) => {
|
||||||
|
const card = e.target.closest('.summary-card');
|
||||||
|
if (!card) return;
|
||||||
|
state.filter = card.dataset.filter;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('.jobs thead').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.sort');
|
||||||
|
if (!btn) return;
|
||||||
|
const key = btn.dataset.sort;
|
||||||
|
state.sort = state.sort.key === key ? { key, dir: -state.sort.dir } : { key, dir: 1 };
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.search.addEventListener('input', () => {
|
||||||
|
state.search = dom.search.value;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
|
||||||
|
|
||||||
|
// ---------- Boot ----------
|
||||||
|
refresh().then(schedulePoll);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Central runtime config. Kept as a static file so ops can change it without a build.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
export const CONFIG = Object.freeze({
|
||||||
|
apiBaseUrl: '/api/v1',
|
||||||
|
refreshIntervalMs: 60_000,
|
||||||
|
requestTimeoutMs: 15_000,
|
||||||
|
// A job whose next scheduled run is this far in the past is flagged "Overdue".
|
||||||
|
// Catches the silent failure mode: a job stops reporting and stays green forever.
|
||||||
|
overdueGraceMinutes: 30,
|
||||||
|
// Run history shown in the job modal (the API caps this at 30)
|
||||||
|
historyDays: 30,
|
||||||
|
// ?mock=1 → use in-browser sample data, no backend needed
|
||||||
|
useMock: params.has('mock'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const dateTimeFmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
const timeFmt = new Intl.DateTimeFormat(undefined, { timeStyle: 'medium' });
|
||||||
|
const relFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||||
|
|
||||||
|
const REL_UNITS = [
|
||||||
|
['year', 31_536_000], ['month', 2_592_000], ['week', 604_800],
|
||||||
|
['day', 86_400], ['hour', 3_600], ['minute', 60],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function parseDate(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDateTime = (d) => (d ? dateTimeFmt.format(d) : '—');
|
||||||
|
export const formatTime = (d) => timeFmt.format(d);
|
||||||
|
|
||||||
|
export function formatRelative(d, now = Date.now()) {
|
||||||
|
if (!d) return '';
|
||||||
|
const diffSec = Math.round((d.getTime() - now) / 1000);
|
||||||
|
for (const [unit, secs] of REL_UNITS) {
|
||||||
|
if (Math.abs(diffSec) >= secs) return relFmt.format(Math.round(diffSec / secs), unit);
|
||||||
|
}
|
||||||
|
return relFmt.format(diffSec, 'second');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(totalSec) {
|
||||||
|
if (totalSec == null || Number.isNaN(Number(totalSec))) return null;
|
||||||
|
const s = Math.max(0, Math.round(totalSec));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
return [h && `${h}h`, (h || m) && `${m}m`, `${sec}s`].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes) {
|
||||||
|
if (bytes == null || Number.isNaN(Number(bytes))) return null;
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
let n = Number(bytes);
|
||||||
|
let i = 0;
|
||||||
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||||||
|
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Sample data matching the API contract. Timestamps are relative to page load so it always looks "live".
|
||||||
|
const now = Date.now();
|
||||||
|
const MIN = 60_000;
|
||||||
|
const HR = 60 * MIN;
|
||||||
|
const DAY = 24 * HR;
|
||||||
|
const iso = (offsetMs) => new Date(now + offsetMs).toISOString();
|
||||||
|
|
||||||
|
// every_h drives the generated run history; it isn't part of the API.
|
||||||
|
const JOBS = [
|
||||||
|
{
|
||||||
|
id: 1, company_name: 'Acme Corp', job_name: 'File Server – Nightly', status: 'success', every_h: 24,
|
||||||
|
last_run_at: iso(-6 * HR), next_run_at: iso(18 * HR),
|
||||||
|
message: 'Job completed successfully. 1,284 files processed.',
|
||||||
|
source_system: 'Veeam B&R', duration_seconds: 2840, size_bytes: 128_849_018_880,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2, company_name: 'Acme Corp', job_name: 'SQL01 – Transaction Logs', status: 'warning', every_h: 1,
|
||||||
|
last_run_at: iso(-25 * MIN), next_run_at: iso(35 * MIN),
|
||||||
|
message: 'Processing SQL01 Warning:\nFailed to truncate transaction logs for database "ERP_PROD".\nLog backup completed but truncation was skipped.',
|
||||||
|
source_system: 'Veeam B&R', duration_seconds: 312, size_bytes: 2_147_483_648,
|
||||||
|
details: { retry_count: 0, repository: 'REPO-ONSITE-01' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Running now; "last run" is still the previous completion
|
||||||
|
id: 7, company_name: 'Acme Corp', job_name: 'Hyper-V Hosts – Daily', status: 'running', every_h: 24,
|
||||||
|
running_since: iso(-12 * MIN), last_status: 'success',
|
||||||
|
last_run_at: iso(-DAY + 40 * MIN), next_run_at: iso(DAY - 12 * MIN),
|
||||||
|
message: 'Completed successfully. 6 VMs processed.',
|
||||||
|
source_system: 'Veeam B&R', duration_seconds: 3120, size_bytes: 412_316_860_416,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3, company_name: 'Bluewater Dental', job_name: 'Workstations – Daily', status: 'failed', every_h: 24,
|
||||||
|
last_run_at: iso(-3 * HR), next_run_at: iso(21 * HR),
|
||||||
|
message: 'Error: Unable to connect to backup repository \\\\NAS02\\Backups.\nThe network path was not found. (0x80070035)\nJob terminated after 3 retries.',
|
||||||
|
source_system: 'Datto', duration_seconds: 95,
|
||||||
|
details: { retry_count: 3, target: '\\\\NAS02\\Backups' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4, company_name: 'Bluewater Dental', job_name: 'Imaging Server – Hourly', status: 'success', every_h: 1,
|
||||||
|
last_run_at: iso(-50 * MIN), next_run_at: iso(10 * MIN),
|
||||||
|
message: 'Completed.', source_system: 'Datto', duration_seconds: 420, size_bytes: 8_589_934_592,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Green status but next run is in the past → should show as Overdue
|
||||||
|
id: 5, company_name: 'Northfield Logistics', job_name: 'Exchange – Nightly', status: 'success', every_h: 24,
|
||||||
|
last_run_at: iso(-30 * HR), next_run_at: iso(-6 * HR),
|
||||||
|
message: 'Completed successfully.', source_system: 'Acronis', duration_seconds: 5400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Never run / unrecognised status → rendered as Unknown
|
||||||
|
id: 6, company_name: 'Northfield Logistics', job_name: 'New NAS – Weekly', status: null,
|
||||||
|
last_run_at: null, next_run_at: iso(2 * DAY),
|
||||||
|
message: '', source_system: 'Synology Hyper Backup',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MESSAGES = {
|
||||||
|
success: 'Job completed successfully.',
|
||||||
|
warning: 'Completed with warnings: 1 file skipped (file in use).',
|
||||||
|
failed: 'Error: Target repository unreachable. Job terminated.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so history doesn't reshuffle on every modal open
|
||||||
|
function rng(seed) {
|
||||||
|
let x = seed;
|
||||||
|
return () => ((x = (x * 1_103_515_245 + 12_345) % 2_147_483_648) / 2_147_483_648);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUNS = new Map(); // job id → runs, newest first
|
||||||
|
for (const job of JOBS) {
|
||||||
|
const runs = [];
|
||||||
|
if (job.running_since) {
|
||||||
|
runs.push({ id: `${job.id}-r`, status: 'running', started_at: job.running_since, finished_at: null, message: 'In progress…' });
|
||||||
|
}
|
||||||
|
if (job.last_run_at && job.every_h) {
|
||||||
|
const rand = rng(job.id * 7919);
|
||||||
|
const dur = (job.duration_seconds ?? 600) * 1000;
|
||||||
|
let end = Date.parse(job.last_run_at);
|
||||||
|
for (let i = 0; end - dur > now - 30 * DAY; i++, end -= job.every_h * HR) {
|
||||||
|
const r = rand();
|
||||||
|
const status = i === 0 ? (job.last_status ?? job.status) : r < 0.03 ? 'failed' : r < 0.1 ? 'warning' : 'success';
|
||||||
|
const thisDur = dur * (0.8 + rand() * 0.4);
|
||||||
|
runs.push({
|
||||||
|
id: `${job.id}-${i}`,
|
||||||
|
status,
|
||||||
|
started_at: new Date(end - thisDur).toISOString(),
|
||||||
|
finished_at: new Date(end).toISOString(),
|
||||||
|
duration_seconds: Math.round(thisDur / 1000),
|
||||||
|
size_bytes: status === 'failed' ? null : job.size_bytes,
|
||||||
|
message: i === 0 ? job.message : MESSAGES[status],
|
||||||
|
details: i === 0 ? job.details ?? {} : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RUNS.set(String(job.id), runs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const summary = ({ details, every_h, last_status, duration_seconds, size_bytes, ...rest }) => rest;
|
||||||
|
|
||||||
|
export const mockApi = {
|
||||||
|
async listJobs() {
|
||||||
|
await delay(250);
|
||||||
|
return { generated_at: new Date().toISOString(), jobs: JOBS.map(summary) };
|
||||||
|
},
|
||||||
|
async getJob(id) {
|
||||||
|
await delay(200);
|
||||||
|
const job = JOBS.find((j) => String(j.id) === String(id));
|
||||||
|
if (!job) throw new Error('Job not found');
|
||||||
|
const { every_h, ...rest } = job;
|
||||||
|
return { last_status: job.status === 'running' ? null : job.status, ...rest, details: job.details ?? {} };
|
||||||
|
},
|
||||||
|
async listRuns(id, days) {
|
||||||
|
await delay(300);
|
||||||
|
const since = now - days * DAY;
|
||||||
|
const runs = (RUNS.get(String(id)) ?? [])
|
||||||
|
.filter((r) => Date.parse(r.started_at) >= since)
|
||||||
|
.map(({ message, details, ...r }) => r);
|
||||||
|
return { job_id: id, since: new Date(since).toISOString(), runs };
|
||||||
|
},
|
||||||
|
async getRun(id) {
|
||||||
|
await delay(150);
|
||||||
|
for (const runs of RUNS.values()) {
|
||||||
|
const run = runs.find((r) => r.id === id);
|
||||||
|
if (run) return { details: {}, ...run };
|
||||||
|
}
|
||||||
|
throw new Error('Run not found');
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user