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 @@
Loading… + Reports