Files
kyleandClaude Opus 5.5 2297187f88 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]>
2026-10-08 14:56:54 -06:00

117 lines
4.2 KiB
JavaScript

import { CONFIG } from './config.js';
export class ApiError extends Error {
constructor(message, status = null) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
// The API's explanation from an error response: FastAPI's {"detail": "..."}, or for validation
// errors {"detail": [{"msg": "..."}, ...]}
async function errorDetail(res) {
try {
const { detail } = await res.json();
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) return detail.map((d) => d.msg).filter(Boolean).join('; ');
} catch {
// not JSON: fall back to the status code
}
return null;
}
async function request(path, { method = 'GET', body } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
const headers = { Accept: 'application/json' };
if (method !== 'GET') {
// Required by the API for changes: another site's page can't add it to a request here
headers['X-Requested-By'] = 'backup-dashboard';
}
if (body !== undefined) headers['Content-Type'] = 'application/json';
try {
const res = await fetch(`${CONFIG.apiBaseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
credentials: 'same-origin', // the sign-in session cookie
signal: controller.signal,
});
// 401 means the nginx/SSO session lapsed; a reload sends the user back through sign-in
if (res.status === 401) throw new ApiError('Your session has expired. Reload the page to sign in again', 401);
if (!res.ok) {
throw new ApiError((await errorDetail(res)) ?? `Server returned HTTP ${res.status}`, res.status);
}
return await res.json();
} catch (err) {
if (err.name === 'AbortError') throw new ApiError('Request timed out');
if (err instanceof ApiError) throw err;
throw new ApiError(`Network error: ${err.message}`);
} finally {
clearTimeout(timer);
}
}
const httpApi = {
/** GET /jobs → { generated_at, jobs: Job[] } */
async listJobs() {
const data = await request('/jobs');
if (!Array.isArray(data?.jobs)) throw new ApiError('Unexpected response shape from /jobs');
return data;
},
/** GET /jobs/:id → JobDetail */
getJob(id) {
return request(`/jobs/${encodeURIComponent(id)}`);
},
/** GET /jobs/:id/runs?days=N → { job_id, since, runs: RunSummary[] } (newest first, no messages) */
listRuns(id, days) {
return request(`/jobs/${encodeURIComponent(id)}/runs?days=${encodeURIComponent(days)}`);
},
/** GET /runs/:id → Run (with message and details) */
getRun(id) {
return request(`/runs/${encodeURIComponent(id)}`);
},
/** GET /me → { email, name, is_admin } of the Microsoft 365 user; email/name null without sign-in */
getMe() {
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() {
return request('/admin/keys');
},
/** POST /admin/keys → { key: ApiKey, secret } — the secret is only ever returned here */
createKey(name) {
return request('/admin/keys', { method: 'POST', body: { name } });
},
/** POST /admin/keys/:id/revoke → { key: ApiKey } */
revokeKey(id) {
return request(`/admin/keys/${encodeURIComponent(id)}/revoke`, { method: 'POST' });
},
};
// Mock is loaded lazily so it never ships in the production request path.
export async function getApi() {
if (CONFIG.useMock) {
const { mockApi } = await import('./mock.js');
return mockApi;
}
return httpApi;
}