/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]>
466 lines
17 KiB
JavaScript
466 lines
17 KiB
JavaScript
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';
|
|
|
|
const api = await getApi();
|
|
|
|
// ---------- Domain ----------
|
|
|
|
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'),
|
|
reportsLink: $('#reports-link'),
|
|
lastUpdated: $('#last-updated'),
|
|
errorBanner: $('#error-banner'),
|
|
dialog: $('#detail-dialog'),
|
|
detailTitle: $('#detail-title'),
|
|
detailSubtitle: $('#detail-subtitle'),
|
|
detailBody: $('#detail-body'),
|
|
};
|
|
|
|
// ---------- Rendering ----------
|
|
|
|
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,
|
|
};
|
|
|
|
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;
|
|
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' },
|
|
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));
|
|
|
|
// 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
|
|
);
|
|
refresh().then(schedulePoll); |