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:
@@ -422,3 +422,104 @@ code {
|
||||
.modal--small { width: min(460px, calc(100vw - 2rem)); }
|
||||
.modal--small .modal__body p { margin-top: 0; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
|
||||
|
||||
/* ---------- Report page ---------- */
|
||||
.report-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.report-controls__field { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.report-controls__field label { font-weight: 600; font-size: 0.9rem; }
|
||||
.report-controls__field .input { width: auto; min-width: 14rem; }
|
||||
.report-controls__actions { display: flex; gap: 0.5rem; margin-left: auto; }
|
||||
.btn[aria-disabled="true"] { opacity: 0.55; pointer-events: none; }
|
||||
|
||||
.report__header { margin-bottom: 1rem; }
|
||||
.report__title { margin: 0; font-size: 1.4rem; }
|
||||
.report__period { margin: 0.25rem 0 0; color: var(--muted); }
|
||||
.report__section-title { font-size: 1.05rem; margin: 1.75rem 0 0.75rem; }
|
||||
.report__footer { margin-top: 1.5rem; font-size: 0.85rem; }
|
||||
|
||||
.report-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
.report-summary__item {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.report-summary__item dt { font-size: 0.85rem; color: var(--muted); }
|
||||
.report-summary__item dd { margin: 0; font-size: 1.5rem; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.report-summary__item--warning { border-left-color: var(--warning-bd); }
|
||||
.report-summary__item--failed { border-left-color: var(--failed-bd); }
|
||||
|
||||
/* Row headers (job names) are <th> for accessibility, but shouldn't look like the column headings */
|
||||
.report-table tbody th {
|
||||
background: none; color: var(--text); font-size: inherit; font-weight: 600;
|
||||
text-transform: none; letter-spacing: normal;
|
||||
}
|
||||
.report-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.report-table td > .time__rel { display: block; }
|
||||
.report-table .badge { font-size: 0.75rem; padding: 0.1rem 0.5rem; }
|
||||
.report-alert { color: var(--failed-fg); font-weight: 600; }
|
||||
.report-message { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 20rem; font-size: 0.85rem; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
|
||||
.report-legend {
|
||||
display: flex; flex-wrap: wrap; gap: 0.25rem 1rem;
|
||||
margin: 0 0 0.75rem; padding: 0; list-style: none; font-size: 0.85rem; color: var(--muted);
|
||||
}
|
||||
.report-legend li { display: flex; align-items: center; gap: 0.35rem; }
|
||||
.report-legend .day { display: inline-block; width: 0.9rem; height: 0.9rem; }
|
||||
|
||||
.report-daily__job { margin-bottom: 0.75rem; }
|
||||
.report-daily__name { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem; }
|
||||
/* One cell per day of the report's period */
|
||||
.report-strip { grid-template-columns: repeat(var(--days, 30), 1fr); gap: 2px; }
|
||||
.report-strip .day { display: block; height: 18px; cursor: default; }
|
||||
/* Before the dashboard knew of the job: outlined only, so it doesn't read as a missed backup */
|
||||
.day--untracked { background: transparent; border: 1px dashed var(--muted); opacity: 0.5; }
|
||||
|
||||
/* ---------- Printing ---------- */
|
||||
@media print {
|
||||
/* Always print light, whatever the screen's theme (same values as the light tokens above) */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f0f2f5;
|
||||
--border: #c9ced6;
|
||||
--text: #1f2328;
|
||||
--muted: #57606a;
|
||||
--accent: #0969da;
|
||||
--success-fg: #116329; --success-bg: #dafbe1; --success-bd: #4ac26b;
|
||||
--warning-fg: #7d4e00; --warning-bg: #fff8c5; --warning-bd: #d4a72c;
|
||||
--failed-fg: #a40e26; --failed-bg: #ffebe9; --failed-bd: #ff8182;
|
||||
--unknown-fg: #57606a; --unknown-bg: #eaeef2; --unknown-bd: #afb8c1;
|
||||
--running-fg: #0550ae; --running-bg: #ddf4ff; --running-bd: #54aeff;
|
||||
--shadow: none;
|
||||
font-size: 11pt;
|
||||
}
|
||||
@page { margin: 14mm; }
|
||||
body { background: #fff; color: var(--text); }
|
||||
.no-print, .topbar, .banner { display: none !important; }
|
||||
.container { max-width: none; padding: 0; }
|
||||
.table-wrap { overflow: visible; box-shadow: none; }
|
||||
.jobs th, .jobs td { white-space: normal; padding: 0.35rem 0.5rem; }
|
||||
.jobs tbody tr:hover { background: none; }
|
||||
.report-message { min-width: 0; }
|
||||
/* Keep things whole across page breaks */
|
||||
tr, .report-daily__job, .report-summary__item, .report-legend { break-inside: avoid; }
|
||||
.report__section-title { break-after: avoid; }
|
||||
thead { display: table-header-group; } /* repeat column headings on each page */
|
||||
/* Browsers drop backgrounds when printing unless told they carry meaning */
|
||||
.day, .badge, .report-summary__item { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<div class="topbar__meta">
|
||||
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
|
||||
<button id="refresh-btn" class="btn" type="button">Refresh</button>
|
||||
<a id="reports-link" class="btn" href="/report.html">Reports</a>
|
||||
<a id="admin-link" class="btn" href="/admin.html" hidden>Admin</a>
|
||||
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
|
||||
<span id="signed-in" class="topbar__user" hidden>
|
||||
|
||||
@@ -77,6 +77,21 @@ const httpApi = {
|
||||
return request('/me');
|
||||
},
|
||||
|
||||
// ----- Reports -----
|
||||
/** GET /tenants → { tenants: [{ name, jobs }] } */
|
||||
listTenants() {
|
||||
return request('/tenants');
|
||||
},
|
||||
/** GET /reports/tenant → { tenant, days, since, until, generated_at, jobs: [job + runs] } */
|
||||
tenantReport(tenant, days) {
|
||||
return request(`/reports/tenant?tenant=${encodeURIComponent(tenant)}&days=${encodeURIComponent(days)}`);
|
||||
},
|
||||
/** Link to the CSV of every run in the period, with times in time zone `tz` */
|
||||
async tenantReportCsvUrl(tenant, days, tz) {
|
||||
const params = new URLSearchParams({ tenant, days: String(days), tz });
|
||||
return `${CONFIG.apiBaseUrl}/reports/tenant.csv?${params}`;
|
||||
},
|
||||
|
||||
// ----- Admin (403 for non-admins) -----
|
||||
/** GET /admin/keys → { keys: ApiKey[] } (active first) */
|
||||
listKeys() {
|
||||
|
||||
+15
-74
@@ -2,6 +2,10 @@ import { CONFIG } from './config.js';
|
||||
import { getApi } from './api.js';
|
||||
import { el } from './dom.js';
|
||||
import { showSignedInUser } from './signed-in.js';
|
||||
import { STATUS, normalizeStatus, statusBadge } from './status.js';
|
||||
import {
|
||||
localDayKey, dayLabelFmt, dayTickFmt, normalizeRun, buildDays, describeCounts,
|
||||
} from './history.js';
|
||||
import {
|
||||
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
|
||||
} from './format.js';
|
||||
@@ -9,20 +13,6 @@ import {
|
||||
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 {
|
||||
@@ -62,6 +52,7 @@ const dom = {
|
||||
signedInEmail: $('#signed-in-email'),
|
||||
signOut: $('#sign-out'),
|
||||
adminLink: $('#admin-link'),
|
||||
reportsLink: $('#reports-link'),
|
||||
lastUpdated: $('#last-updated'),
|
||||
errorBanner: $('#error-banner'),
|
||||
dialog: $('#detail-dialog'),
|
||||
@@ -71,20 +62,6 @@ const dom = {
|
||||
};
|
||||
|
||||
// ---------- 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);
|
||||
|
||||
@@ -231,51 +208,6 @@ const modal = {
|
||||
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)];
|
||||
@@ -288,7 +220,8 @@ function renderSummarySection() {
|
||||
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;
|
||||
const company = detail?.company_name ?? job.company;
|
||||
dom.detailSubtitle.replaceChildren(el('a', { href: reportUrl(company), title: `Backup report for ${company}` }, company));
|
||||
|
||||
return el('section', {},
|
||||
el('div', { class: 'detail-status' },
|
||||
@@ -517,7 +450,15 @@ dom.search.addEventListener('input', () => {
|
||||
|
||||
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
|
||||
|
||||
// A company's report page (keeps ?mock=1)
|
||||
function reportUrl(company) {
|
||||
const params = new URLSearchParams({ tenant: company, days: '30' });
|
||||
if (CONFIG.useMock) params.set('mock', '1');
|
||||
return `/report.html?${params}`;
|
||||
}
|
||||
|
||||
// ---------- Boot ----------
|
||||
dom.reportsLink.href = `/report.html${CONFIG.useMock ? '?mock=1' : ''}`;
|
||||
api.getMe().then(
|
||||
(me) => showSignedInUser(me, dom),
|
||||
(err) => console.error(err), // not worth a banner: the jobs request will surface real problems
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Run history: runs grouped into days (local time), as shown in the dashboard's job detail and in reports.
|
||||
import { parseDate } from './format.js';
|
||||
import { STATUS, normalizeStatus } from './status.js';
|
||||
|
||||
// Colour of a day cell: the worst thing that happened that day
|
||||
export const DAY_SEVERITY = ['failed', 'warning', 'unknown', 'running', 'success'];
|
||||
|
||||
const pad2 = (n) => String(n).padStart(2, '0');
|
||||
export const localDayKey = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
export const dayLabelFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
export const dayTickFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function describeCounts(counts) {
|
||||
return DAY_SEVERITY.filter((s) => counts[s])
|
||||
.map((s) => `${counts[s]} ${STATUS[s].label.toLowerCase()}`)
|
||||
.join(', ');
|
||||
}
|
||||
@@ -143,6 +143,46 @@ export const mockApi = {
|
||||
return { email: null, name: null, is_admin: true };
|
||||
},
|
||||
|
||||
async listTenants() {
|
||||
await delay(150);
|
||||
const counts = new Map();
|
||||
for (const j of JOBS) counts.set(j.company_name, (counts.get(j.company_name) ?? 0) + 1);
|
||||
return { tenants: [...counts].map(([name, jobs]) => ({ name, jobs })).sort((a, b) => a.name.localeCompare(b.name)) };
|
||||
},
|
||||
async tenantReport(tenant, days) {
|
||||
await delay(250);
|
||||
const jobs = JOBS.filter((j) => j.company_name.toLowerCase() === tenant.toLowerCase());
|
||||
if (!jobs.length) throw new Error(`No company named '${tenant}'`);
|
||||
const since = now - days * DAY;
|
||||
return {
|
||||
tenant: jobs[0].company_name, days,
|
||||
since: new Date(since).toISOString(), until: new Date(now).toISOString(), generated_at: new Date(now).toISOString(),
|
||||
jobs: jobs.map(({ details, every_h, ...job }) => ({
|
||||
...job,
|
||||
// Earliest run on record, as the API reports it
|
||||
tracked_since: (RUNS.get(String(job.id)) ?? []).map((r) => r.started_at).sort()[0] ?? new Date(now).toISOString(),
|
||||
runs: (RUNS.get(String(job.id)) ?? [])
|
||||
.filter((r) => Date.parse(r.started_at) >= since)
|
||||
.map(({ id, details: _d, message, ...r }) => ({ ...r, message: ['warning', 'failed'].includes(r.status) ? message : null }))
|
||||
.reverse(), // oldest first, like the API
|
||||
})),
|
||||
};
|
||||
},
|
||||
// No server in mock mode: build the CSV here and link to it as a blob
|
||||
async tenantReportCsvUrl(tenant, days, tz) {
|
||||
const report = await this.tenantReport(tenant, days);
|
||||
const quoteCell = (v) => `"${String(v ?? '').replaceAll('"', '""')}"`;
|
||||
// Times stay in UTC here (the real API converts them to `tz`)
|
||||
const rows = [['Company', 'Job', 'Source', 'Started (UTC)', 'Finished (UTC)', 'Status', 'Message']];
|
||||
for (const job of report.jobs) {
|
||||
for (const run of job.runs) {
|
||||
rows.push([report.tenant, job.job_name, job.source_system, run.started_at, run.finished_at ?? '', run.status, run.message ?? '']);
|
||||
}
|
||||
}
|
||||
const csv = rows.map((r) => r.map(quoteCell).join(',')).join('\r\n');
|
||||
return URL.createObjectURL(new Blob(['\ufeff' + csv], { type: 'text/csv' }));
|
||||
},
|
||||
|
||||
async listKeys() {
|
||||
await delay(200);
|
||||
return { keys: [...KEYS].sort((a, b) => (!!a.revoked_at - !!b.revoked_at) || a.name.localeCompare(b.name)) };
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,32 @@
|
||||
// Run/job statuses: labels, icons, sort order, and the badge that shows one.
|
||||
import { el } from './dom.js';
|
||||
|
||||
// rank drives default sort: worst first
|
||||
export 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
|
||||
export function normalizeStatus(raw) {
|
||||
const s = String(raw ?? '').toLowerCase();
|
||||
return Object.hasOwn(STATUS, s) ? s : 'unknown';
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Backup report · Backup Monitor</title>
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<script type="module" src="/js/report.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar no-print">
|
||||
<div class="topbar__nav">
|
||||
<a id="back-link" class="btn" href="/">← Dashboard</a>
|
||||
<h1 class="topbar__title">Reports</h1>
|
||||
</div>
|
||||
<div class="topbar__meta">
|
||||
<span id="signed-in" class="topbar__user" hidden>
|
||||
<span id="signed-in-email" class="muted"></span>
|
||||
<a id="sign-out" class="btn" href="#">Sign out</a>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container container--narrow">
|
||||
<form id="report-form" class="report-controls no-print">
|
||||
<div class="report-controls__field">
|
||||
<label for="tenant">Company</label>
|
||||
<select id="tenant" name="tenant" class="input" required>
|
||||
<option value="">Choose a company…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="report-controls__field">
|
||||
<label for="days">Period</label>
|
||||
<select id="days" name="days" class="input">
|
||||
<option value="7">Last 7 days</option>
|
||||
<option value="30" selected>Last 30 days</option>
|
||||
<option value="90">Last 90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="report-controls__actions">
|
||||
<button id="print-btn" class="btn btn--primary" type="button" disabled>Print / Save as PDF</button>
|
||||
<a id="csv-link" class="btn" aria-disabled="true">Download CSV</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="error-banner" class="banner banner--error" role="alert" hidden></div>
|
||||
<p id="choose-hint" class="empty no-print">Choose a company to see its backup report.</p>
|
||||
<p id="loading" class="muted no-print" hidden>Loading report…</p>
|
||||
|
||||
<article id="report" class="report" aria-labelledby="report-title" hidden>
|
||||
<header class="report__header">
|
||||
<h2 id="report-title" class="report__title"></h2>
|
||||
<p id="report-period" class="report__period"></p>
|
||||
</header>
|
||||
|
||||
<section aria-label="Summary">
|
||||
<dl id="report-summary" class="report-summary"></dl>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="jobs-heading">
|
||||
<h3 id="jobs-heading" class="report__section-title">Jobs</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="jobs report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Job</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col" class="num">Runs</th>
|
||||
<th scope="col" class="num">Successful</th>
|
||||
<th scope="col" class="num">Warnings</th>
|
||||
<th scope="col" class="num">Failed</th>
|
||||
<th scope="col" class="num">Success rate</th>
|
||||
<th scope="col">Last result</th>
|
||||
<th scope="col">Last successful backup</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobs-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="daily-heading">
|
||||
<h3 id="daily-heading" class="report__section-title">Day by day</h3>
|
||||
<ul class="report-legend" aria-label="Legend">
|
||||
<li><span class="day day--success" aria-hidden="true"></span>Successful</li>
|
||||
<li><span class="day day--warning" aria-hidden="true"></span>Warning</li>
|
||||
<li><span class="day day--failed" aria-hidden="true"></span>Failed</li>
|
||||
<li><span class="day day--running" aria-hidden="true"></span>In progress</li>
|
||||
<li><span class="day day--none" aria-hidden="true"></span>No backup</li>
|
||||
<li><span class="day day--untracked" aria-hidden="true"></span>Not monitored yet</li>
|
||||
</ul>
|
||||
<div id="daily" class="report-daily"></div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="issues-heading">
|
||||
<h3 id="issues-heading" class="report__section-title">Warnings and failures</h3>
|
||||
<p id="issues-none" class="muted" hidden>None in this period.</p>
|
||||
<div id="issues-wrap" class="table-wrap">
|
||||
<table class="jobs report-table report-issues">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">When</th>
|
||||
<th scope="col">Job</th>
|
||||
<th scope="col">Result</th>
|
||||
<th scope="col">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="issues-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer id="report-footer" class="report__footer muted"></footer>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user