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
+2 -1
View File
@@ -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
+7
View File
@@ -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:
+143
View File
@@ -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)}"},
)
+2
View File
@@ -1,2 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.30
# Time zone data for report times, where the OS has none
tzdata
+133
View File
@@ -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