import { CONFIG } from './config.js'; import { getApi } from './api.js'; import { el } from './dom.js'; import { showSignedInUser } from './signed-in.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'), signedIn: $('#signed-in'), signedInEmail: $('#signed-in-email'), signOut: $('#sign-out'), adminLink: $('#admin-link'), lastUpdated: $('#last-updated'), errorBanner: $('#error-banner'), dialog: $('#detail-dialog'), detailTitle: $('#detail-title'), detailSubtitle: $('#detail-subtitle'), detailBody: $('#detail-body'), }; // ---------- 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, emptyLabel = 'Never' } = {}) { if (!date) return el('td', {}, el('span', { class: 'muted' }, emptyLabel)); 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), // No next run: unscheduled, or e.g. a Veeam copy job in immediate mode timeCell(job.nextRun, { extra: overdueTag, emptyLabel: 'Not scheduled' })); } 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, 'Not scheduled')), 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 ---------- api.getMe().then( (me) => showSignedInUser(me, dom), (err) => console.error(err), // not worth a banner: the jobs request will surface real problems ); refresh().then(schedulePoll);