// Per-company backup report: summary, per-job results, day-by-day strips and every warning and // failure for a period. Printable (Print / Save as PDF) and downloadable as CSV. import { CONFIG } from './config.js'; import { getApi } from './api.js'; import { el } from './dom.js'; import { parseDate, formatDateTime } from './format.js'; import { buildDays, describeCounts, dayLabelFmt, normalizeRun } from './history.js'; import { showSignedInUser } from './signed-in.js'; import { normalizeStatus, statusBadge } from './status.js'; const api = await getApi(); // Report messages are trimmed to keep a printed report readable; the CSV has them in full const MAX_MESSAGE_CHARS = 400; const TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; const dateFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' }); const percentFmt = new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 1 }); const $ = (sel) => document.querySelector(sel); const dom = { backLink: $('#back-link'), signedIn: $('#signed-in'), signedInEmail: $('#signed-in-email'), signOut: $('#sign-out'), tenant: $('#tenant'), days: $('#days'), printBtn: $('#print-btn'), csvLink: $('#csv-link'), errorBanner: $('#error-banner'), chooseHint: $('#choose-hint'), loading: $('#loading'), report: $('#report'), title: $('#report-title'), period: $('#report-period'), summary: $('#report-summary'), jobsBody: $('#jobs-body'), daily: $('#daily'), issuesBody: $('#issues-body'), issuesWrap: $('#issues-wrap'), issuesNone: $('#issues-none'), footer: $('#report-footer'), }; let requestId = 0; // a slow response for one company must not overwrite a later one // ---------- Numbers ---------- function summarize(runs) { const counts = { success: 0, warning: 0, failed: 0, running: 0, unknown: 0 }; for (const run of runs) counts[run.status]++; const finished = runs.length - counts.running; const lastFinished = runs.filter((r) => r.finishedAt).at(-1) ?? null; const lastSuccess = runs.filter((r) => r.status === 'success').at(-1) ?? null; return { counts, total: runs.length, finished, // Of finished runs only: one still in progress hasn't succeeded or failed yet rate: finished ? counts.success / finished : null, lastFinished, lastSuccess, }; } const isOverdue = (job) => { const next = parseDate(job.next_run_at); return normalizeStatus(job.status) !== 'running' && !!next && next.getTime() + CONFIG.overdueGraceMinutes * 60_000 < Date.now(); }; // ---------- Rendering ---------- function renderSummary(jobs) { const all = summarize(jobs.flatMap((j) => j.runs)); const overdue = jobs.filter((j) => isOverdue(j.job)).length; const item = (label, value, modifier = '') => el('div', { class: `report-summary__item ${modifier}` }, el('dt', {}, label), el('dd', {}, value)); dom.summary.replaceChildren( item('Jobs', String(jobs.length)), item('Backup runs', String(all.total)), item('Success rate', all.rate === null ? '—' : percentFmt.format(all.rate)), item('Warnings', String(all.counts.warning), all.counts.warning ? 'report-summary__item--warning' : ''), item('Failures', String(all.counts.failed), all.counts.failed ? 'report-summary__item--failed' : ''), item('Overdue now', String(overdue), overdue ? 'report-summary__item--failed' : ''), ); } function renderJobs(jobs, days) { dom.jobsBody.replaceChildren(...jobs.map(({ job, runs, stats }) => { const last = stats.lastFinished; return el('tr', {}, el('th', { scope: 'row' }, job.job_name), el('td', {}, job.source_system), el('td', { class: 'num' }, String(stats.total), stats.counts.running ? el('span', { class: 'time__rel' }, `${stats.counts.running} in progress`) : null), el('td', { class: 'num' }, String(stats.counts.success)), el('td', { class: 'num' }, String(stats.counts.warning)), el('td', { class: 'num' }, String(stats.counts.failed)), el('td', { class: 'num' }, stats.rate === null ? '—' : percentFmt.format(stats.rate)), el('td', {}, last ? [statusBadge(last.status), el('span', { class: 'time__rel' }, formatDateTime(last.finishedAt))] : el('span', { class: 'muted' }, 'No finished runs')), el('td', {}, stats.lastSuccess ? formatDateTime(stats.lastSuccess.finishedAt) : el('span', { class: 'report-alert' }, `None in the last ${days} days`))); })); } function renderDaily(jobs, days) { dom.daily.replaceChildren(...jobs.map(({ job, runs }) => { const cells = buildDays(runs, days); // Days before the dashboard knew of the job aren't missed backups, just unmonitored ones const tracked = parseDate(job.tracked_since); if (tracked) { const firstDay = new Date(tracked); firstDay.setHours(0, 0, 0, 0); for (const d of cells) if (d.date < firstDay && !d.total) d.status = 'untracked'; } const tally = {}; for (const d of cells) tally[d.status] = (tally[d.status] ?? 0) + 1; const description = `${job.job_name}, ${days} days: ${tally.success ?? 0} successful, ${tally.warning ?? 0} with warnings, ` + `${tally.failed ?? 0} failed, ${tally.none ?? 0} without a backup` + (tally.untracked ? `, ${tally.untracked} before monitoring began` : ''); return el('div', { class: 'report-daily__job' }, el('div', { class: 'report-daily__name' }, job.job_name), // Coloured cells are visual only; the label gives screen readers the same picture el('div', { class: 'day-strip report-strip', role: 'img', 'aria-label': description, style: `--days: ${days}` }, cells.map((d) => el('span', { class: `day day--${d.status}`, title: `${dayLabelFmt.format(d.date)}: ${d.total ? describeCounts(d.counts) : d.status === 'untracked' ? 'not monitored yet' : 'no backup'}`, })))); })); } function renderIssues(jobs) { const issues = jobs .flatMap(({ job, runs }) => runs .filter((r) => r.status === 'warning' || r.status === 'failed') .map((r) => ({ job, run: r }))) .sort((a, b) => b.run.startedAt - a.run.startedAt); // newest first dom.issuesBody.replaceChildren(...issues.map(({ job, run }) => { const message = (run.message ?? '').trim() || 'No details reported.'; const shown = message.length > MAX_MESSAGE_CHARS ? `${message.slice(0, MAX_MESSAGE_CHARS)}…` : message; return el('tr', {}, el('td', { class: 'nowrap' }, formatDateTime(run.startedAt)), el('th', { scope: 'row' }, job.job_name), el('td', {}, statusBadge(run.status)), el('td', { class: 'report-message' }, shown)); })); dom.issuesNone.hidden = issues.length > 0; dom.issuesWrap.hidden = issues.length === 0; } function render(report) { const days = report.days; const jobs = report.jobs.map((job) => { const runs = job.runs.map((r) => ({ ...normalizeRun({ id: '', ...r }), message: r.message })) .filter((r) => r.startedAt); return { job, runs, stats: summarize(runs) }; }); const since = parseDate(report.since); const until = parseDate(report.until); dom.title.textContent = `Backup report: ${report.tenant}`; dom.period.textContent = `${dateFmt.format(since)} – ${dateFmt.format(until)} (last ${days} days)`; document.title = `Backup report: ${report.tenant} · Backup Monitor`; renderSummary(jobs); renderJobs(jobs, days); renderDaily(jobs, days); renderIssues(jobs); dom.footer.textContent = `Generated ${formatDateTime(parseDate(report.generated_at))}. Times are in ${TIME_ZONE}. ` + 'Success rate counts finished runs only. The CSV export lists every run with its full details.'; dom.report.hidden = false; } // ---------- Loading ---------- function setActionsEnabled(enabled) { dom.printBtn.disabled = !enabled; if (!enabled) { dom.csvLink.removeAttribute('href'); dom.csvLink.setAttribute('aria-disabled', 'true'); } } async function load() { const tenant = dom.tenant.value; const days = Number(dom.days.value); // Shareable, bookmarkable URL (keeps ?mock=1) const params = new URLSearchParams(location.search); if (tenant) params.set('tenant', tenant); else params.delete('tenant'); params.set('days', String(days)); history.replaceState(null, '', `?${params}`); dom.errorBanner.hidden = true; setActionsEnabled(false); if (!tenant) { dom.report.hidden = true; dom.chooseHint.hidden = false; return; } dom.chooseHint.hidden = true; dom.loading.hidden = false; const id = ++requestId; try { const report = await api.tenantReport(tenant, days); if (id !== requestId) return; render(report); dom.csvLink.href = await api.tenantReportCsvUrl(report.tenant, days, TIME_ZONE); dom.csvLink.download = `backup-report-${report.tenant.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.csv`; dom.csvLink.removeAttribute('aria-disabled'); dom.printBtn.disabled = false; } catch (err) { if (id !== requestId) return; dom.report.hidden = true; dom.errorBanner.textContent = `Couldn't load the report: ${err.message}`; dom.errorBanner.hidden = false; } finally { if (id === requestId) dom.loading.hidden = true; } } dom.tenant.addEventListener('change', load); dom.days.addEventListener('change', load); dom.printBtn.addEventListener('click', () => window.print()); async function main() { dom.backLink.href = `/${location.search.includes('mock') ? '?mock=1' : ''}`; api.getMe().then((me) => showSignedInUser(me, dom), () => {}); const params = new URLSearchParams(location.search); const requestedDays = params.get('days'); if ([...dom.days.options].some((o) => o.value === requestedDays)) dom.days.value = requestedDays; try { const { tenants } = await api.listTenants(); dom.tenant.append(...tenants.map((t) => el('option', { value: t.name }, `${t.name} (${t.jobs} job${t.jobs === 1 ? '' : 's'})`))); } catch (err) { dom.errorBanner.textContent = `Couldn't load the companies: ${err.message}`; dom.errorBanner.hidden = false; return; } // ?tenant= from a link: match case-insensitively, as the API does const requested = (params.get('tenant') ?? '').toLowerCase(); const match = [...dom.tenant.options].find((o) => o.value && o.value.toLowerCase() === requested); if (match) dom.tenant.value = match.value; await load(); } main();