diff --git a/README.md b/README.md
index 72ac30d..316af73 100644
--- a/README.md
+++ b/README.md
@@ -67,10 +67,31 @@ curl -X POST https://backups.example.com/api/v1/ingest \
| `GET /api/v1/jobs/{id}` | One job, plus the last finished run's duration, size, message and details |
| `GET /api/v1/jobs/{id}/runs?days=30` | Run history (no messages), newest first |
| `GET /api/v1/runs/{id}` | One run including message and details |
+| `GET /api/v1/tenants` | Companies with jobs, for choosing a report |
+| `GET /api/v1/reports/tenant?tenant=…&days=30` | One company's jobs and every run in the period (messages for warnings/failures only) |
+| `GET /api/v1/reports/tenant.csv?tenant=…&days=30&tz=America/Edmonton` | The same as CSV, with full messages and times in `tz` |
| `GET /api/v1/health` | `{"status": "ok"}` |
The OpenAPI schema is at `/api/v1/openapi.json`.
+## Reports
+
+**Reports** in the dashboard's top bar (or a company's name in a job's detail) opens a backup report
+for one company over the last 7, 30 or 90 days. The address carries the choice, e.g.
+`/report.html?tenant=Alpha%20Construction&days=30`, so a report can be bookmarked or linked.
+
+- **On the page:** a summary (jobs, runs, success rate, warnings, failures, overdue now); per job,
+ its run counts, success rate, last result and last successful backup; a day-by-day strip; and
+ every warning and failure with its message. **Print / Save as PDF** prints it light and without
+ the page controls, whatever the screen's theme.
+- **CSV:** every run in the period, with full messages and times in the browser's time zone. It
+ opens directly in Excel. Values that a spreadsheet would treat as formulas are prefixed with `'`.
+- **Success rate** is successful runs ÷ finished runs; runs still in progress don't count against it.
+- **Days before a job was monitored** (before its earliest known run) show as *Not monitored
+ yet*, not as missed backups.
+- Periods are limited by retention (`BACKUP_DASH_RETENTION_DAYS`, 90 by default). Anyone who can
+ sign in can view reports.
+
## Authentication
- **Dashboard users:** Microsoft 365 sign-in. nginx checks every request with
diff --git a/backend/app/main.py b/backend/app/main.py
index ec4efbc..f6bd613 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -7,7 +7,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
-from . import admin, db, repo
+from . import admin, db, reports, repo
from .auth import is_admin, require_ingest_key
from .models import IngestIn
from .settings import HISTORY_DAYS, Settings, load_settings
@@ -122,6 +122,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.settings = settings
app.include_router(router)
app.include_router(admin.router)
+ app.include_router(reports.router)
if settings.serve_static:
from fastapi.staticfiles import StaticFiles
diff --git a/backend/app/repo.py b/backend/app/repo.py
index 7328cf5..66b60a8 100644
--- a/backend/app/repo.py
+++ b/backend/app/repo.py
@@ -42,6 +42,13 @@ def list_jobs(conn: sqlite3.Connection) -> list[dict[str, Any]]:
return [{k: row[k] for k in _SUMMARY_FIELDS} for row in rows]
+def list_tenant_jobs(conn: sqlite3.Connection, tenant_id: int) -> list[dict[str, Any]]:
+ """A company's jobs with their current status, for reports."""
+ rows = conn.execute(_JOB_SQL + " WHERE j.tenant_id = ? ORDER BY j.name", (tenant_id,)).fetchall()
+ fields = (*_SUMMARY_FIELDS, "last_status")
+ return [{k: row[k] for k in fields} for row in rows]
+
+
def get_job(conn: sqlite3.Connection, job_id: int) -> dict[str, Any] | None:
row = conn.execute(_JOB_SQL + " WHERE j.id = ?", (job_id,)).fetchone()
if row is None:
diff --git a/backend/app/reports.py b/backend/app/reports.py
new file mode 100644
index 0000000..7d2d087
--- /dev/null
+++ b/backend/app/reports.py
@@ -0,0 +1,143 @@
+"""Per-company reports: every run of one company's backup jobs over a period, as JSON (for the
+printable report page) or CSV. Read-only, like the dashboard, so any signed-in user may view them."""
+
+import csv
+import io
+import re
+import sqlite3
+from datetime import UTC, datetime, timedelta
+from typing import Any
+from urllib.parse import quote
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
+
+from . import db, repo
+from .timeutil import DB_FORMAT, to_db, utcnow
+
+router = APIRouter(prefix="/api/v1")
+
+CSV_COLUMNS = ("Company", "Job", "Source", "Started", "Finished", "Duration (seconds)", "Status",
+ "Size (bytes)", "Message")
+
+
+@router.get("/tenants")
+def list_tenants(conn: sqlite3.Connection = Depends(db.get_db)):
+ """Companies that have jobs, for choosing a report."""
+ rows = conn.execute(
+ """
+ SELECT t.name, COUNT(j.id) AS jobs FROM tenants t JOIN jobs j ON j.tenant_id = t.id
+ GROUP BY t.id ORDER BY t.name COLLATE NOCASE
+ """
+ ).fetchall()
+ return {"tenants": [dict(r) for r in rows]}
+
+
+def _load_report(request: Request, conn: sqlite3.Connection, tenant: str, days: int,
+ all_messages: bool = False) -> dict[str, Any]:
+ retention = request.app.state.settings.retention_days
+ if days > retention:
+ raise HTTPException(422, f"Reports can cover at most {retention} days: older runs aren't kept")
+ row = conn.execute("SELECT id, name FROM tenants WHERE name = ?", (tenant.strip(),)).fetchone() # NOCASE
+ if row is None:
+ raise HTTPException(404, f"No company named {tenant!r}")
+
+ now = utcnow()
+ since = now - timedelta(days=days)
+ jobs = repo.list_tenant_jobs(conn, row["id"])
+ runs_by_job: dict[int, list[dict[str, Any]]] = {job["id"]: [] for job in jobs}
+ # By default only warnings' and failures' messages: successes all say much the same, and an
+ # hourly job has thousands of runs in 90 days
+ for run in conn.execute(
+ """
+ SELECT r.job_id, r.status, r.started_at, r.finished_at,
+ unixepoch(r.finished_at) - unixepoch(r.started_at) AS duration_seconds, r.size_bytes,
+ CASE WHEN ? OR r.status IN ('warning', 'failed') THEN r.message END AS message
+ FROM runs r JOIN jobs j ON j.id = r.job_id
+ WHERE j.tenant_id = ? AND r.started_at >= ?
+ ORDER BY r.started_at, r.id
+ """,
+ (all_messages, row["id"], to_db(since)),
+ ):
+ run = dict(run)
+ runs_by_job[run.pop("job_id")].append(run)
+ # When the dashboard first knew of each job: its earliest run (collectors backfill history), or
+ # when it was first reported if earlier. Days before then are "not monitored", not "no backup".
+ tracked = dict(conn.execute(
+ """
+ SELECT j.id, min(j.created_at, coalesce((SELECT MIN(r.started_at) FROM runs r WHERE r.job_id = j.id), j.created_at))
+ FROM jobs j WHERE j.tenant_id = ?
+ """,
+ (row["id"],),
+ ).fetchall())
+ for job in jobs:
+ job["runs"] = runs_by_job[job["id"]]
+ job["tracked_since"] = tracked[job["id"]]
+ return {
+ "tenant": row["name"],
+ "days": days,
+ "since": to_db(since),
+ "until": to_db(now),
+ "generated_at": to_db(now),
+ "jobs": jobs,
+ }
+
+
+@router.get("/reports/tenant")
+def tenant_report(
+ request: Request,
+ tenant: str = Query(..., min_length=1),
+ days: int = Query(30, ge=1),
+ conn: sqlite3.Connection = Depends(db.get_db),
+):
+ return _load_report(request, conn, tenant, days)
+
+
+def _safe_cell(value: Any) -> Any:
+ """Defuse text that a spreadsheet would run as a formula. Job names and messages come from the
+ backup servers, so a value like '=HYPERLINK(...)' must stay text."""
+ if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r"):
+ return "'" + value
+ return value
+
+
+@router.get("/reports/tenant.csv")
+def tenant_report_csv(
+ request: Request,
+ tenant: str = Query(..., min_length=1),
+ days: int = Query(30, ge=1),
+ tz: str = Query("UTC", description="IANA time zone for the times, e.g. America/Edmonton"),
+ conn: sqlite3.Connection = Depends(db.get_db),
+):
+ try:
+ zone = ZoneInfo(tz)
+ except (ZoneInfoNotFoundError, ValueError):
+ raise HTTPException(422, f"Unknown time zone {tz!r}") from None
+ report = _load_report(request, conn, tenant, days, all_messages=True)
+
+ def local(value: str | None) -> str:
+ if not value:
+ return ""
+ return datetime.strptime(value, DB_FORMAT).replace(tzinfo=UTC).astimezone(zone).strftime("%Y-%m-%d %H:%M:%S")
+
+ out = io.StringIO()
+ writer = csv.writer(out)
+ writer.writerow([c.replace("Started", f"Started ({tz})").replace("Finished", f"Finished ({tz})") for c in CSV_COLUMNS])
+ for job in report["jobs"]:
+ for run in job["runs"]:
+ writer.writerow([_safe_cell(v) for v in (
+ report["tenant"], job["job_name"], job["source_system"],
+ local(run["started_at"]), local(run["finished_at"]),
+ run["duration_seconds"] if run["duration_seconds"] is not None else "",
+ run["status"], run["size_bytes"] if run["size_bytes"] is not None else "",
+ run["message"] or "",
+ )])
+
+ slug = re.sub(r"[^A-Za-z0-9]+", "-", report["tenant"]).strip("-").lower() or "company"
+ filename = f"backup-report-{slug}-{datetime.now(zone):%Y-%m-%d}.csv"
+ return Response(
+ # BOM so Excel reads it as UTF-8 (accents, en dashes in job names)
+ content="" + out.getvalue(),
+ media_type="text/csv; charset=utf-8",
+ headers={"Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{quote(filename)}"},
+ )
diff --git a/backend/requirements.txt b/backend/requirements.txt
index c60a450..eaabc6d 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,2 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.30
+# Time zone data for report times, where the OS has none
+tzdata
diff --git a/backend/tests/test_reports.py b/backend/tests/test_reports.py
new file mode 100644
index 0000000..aa90c65
--- /dev/null
+++ b/backend/tests/test_reports.py
@@ -0,0 +1,133 @@
+import csv
+import io
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app import db
+from app.auth import generate_key, hash_key
+from app.main import create_app
+from app.settings import Settings
+from app.timeutil import to_db, utcnow
+
+
+@pytest.fixture
+def client(tmp_path):
+ settings = Settings(db_path=tmp_path / "test.db", retention_days=90)
+ with TestClient(create_app(settings)) as c:
+ key = generate_key()
+ conn = db.connect(settings.db_path)
+ conn.execute("INSERT INTO api_keys (name, key_hash, created_at) VALUES ('t', ?, ?)", (hash_key(key), to_db(utcnow())))
+ conn.close()
+ c.headers["Authorization"] = f"Bearer {key}"
+ yield c
+
+
+def report_run(client, tenant, job, status, started, minutes=10, message="", size=None):
+ run = {"status": status, "started_at": started.isoformat(), "message": message}
+ if status != "running":
+ run["finished_at"] = (started + timedelta(minutes=minutes)).isoformat()
+ if size is not None:
+ run["size_bytes"] = size
+ r = client.post("/api/v1/ingest", json={
+ "tenant": tenant, "source_system": "Veeam B&R", "job": {"external_id": job, "name": job}, "run": run})
+ assert r.status_code == 200, r.text
+
+
+@pytest.fixture
+def alpha(client):
+ now = utcnow()
+ report_run(client, "Alpha Construction", "File Server", "success", now - timedelta(days=1), size=1000,
+ message="Job completed successfully.")
+ report_run(client, "Alpha Construction", "File Server", "failed", now - timedelta(days=2),
+ message="=HYPERLINK(\"http://evil\")\nRepository unreachable")
+ report_run(client, "Alpha Construction", "File Server", "success", now - timedelta(days=45)) # outside 30 days
+ report_run(client, "Alpha Construction", "SQL01", "warning", now - timedelta(hours=5), message="Log truncation skipped")
+ report_run(client, "Alpha Construction", "SQL01", "running", now - timedelta(minutes=5))
+ report_run(client, "Bluewater Dental", "Imaging", "success", now - timedelta(days=1))
+ return now
+
+
+def test_lists_companies_with_jobs(client, alpha):
+ assert client.get("/api/v1/tenants").json()["tenants"] == [
+ {"name": "Alpha Construction", "jobs": 2}, {"name": "Bluewater Dental", "jobs": 1}]
+
+
+def test_report_covers_one_company_for_the_period(client, alpha):
+ r = client.get("/api/v1/reports/tenant", params={"tenant": "alpha construction"}) # any case
+ assert r.status_code == 200
+ report = r.json()
+ assert report["tenant"] == "Alpha Construction"
+ assert report["days"] == 30
+ jobs = {j["job_name"]: j for j in report["jobs"]}
+ assert set(jobs) == {"File Server", "SQL01"} # not Bluewater's
+ assert [run["status"] for run in jobs["File Server"]["runs"]] == ["failed", "success"] # oldest first; 45-day-old run left out
+ assert [run["status"] for run in jobs["SQL01"]["runs"]] == ["warning", "running"]
+ assert jobs["SQL01"]["status"] == "running"
+
+
+def test_report_says_when_each_job_was_first_tracked(client, alpha):
+ jobs = {j["job_name"]: j for j in client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction"}).json()["jobs"]}
+ # File Server's earliest run (45 days ago, outside the report) predates its first report to the API
+ assert jobs["File Server"]["tracked_since"] == to_db(alpha - timedelta(days=45))
+ # SQL01's runs are all recent; it was first reported (created) when its first run arrived
+ assert jobs["SQL01"]["tracked_since"] <= to_db(alpha - timedelta(hours=5))
+
+
+def test_report_includes_messages_only_for_warnings_and_failures(client, alpha):
+ jobs = {j["job_name"]: j for j in client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction"}).json()["jobs"]}
+ failed, success = jobs["File Server"]["runs"]
+ assert "Repository unreachable" in failed["message"]
+ assert success["message"] is None
+ assert success["size_bytes"] == 1000
+ assert success["duration_seconds"] == 600
+
+
+def test_report_period_is_limited_to_retention(client, alpha):
+ assert client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 90}).status_code == 200
+ r = client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 91})
+ assert r.status_code == 422
+ assert "90 days" in r.json()["detail"]
+ assert client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 0}).status_code == 422
+
+
+def test_unknown_company_is_404(client, alpha):
+ assert client.get("/api/v1/reports/tenant", params={"tenant": "Nobody Inc"}).status_code == 404
+
+
+def read_csv(response):
+ text = response.content.decode("utf-8")
+ assert text.startswith("") # so Excel reads it as UTF-8
+ return list(csv.reader(io.StringIO(text[1:])))
+
+
+def test_csv_has_every_run_with_times_in_the_given_zone(client):
+ started = datetime(2026, 1, 15, 12, 0, tzinfo=UTC) # 05:00 in Edmonton (MST, -7)
+ # The fixture's timestamps are relative to now; this run needs a fixed winter date in range
+ client.app.state.settings = Settings(db_path=client.app.state.settings.db_path, retention_days=10_000)
+ report_run(client, "Alpha Construction", "File Server", "success", started, minutes=20, size=5,
+ message="Job completed successfully.")
+ r = client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction", "days": 5000, "tz": "America/Edmonton"})
+ assert r.status_code == 200
+ assert r.headers["content-type"].startswith("text/csv")
+ rows = read_csv(r)
+ assert rows[0] == ["Company", "Job", "Source", "Started (America/Edmonton)", "Finished (America/Edmonton)",
+ "Duration (seconds)", "Status", "Size (bytes)", "Message"]
+ assert rows[1] == ["Alpha Construction", "File Server", "Veeam B&R", "2026-01-15 05:00:00", "2026-01-15 05:20:00",
+ "1200", "success", "5", "Job completed successfully."]
+
+
+def test_csv_defuses_spreadsheet_formulas_and_keeps_full_messages(client, alpha):
+ rows = read_csv(client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction"}))
+ by_status = {row[6]: row for row in rows[1:]}
+ assert by_status["failed"][8].startswith("'=HYPERLINK")
+ assert by_status["success"][8] == "Job completed successfully." # successes' messages too, unlike the JSON
+ assert by_status["running"][4] == "" # not finished
+ assert len(rows) == 1 + 4 # header + the 4 runs in the last 30 days
+
+
+def test_csv_filename_and_bad_time_zone(client, alpha):
+ r = client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction"})
+ assert 'filename="backup-report-alpha-construction-' in r.headers["content-disposition"]
+ assert client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction", "tz": "Mars/Olympus"}).status_code == 422
diff --git a/public/css/styles.css b/public/css/styles.css
index 55f0c13..c7a274c 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -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
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; }
+}
diff --git a/public/index.html b/public/index.html
index 5a76863..31cc4f2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -14,6 +14,7 @@
|