From 2297187f88d9c3d6c77706a1cdcf706205ce4cd6 Mon Sep 17 00:00:00 2001 From: kyle Date: Thu, 8 Oct 2026 14:56:54 -0600 Subject: [PATCH] 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 --- README.md | 21 +++ backend/app/main.py | 3 +- backend/app/repo.py | 7 + backend/app/reports.py | 143 ++++++++++++++++++++ backend/requirements.txt | 2 + backend/tests/test_reports.py | 133 ++++++++++++++++++ public/css/styles.css | 101 ++++++++++++++ public/index.html | 1 + public/js/api.js | 15 ++ public/js/app.js | 89 ++---------- public/js/history.js | 48 +++++++ public/js/mock.js | 40 ++++++ public/js/report.js | 248 ++++++++++++++++++++++++++++++++++ public/js/status.js | 32 +++++ public/report.html | 117 ++++++++++++++++ 15 files changed, 925 insertions(+), 75 deletions(-) create mode 100644 backend/app/reports.py create mode 100644 backend/tests/test_reports.py create mode 100644 public/js/history.js create mode 100644 public/js/report.js create mode 100644 public/js/status.js create mode 100644 public/report.html 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