Author SHA1 Message Date
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
kyleandClaude Opus 5.5 abb684ff0d Document updating the dashboard server
The README only covered the first install, so the admin page update
stalled at a step that wasn't written down: nginx serves its own copy
of public/ in /var/www, and without copying it a new page quietly falls
back to index.html.

"Updating the server" covers: backing up the database, pulling and
listing what changed, Python packages, copying the web files,
restarting, health checks, which deploy/ templates map to which
installed files, and redeploying collectors when they change. A
rollback section restores the database before starting the older API
(which refuses a newer database), using sqlite3 .restore rather than
cp because of the WAL file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 14:24:51 -06:00
15 changed files with 1010 additions and 75 deletions
+106
View File
@@ -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
@@ -173,3 +194,88 @@ Collector tests (PowerShell 7 + Pester; see [collectors/tests](collectors/tests/
Back up the SQLite file with `sqlite3 backup-dashboard.db ".backup copy.db"`. Don't use a plain `cp`
while the service is running.
### Updating the server
After changes are merged into `main`, on the dashboard server:
1. **Back up the database.** It takes seconds, and an update may upgrade the database's structure
when the API restarts, which only this backup can reverse. Needs the `sqlite3` package:
```bash
sudo -u backupdash sqlite3 /var/lib/backup-dashboard/backup-dashboard.db ".backup /var/lib/backup-dashboard/before-update.db"
```
2. **Pull, and see what changed** (the list tells you whether steps 7 and 8 apply):
```bash
sudo git -C /opt/backup-dashboard pull
```
```bash
git -C /opt/backup-dashboard diff --stat ORIG_HEAD HEAD
```
3. **Python packages.** Does nothing unless `backend/requirements.txt` changed:
```bash
sudo /opt/backup-dashboard/venv/bin/pip install -q -r /opt/backup-dashboard/backend/requirements.txt
```
4. **Web files.** nginx serves its own copy in `/var/www`, so a pull alone doesn't change what
browsers get. If this step is skipped, new pages quietly show the dashboard instead (nginx
falls back to `index.html` for files it doesn't have):
```bash
sudo rsync -a /opt/backup-dashboard/public/ /var/www/backup-dashboard/public/
```
Browsers pick up the new files on their next page load.
5. **Restart the API**, which loads the new code and settings and upgrades the database if needed:
```bash
sudo systemctl restart backup-dashboard-api
```
6. **Check it's healthy.** The first should print `{"status":"ok"}`; the log shouldn't show errors:
```bash
curl -s http://127.0.0.1:8000/api/v1/health
```
```bash
journalctl -u backup-dashboard-api -n 20 --no-pager
```
7. **If files in `deploy/` changed:** those are templates whose installed copies live elsewhere,
so a pull doesn't update them. Compare each changed one with its installed copy (e.g.
`diff /opt/backup-dashboard/deploy/oauth2-proxy.service /etc/systemd/system/oauth2-proxy.service`)
and bring the changes across:
| Changed in the repo | Installed copy | After updating it |
|---|---|---|
| `deploy/backup-dashboard.env` | `/etc/backup-dashboard/backup-dashboard.env` | Add any new settings, keeping your values; restart the API |
| `deploy/backup-dashboard-api.service` | `/etc/systemd/system/backup-dashboard-api.service` | `sudo systemctl daemon-reload`, restart the API |
| `deploy/nginx.conf` | Your nginx site config | Keep your server name and certificate paths; `sudo nginx -t && sudo systemctl reload nginx` |
| `deploy/oauth2-proxy.cfg` | `/etc/oauth2-proxy/backup-dashboard.cfg` | Keep your IDs and secrets; `sudo systemctl restart oauth2-proxy` |
| `deploy/oauth2-proxy.service` | `/etc/systemd/system/oauth2-proxy.service` | `sudo systemctl daemon-reload && sudo systemctl restart oauth2-proxy` |
8. **If files in `collectors/` changed:** the backup servers keep running their installed copy.
On each affected server, copy over the whole `collectors` folder, re-run that collector's
installer, and do a dry run (see its README). The changes are already covered by the
collector tests (`./collectors/tests/Invoke-CollectorTests.ps1`) if you want to run them first.
**If something goes wrong**, go back to the version from before step 2 (`ORIG_HEAD`). The order
matters: an older API won't start on a database a newer one has upgraded, so restore the database
before starting it.
1. Stop the API:
```bash
sudo systemctl stop backup-dashboard-api
```
2. Check whether the update upgraded the database: if these two numbers differ, it did.
```bash
for db in backup-dashboard before-update; do sudo -u backupdash sqlite3 /var/lib/backup-dashboard/$db.db "PRAGMA user_version"; done
```
If so, restore the backup from step 1 (`.restore`, not `cp`: it also deals with SQLite's `-wal`
file). Runs reported since the backup drop out of the history, since collectors have already
marked them as sent; backups themselves are unaffected.
```bash
sudo -u backupdash sqlite3 /var/lib/backup-dashboard/backup-dashboard.db ".restore /var/lib/backup-dashboard/before-update.db"
```
3. Go back to the previous code and web files, then start the API:
```bash
sudo git -C /opt/backup-dashboard checkout ORIG_HEAD
```
```bash
sudo rsync -a /opt/backup-dashboard/public/ /var/www/backup-dashboard/public/ && sudo systemctl start backup-dashboard-api
```
Once the problem is fixed, return to `main` with `sudo git -C /opt/backup-dashboard checkout main`
and update as above.
+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
+101
View File
@@ -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 <th> 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; }
}
+1
View File
@@ -14,6 +14,7 @@
<div class="topbar__meta">
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
<button id="refresh-btn" class="btn" type="button">Refresh</button>
<a id="reports-link" class="btn" href="/report.html">Reports</a>
<a id="admin-link" class="btn" href="/admin.html" hidden>Admin</a>
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
<span id="signed-in" class="topbar__user" hidden>
+15
View File
@@ -77,6 +77,21 @@ const httpApi = {
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() {
+15 -74
View File
@@ -2,6 +2,10 @@ import { CONFIG } from './config.js';
import { getApi } from './api.js';
import { el } from './dom.js';
import { showSignedInUser } from './signed-in.js';
import { STATUS, normalizeStatus, statusBadge } from './status.js';
import {
localDayKey, dayLabelFmt, dayTickFmt, normalizeRun, buildDays, describeCounts,
} from './history.js';
import {
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
} from './format.js';
@@ -9,20 +13,6 @@ import {
const api = await getApi();
// ---------- Domain ----------
// rank drives default sort: worst first
const STATUS = {
failed: { label: 'Failed', icon: '✕', rank: 0 },
warning: { label: 'Warning', icon: '!', rank: 1 },
unknown: { label: 'Unknown', icon: '?', rank: 2 },
running: { label: 'Running', icon: '↻', rank: 3 },
success: { label: 'Success', icon: '✓', rank: 4 },
};
// Anything the backend sends that we don't recognise becomes "unknown" rather than breaking the UI
function normalizeStatus(raw) {
const s = String(raw ?? '').toLowerCase();
return Object.hasOwn(STATUS, s) ? s : 'unknown';
}
function normalizeJob(j) {
return {
@@ -62,6 +52,7 @@ const dom = {
signedInEmail: $('#signed-in-email'),
signOut: $('#sign-out'),
adminLink: $('#admin-link'),
reportsLink: $('#reports-link'),
lastUpdated: $('#last-updated'),
errorBanner: $('#error-banner'),
dialog: $('#detail-dialog'),
@@ -71,20 +62,6 @@ const dom = {
};
// ---------- Rendering ----------
function statusBadge(status, { asButton = false, opensDialog = false, dataset = null, title = null } = {}) {
const { label, icon } = STATUS[status];
const content = [el('span', { class: 'badge__icon', 'aria-hidden': 'true' }, icon), label];
return asButton
? el('button', {
type: 'button',
class: `badge badge--${status}`,
'aria-haspopup': opensDialog ? 'dialog' : null,
'aria-label': `${label} – view details`,
title,
dataset,
}, content)
: el('span', { class: `badge badge--${status}`, title }, content);
}
const runningTitle = (since) => (since ? `Running since ${formatDateTime(since)}` : null);
@@ -231,51 +208,6 @@ const modal = {
runMessageError: null,
};
// Colour of a day cell: the worst thing that happened that day
const DAY_SEVERITY = ['failed', 'warning', 'unknown', 'running', 'success'];
const pad2 = (n) => String(n).padStart(2, '0');
const localDayKey = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
const dayLabelFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
const dayTickFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
function normalizeRun(r) {
return {
id: String(r.id),
status: normalizeStatus(r.status),
startedAt: parseDate(r.started_at),
finishedAt: parseDate(r.finished_at),
duration: r.duration_seconds,
size: r.size_bytes,
};
}
function buildDays(runs, count) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const days = [];
for (let i = count - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
days.push({ key: localDayKey(date), date, counts: {}, total: 0 });
}
const byKey = new Map(days.map((d) => [d.key, d]));
for (const run of runs) {
const day = byKey.get(localDayKey(run.startedAt));
if (!day) continue;
day.counts[run.status] = (day.counts[run.status] ?? 0) + 1;
day.total++;
}
for (const day of days) day.status = day.total ? DAY_SEVERITY.find((s) => day.counts[s]) : 'none';
return days;
}
function describeCounts(counts) {
return DAY_SEVERITY.filter((s) => counts[s])
.map((s) => `${counts[s]} ${STATUS[s].label.toLowerCase()}`)
.join(', ');
}
function detailRow(label, value) {
if (value == null || value === '') return [];
return [el('dt', {}, label), el('dd', {}, value)];
@@ -288,7 +220,8 @@ function renderSummarySection() {
const lastStatus = detail?.last_status ? normalizeStatus(detail.last_status) : null;
dom.detailTitle.textContent = detail?.job_name ?? job.name;
dom.detailSubtitle.textContent = detail?.company_name ?? job.company;
const company = detail?.company_name ?? job.company;
dom.detailSubtitle.replaceChildren(el('a', { href: reportUrl(company), title: `Backup report for ${company}` }, company));
return el('section', {},
el('div', { class: 'detail-status' },
@@ -517,7 +450,15 @@ dom.search.addEventListener('input', () => {
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
// A company's report page (keeps ?mock=1)
function reportUrl(company) {
const params = new URLSearchParams({ tenant: company, days: '30' });
if (CONFIG.useMock) params.set('mock', '1');
return `/report.html?${params}`;
}
// ---------- Boot ----------
dom.reportsLink.href = `/report.html${CONFIG.useMock ? '?mock=1' : ''}`;
api.getMe().then(
(me) => showSignedInUser(me, dom),
(err) => console.error(err), // not worth a banner: the jobs request will surface real problems
+48
View File
@@ -0,0 +1,48 @@
// Run history: runs grouped into days (local time), as shown in the dashboard's job detail and in reports.
import { parseDate } from './format.js';
import { STATUS, normalizeStatus } from './status.js';
// Colour of a day cell: the worst thing that happened that day
export const DAY_SEVERITY = ['failed', 'warning', 'unknown', 'running', 'success'];
const pad2 = (n) => String(n).padStart(2, '0');
export const localDayKey = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
export const dayLabelFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
export const dayTickFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
export function normalizeRun(r) {
return {
id: String(r.id),
status: normalizeStatus(r.status),
startedAt: parseDate(r.started_at),
finishedAt: parseDate(r.finished_at),
duration: r.duration_seconds,
size: r.size_bytes,
};
}
export function buildDays(runs, count) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const days = [];
for (let i = count - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
days.push({ key: localDayKey(date), date, counts: {}, total: 0 });
}
const byKey = new Map(days.map((d) => [d.key, d]));
for (const run of runs) {
const day = byKey.get(localDayKey(run.startedAt));
if (!day) continue;
day.counts[run.status] = (day.counts[run.status] ?? 0) + 1;
day.total++;
}
for (const day of days) day.status = day.total ? DAY_SEVERITY.find((s) => day.counts[s]) : 'none';
return days;
}
export function describeCounts(counts) {
return DAY_SEVERITY.filter((s) => counts[s])
.map((s) => `${counts[s]} ${STATUS[s].label.toLowerCase()}`)
.join(', ');
}
+40
View File
@@ -143,6 +143,46 @@ export const mockApi = {
return { email: null, name: null, is_admin: true };
},
async listTenants() {
await delay(150);
const counts = new Map();
for (const j of JOBS) counts.set(j.company_name, (counts.get(j.company_name) ?? 0) + 1);
return { tenants: [...counts].map(([name, jobs]) => ({ name, jobs })).sort((a, b) => a.name.localeCompare(b.name)) };
},
async tenantReport(tenant, days) {
await delay(250);
const jobs = JOBS.filter((j) => j.company_name.toLowerCase() === tenant.toLowerCase());
if (!jobs.length) throw new Error(`No company named '${tenant}'`);
const since = now - days * DAY;
return {
tenant: jobs[0].company_name, days,
since: new Date(since).toISOString(), until: new Date(now).toISOString(), generated_at: new Date(now).toISOString(),
jobs: jobs.map(({ details, every_h, ...job }) => ({
...job,
// Earliest run on record, as the API reports it
tracked_since: (RUNS.get(String(job.id)) ?? []).map((r) => r.started_at).sort()[0] ?? new Date(now).toISOString(),
runs: (RUNS.get(String(job.id)) ?? [])
.filter((r) => Date.parse(r.started_at) >= since)
.map(({ id, details: _d, message, ...r }) => ({ ...r, message: ['warning', 'failed'].includes(r.status) ? message : null }))
.reverse(), // oldest first, like the API
})),
};
},
// No server in mock mode: build the CSV here and link to it as a blob
async tenantReportCsvUrl(tenant, days, tz) {
const report = await this.tenantReport(tenant, days);
const quoteCell = (v) => `"${String(v ?? '').replaceAll('"', '""')}"`;
// Times stay in UTC here (the real API converts them to `tz`)
const rows = [['Company', 'Job', 'Source', 'Started (UTC)', 'Finished (UTC)', 'Status', 'Message']];
for (const job of report.jobs) {
for (const run of job.runs) {
rows.push([report.tenant, job.job_name, job.source_system, run.started_at, run.finished_at ?? '', run.status, run.message ?? '']);
}
}
const csv = rows.map((r) => r.map(quoteCell).join(',')).join('\r\n');
return URL.createObjectURL(new Blob(['\ufeff' + csv], { type: 'text/csv' }));
},
async listKeys() {
await delay(200);
return { keys: [...KEYS].sort((a, b) => (!!a.revoked_at - !!b.revoked_at) || a.name.localeCompare(b.name)) };
+248
View File
@@ -0,0 +1,248 @@
// Per-company backup report: summary, per-job results, day-by-day strips and every warning and
// failure for a period. Printable (Print / Save as PDF) and downloadable as CSV.
import { CONFIG } from './config.js';
import { getApi } from './api.js';
import { el } from './dom.js';
import { parseDate, formatDateTime } from './format.js';
import { buildDays, describeCounts, dayLabelFmt, normalizeRun } from './history.js';
import { showSignedInUser } from './signed-in.js';
import { normalizeStatus, statusBadge } from './status.js';
const api = await getApi();
// Report messages are trimmed to keep a printed report readable; the CSV has them in full
const MAX_MESSAGE_CHARS = 400;
const TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
const dateFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
const percentFmt = new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 1 });
const $ = (sel) => document.querySelector(sel);
const dom = {
backLink: $('#back-link'),
signedIn: $('#signed-in'),
signedInEmail: $('#signed-in-email'),
signOut: $('#sign-out'),
tenant: $('#tenant'),
days: $('#days'),
printBtn: $('#print-btn'),
csvLink: $('#csv-link'),
errorBanner: $('#error-banner'),
chooseHint: $('#choose-hint'),
loading: $('#loading'),
report: $('#report'),
title: $('#report-title'),
period: $('#report-period'),
summary: $('#report-summary'),
jobsBody: $('#jobs-body'),
daily: $('#daily'),
issuesBody: $('#issues-body'),
issuesWrap: $('#issues-wrap'),
issuesNone: $('#issues-none'),
footer: $('#report-footer'),
};
let requestId = 0; // a slow response for one company must not overwrite a later one
// ---------- Numbers ----------
function summarize(runs) {
const counts = { success: 0, warning: 0, failed: 0, running: 0, unknown: 0 };
for (const run of runs) counts[run.status]++;
const finished = runs.length - counts.running;
const lastFinished = runs.filter((r) => r.finishedAt).at(-1) ?? null;
const lastSuccess = runs.filter((r) => r.status === 'success').at(-1) ?? null;
return {
counts,
total: runs.length,
finished,
// Of finished runs only: one still in progress hasn't succeeded or failed yet
rate: finished ? counts.success / finished : null,
lastFinished,
lastSuccess,
};
}
const isOverdue = (job) => {
const next = parseDate(job.next_run_at);
return normalizeStatus(job.status) !== 'running' && !!next
&& next.getTime() + CONFIG.overdueGraceMinutes * 60_000 < Date.now();
};
// ---------- Rendering ----------
function renderSummary(jobs) {
const all = summarize(jobs.flatMap((j) => j.runs));
const overdue = jobs.filter((j) => isOverdue(j.job)).length;
const item = (label, value, modifier = '') => el('div', { class: `report-summary__item ${modifier}` },
el('dt', {}, label), el('dd', {}, value));
dom.summary.replaceChildren(
item('Jobs', String(jobs.length)),
item('Backup runs', String(all.total)),
item('Success rate', all.rate === null ? '—' : percentFmt.format(all.rate)),
item('Warnings', String(all.counts.warning), all.counts.warning ? 'report-summary__item--warning' : ''),
item('Failures', String(all.counts.failed), all.counts.failed ? 'report-summary__item--failed' : ''),
item('Overdue now', String(overdue), overdue ? 'report-summary__item--failed' : ''),
);
}
function renderJobs(jobs, days) {
dom.jobsBody.replaceChildren(...jobs.map(({ job, runs, stats }) => {
const last = stats.lastFinished;
return el('tr', {},
el('th', { scope: 'row' }, job.job_name),
el('td', {}, job.source_system),
el('td', { class: 'num' }, String(stats.total), stats.counts.running ? el('span', { class: 'time__rel' }, `${stats.counts.running} in progress`) : null),
el('td', { class: 'num' }, String(stats.counts.success)),
el('td', { class: 'num' }, String(stats.counts.warning)),
el('td', { class: 'num' }, String(stats.counts.failed)),
el('td', { class: 'num' }, stats.rate === null ? '—' : percentFmt.format(stats.rate)),
el('td', {}, last
? [statusBadge(last.status), el('span', { class: 'time__rel' }, formatDateTime(last.finishedAt))]
: el('span', { class: 'muted' }, 'No finished runs')),
el('td', {}, stats.lastSuccess
? formatDateTime(stats.lastSuccess.finishedAt)
: el('span', { class: 'report-alert' }, `None in the last ${days} days`)));
}));
}
function renderDaily(jobs, days) {
dom.daily.replaceChildren(...jobs.map(({ job, runs }) => {
const cells = buildDays(runs, days);
// Days before the dashboard knew of the job aren't missed backups, just unmonitored ones
const tracked = parseDate(job.tracked_since);
if (tracked) {
const firstDay = new Date(tracked);
firstDay.setHours(0, 0, 0, 0);
for (const d of cells) if (d.date < firstDay && !d.total) d.status = 'untracked';
}
const tally = {};
for (const d of cells) tally[d.status] = (tally[d.status] ?? 0) + 1;
const description = `${job.job_name}, ${days} days: ${tally.success ?? 0} successful, ${tally.warning ?? 0} with warnings, `
+ `${tally.failed ?? 0} failed, ${tally.none ?? 0} without a backup`
+ (tally.untracked ? `, ${tally.untracked} before monitoring began` : '');
return el('div', { class: 'report-daily__job' },
el('div', { class: 'report-daily__name' }, job.job_name),
// Coloured cells are visual only; the label gives screen readers the same picture
el('div', { class: 'day-strip report-strip', role: 'img', 'aria-label': description, style: `--days: ${days}` },
cells.map((d) => el('span', {
class: `day day--${d.status}`,
title: `${dayLabelFmt.format(d.date)}: ${d.total ? describeCounts(d.counts) : d.status === 'untracked' ? 'not monitored yet' : 'no backup'}`,
}))));
}));
}
function renderIssues(jobs) {
const issues = jobs
.flatMap(({ job, runs }) => runs
.filter((r) => r.status === 'warning' || r.status === 'failed')
.map((r) => ({ job, run: r })))
.sort((a, b) => b.run.startedAt - a.run.startedAt); // newest first
dom.issuesBody.replaceChildren(...issues.map(({ job, run }) => {
const message = (run.message ?? '').trim() || 'No details reported.';
const shown = message.length > MAX_MESSAGE_CHARS ? `${message.slice(0, MAX_MESSAGE_CHARS)}…` : message;
return el('tr', {},
el('td', { class: 'nowrap' }, formatDateTime(run.startedAt)),
el('th', { scope: 'row' }, job.job_name),
el('td', {}, statusBadge(run.status)),
el('td', { class: 'report-message' }, shown));
}));
dom.issuesNone.hidden = issues.length > 0;
dom.issuesWrap.hidden = issues.length === 0;
}
function render(report) {
const days = report.days;
const jobs = report.jobs.map((job) => {
const runs = job.runs.map((r) => ({ ...normalizeRun({ id: '', ...r }), message: r.message }))
.filter((r) => r.startedAt);
return { job, runs, stats: summarize(runs) };
});
const since = parseDate(report.since);
const until = parseDate(report.until);
dom.title.textContent = `Backup report: ${report.tenant}`;
dom.period.textContent = `${dateFmt.format(since)} – ${dateFmt.format(until)} (last ${days} days)`;
document.title = `Backup report: ${report.tenant} · Backup Monitor`;
renderSummary(jobs);
renderJobs(jobs, days);
renderDaily(jobs, days);
renderIssues(jobs);
dom.footer.textContent = `Generated ${formatDateTime(parseDate(report.generated_at))}. Times are in ${TIME_ZONE}. `
+ 'Success rate counts finished runs only. The CSV export lists every run with its full details.';
dom.report.hidden = false;
}
// ---------- Loading ----------
function setActionsEnabled(enabled) {
dom.printBtn.disabled = !enabled;
if (!enabled) {
dom.csvLink.removeAttribute('href');
dom.csvLink.setAttribute('aria-disabled', 'true');
}
}
async function load() {
const tenant = dom.tenant.value;
const days = Number(dom.days.value);
// Shareable, bookmarkable URL (keeps ?mock=1)
const params = new URLSearchParams(location.search);
if (tenant) params.set('tenant', tenant); else params.delete('tenant');
params.set('days', String(days));
history.replaceState(null, '', `?${params}`);
dom.errorBanner.hidden = true;
setActionsEnabled(false);
if (!tenant) {
dom.report.hidden = true;
dom.chooseHint.hidden = false;
return;
}
dom.chooseHint.hidden = true;
dom.loading.hidden = false;
const id = ++requestId;
try {
const report = await api.tenantReport(tenant, days);
if (id !== requestId) return;
render(report);
dom.csvLink.href = await api.tenantReportCsvUrl(report.tenant, days, TIME_ZONE);
dom.csvLink.download = `backup-report-${report.tenant.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.csv`;
dom.csvLink.removeAttribute('aria-disabled');
dom.printBtn.disabled = false;
} catch (err) {
if (id !== requestId) return;
dom.report.hidden = true;
dom.errorBanner.textContent = `Couldn't load the report: ${err.message}`;
dom.errorBanner.hidden = false;
} finally {
if (id === requestId) dom.loading.hidden = true;
}
}
dom.tenant.addEventListener('change', load);
dom.days.addEventListener('change', load);
dom.printBtn.addEventListener('click', () => window.print());
async function main() {
dom.backLink.href = `/${location.search.includes('mock') ? '?mock=1' : ''}`;
api.getMe().then((me) => showSignedInUser(me, dom), () => {});
const params = new URLSearchParams(location.search);
const requestedDays = params.get('days');
if ([...dom.days.options].some((o) => o.value === requestedDays)) dom.days.value = requestedDays;
try {
const { tenants } = await api.listTenants();
dom.tenant.append(...tenants.map((t) => el('option', { value: t.name }, `${t.name} (${t.jobs} job${t.jobs === 1 ? '' : 's'})`)));
} catch (err) {
dom.errorBanner.textContent = `Couldn't load the companies: ${err.message}`;
dom.errorBanner.hidden = false;
return;
}
// ?tenant= from a link: match case-insensitively, as the API does
const requested = (params.get('tenant') ?? '').toLowerCase();
const match = [...dom.tenant.options].find((o) => o.value && o.value.toLowerCase() === requested);
if (match) dom.tenant.value = match.value;
await load();
}
main();
+32
View File
@@ -0,0 +1,32 @@
// Run/job statuses: labels, icons, sort order, and the badge that shows one.
import { el } from './dom.js';
// rank drives default sort: worst first
export const STATUS = {
failed: { label: 'Failed', icon: '✕', rank: 0 },
warning: { label: 'Warning', icon: '!', rank: 1 },
unknown: { label: 'Unknown', icon: '?', rank: 2 },
running: { label: 'Running', icon: '↻', rank: 3 },
success: { label: 'Success', icon: '✓', rank: 4 },
};
// Anything the backend sends that we don't recognise becomes "unknown" rather than breaking the UI
export function normalizeStatus(raw) {
const s = String(raw ?? '').toLowerCase();
return Object.hasOwn(STATUS, s) ? s : 'unknown';
}
export function statusBadge(status, { asButton = false, opensDialog = false, dataset = null, title = null } = {}) {
const { label, icon } = STATUS[status];
const content = [el('span', { class: 'badge__icon', 'aria-hidden': 'true' }, icon), label];
return asButton
? el('button', {
type: 'button',
class: `badge badge--${status}`,
'aria-haspopup': opensDialog ? 'dialog' : null,
'aria-label': `${label} – view details`,
title,
dataset,
}, content)
: el('span', { class: `badge badge--${status}`, title }, content);
}
+117
View File
@@ -0,0 +1,117 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backup report · Backup Monitor</title>
<link rel="stylesheet" href="/css/styles.css">
<script type="module" src="/js/report.js"></script>
</head>
<body>
<header class="topbar no-print">
<div class="topbar__nav">
<a id="back-link" class="btn" href="/">← Dashboard</a>
<h1 class="topbar__title">Reports</h1>
</div>
<div class="topbar__meta">
<span id="signed-in" class="topbar__user" hidden>
<span id="signed-in-email" class="muted"></span>
<a id="sign-out" class="btn" href="#">Sign out</a>
</span>
</div>
</header>
<main class="container container--narrow">
<form id="report-form" class="report-controls no-print">
<div class="report-controls__field">
<label for="tenant">Company</label>
<select id="tenant" name="tenant" class="input" required>
<option value="">Choose a company…</option>
</select>
</div>
<div class="report-controls__field">
<label for="days">Period</label>
<select id="days" name="days" class="input">
<option value="7">Last 7 days</option>
<option value="30" selected>Last 30 days</option>
<option value="90">Last 90 days</option>
</select>
</div>
<div class="report-controls__actions">
<button id="print-btn" class="btn btn--primary" type="button" disabled>Print / Save as PDF</button>
<a id="csv-link" class="btn" aria-disabled="true">Download CSV</a>
</div>
</form>
<div id="error-banner" class="banner banner--error" role="alert" hidden></div>
<p id="choose-hint" class="empty no-print">Choose a company to see its backup report.</p>
<p id="loading" class="muted no-print" hidden>Loading report…</p>
<article id="report" class="report" aria-labelledby="report-title" hidden>
<header class="report__header">
<h2 id="report-title" class="report__title"></h2>
<p id="report-period" class="report__period"></p>
</header>
<section aria-label="Summary">
<dl id="report-summary" class="report-summary"></dl>
</section>
<section aria-labelledby="jobs-heading">
<h3 id="jobs-heading" class="report__section-title">Jobs</h3>
<div class="table-wrap">
<table class="jobs report-table">
<thead>
<tr>
<th scope="col">Job</th>
<th scope="col">Source</th>
<th scope="col" class="num">Runs</th>
<th scope="col" class="num">Successful</th>
<th scope="col" class="num">Warnings</th>
<th scope="col" class="num">Failed</th>
<th scope="col" class="num">Success rate</th>
<th scope="col">Last result</th>
<th scope="col">Last successful backup</th>
</tr>
</thead>
<tbody id="jobs-body"></tbody>
</table>
</div>
</section>
<section aria-labelledby="daily-heading">
<h3 id="daily-heading" class="report__section-title">Day by day</h3>
<ul class="report-legend" aria-label="Legend">
<li><span class="day day--success" aria-hidden="true"></span>Successful</li>
<li><span class="day day--warning" aria-hidden="true"></span>Warning</li>
<li><span class="day day--failed" aria-hidden="true"></span>Failed</li>
<li><span class="day day--running" aria-hidden="true"></span>In progress</li>
<li><span class="day day--none" aria-hidden="true"></span>No backup</li>
<li><span class="day day--untracked" aria-hidden="true"></span>Not monitored yet</li>
</ul>
<div id="daily" class="report-daily"></div>
</section>
<section aria-labelledby="issues-heading">
<h3 id="issues-heading" class="report__section-title">Warnings and failures</h3>
<p id="issues-none" class="muted" hidden>None in this period.</p>
<div id="issues-wrap" class="table-wrap">
<table class="jobs report-table report-issues">
<thead>
<tr>
<th scope="col">When</th>
<th scope="col">Job</th>
<th scope="col">Result</th>
<th scope="col">Details</th>
</tr>
</thead>
<tbody id="issues-body"></tbody>
</table>
</div>
</section>
<footer id="report-footer" class="report__footer muted"></footer>
</article>
</main>
</body>
</html>