Add per-company backup reports (printable page and CSV)

/report.html shows one company's backups over the last 7, 30 or 90
days, chosen in the page and kept in the URL so reports can be
bookmarked or linked. Reached from Reports in the top bar, or a
company's name in a job's detail.

- Summary (jobs, runs, success rate, warnings, failures, overdue now);
  per job: run counts, success rate (successful / finished runs), last
  result and last successful backup; a day-by-day strip; every warning
  and failure with its message
- Print / Save as PDF: a print stylesheet prints light whatever the
  screen theme, hides the controls, keeps rows and strips whole across
  pages, and keeps the coloured cells and badges
- Days before a job was first known show as "Not monitored yet"
  rather than as missed backups, so a 90-day report for a newly added
  client doesn't look like 60 days of failures
- CSV of every run (full messages, times in the browser's time zone,
  UTF-8 BOM for Excel, formula-like values defused with a leading ')

Backend: GET /api/v1/tenants, /api/v1/reports/tenant and
/api/v1/reports/tenant.csv; periods capped at retention. tzdata added
for systems without time zone data. Frontend status and day-strip
helpers moved out of app.js into status.js / history.js for reuse.

Tested: 9 new backend tests; in the browser against the API with 30
days of demo history (summary, issues, strips, 7/30/90 switching,
CSV download with BOM, print styles applied on screen), mock mode and
phone width.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-08 14:56:54 -06:00
co-authored by Claude Opus 5.5
parent abb684ff0d
commit 2297187f88
15 changed files with 925 additions and 75 deletions
+248
View File
@@ -0,0 +1,248 @@
// 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();