Compare commits
3
Commits
admin-keys
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd49d85c77 | ||
|
|
2297187f88 | ||
|
|
abb684ff0d |
+17
@@ -0,0 +1,17 @@
|
|||||||
|
Backup Dashboard - A simple dashboad showing the status of backup jobs across multiple platforms
|
||||||
|
Copyright (C) 2026 Kyle Harder - Reliability Technology Soluctions Ltd.
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Source: http://opensource.org/licenses/GPL-3.0
|
||||||
@@ -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}` | 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/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/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"}` |
|
| `GET /api/v1/health` | `{"status": "ok"}` |
|
||||||
|
|
||||||
The OpenAPI schema is at `/api/v1/openapi.json`.
|
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
|
## Authentication
|
||||||
|
|
||||||
- **Dashboard users:** Microsoft 365 sign-in. nginx checks every request with
|
- **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`
|
Back up the SQLite file with `sqlite3 backup-dashboard.db ".backup copy.db"`. Don't use a plain `cp`
|
||||||
while the service is running.
|
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
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
|
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 .auth import is_admin, require_ingest_key
|
||||||
from .models import IngestIn
|
from .models import IngestIn
|
||||||
from .settings import HISTORY_DAYS, Settings, load_settings
|
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.state.settings = settings
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
|
app.include_router(reports.router)
|
||||||
|
|
||||||
if settings.serve_static:
|
if settings.serve_static:
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|||||||
@@ -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]
|
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:
|
def get_job(conn: sqlite3.Connection, job_id: int) -> dict[str, Any] | None:
|
||||||
row = conn.execute(_JOB_SQL + " WHERE j.id = ?", (job_id,)).fetchone()
|
row = conn.execute(_JOB_SQL + " WHERE j.id = ?", (job_id,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|||||||
@@ -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)}"},
|
||||||
|
)
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
fastapi>=0.115
|
fastapi>=0.115
|
||||||
uvicorn[standard]>=0.30
|
uvicorn[standard]>=0.30
|
||||||
|
# Time zone data for report times, where the OS has none
|
||||||
|
tzdata
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -422,3 +422,104 @@ code {
|
|||||||
.modal--small { width: min(460px, calc(100vw - 2rem)); }
|
.modal--small { width: min(460px, calc(100vw - 2rem)); }
|
||||||
.modal--small .modal__body p { margin-top: 0; }
|
.modal--small .modal__body p { margin-top: 0; }
|
||||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<div class="topbar__meta">
|
<div class="topbar__meta">
|
||||||
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
|
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
|
||||||
<button id="refresh-btn" class="btn" type="button">Refresh</button>
|
<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>
|
<a id="admin-link" class="btn" href="/admin.html" hidden>Admin</a>
|
||||||
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
|
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
|
||||||
<span id="signed-in" class="topbar__user" hidden>
|
<span id="signed-in" class="topbar__user" hidden>
|
||||||
|
|||||||
@@ -77,6 +77,21 @@ const httpApi = {
|
|||||||
return request('/me');
|
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) -----
|
// ----- Admin (403 for non-admins) -----
|
||||||
/** GET /admin/keys → { keys: ApiKey[] } (active first) */
|
/** GET /admin/keys → { keys: ApiKey[] } (active first) */
|
||||||
listKeys() {
|
listKeys() {
|
||||||
|
|||||||
+15
-74
@@ -2,6 +2,10 @@ import { CONFIG } from './config.js';
|
|||||||
import { getApi } from './api.js';
|
import { getApi } from './api.js';
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
import { showSignedInUser } from './signed-in.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 {
|
import {
|
||||||
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
|
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
|
||||||
} from './format.js';
|
} from './format.js';
|
||||||
@@ -9,20 +13,6 @@ import {
|
|||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
|
|
||||||
// ---------- Domain ----------
|
// ---------- 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) {
|
function normalizeJob(j) {
|
||||||
return {
|
return {
|
||||||
@@ -62,6 +52,7 @@ const dom = {
|
|||||||
signedInEmail: $('#signed-in-email'),
|
signedInEmail: $('#signed-in-email'),
|
||||||
signOut: $('#sign-out'),
|
signOut: $('#sign-out'),
|
||||||
adminLink: $('#admin-link'),
|
adminLink: $('#admin-link'),
|
||||||
|
reportsLink: $('#reports-link'),
|
||||||
lastUpdated: $('#last-updated'),
|
lastUpdated: $('#last-updated'),
|
||||||
errorBanner: $('#error-banner'),
|
errorBanner: $('#error-banner'),
|
||||||
dialog: $('#detail-dialog'),
|
dialog: $('#detail-dialog'),
|
||||||
@@ -71,20 +62,6 @@ const dom = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---------- Rendering ----------
|
// ---------- 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);
|
const runningTitle = (since) => (since ? `Running since ${formatDateTime(since)}` : null);
|
||||||
|
|
||||||
@@ -231,51 +208,6 @@ const modal = {
|
|||||||
runMessageError: null,
|
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) {
|
function detailRow(label, value) {
|
||||||
if (value == null || value === '') return [];
|
if (value == null || value === '') return [];
|
||||||
return [el('dt', {}, label), el('dd', {}, value)];
|
return [el('dt', {}, label), el('dd', {}, value)];
|
||||||
@@ -288,7 +220,8 @@ function renderSummarySection() {
|
|||||||
const lastStatus = detail?.last_status ? normalizeStatus(detail.last_status) : null;
|
const lastStatus = detail?.last_status ? normalizeStatus(detail.last_status) : null;
|
||||||
|
|
||||||
dom.detailTitle.textContent = detail?.job_name ?? job.name;
|
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', {},
|
return el('section', {},
|
||||||
el('div', { class: 'detail-status' },
|
el('div', { class: 'detail-status' },
|
||||||
@@ -517,7 +450,15 @@ dom.search.addEventListener('input', () => {
|
|||||||
|
|
||||||
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
|
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 ----------
|
// ---------- Boot ----------
|
||||||
|
dom.reportsLink.href = `/report.html${CONFIG.useMock ? '?mock=1' : ''}`;
|
||||||
api.getMe().then(
|
api.getMe().then(
|
||||||
(me) => showSignedInUser(me, dom),
|
(me) => showSignedInUser(me, dom),
|
||||||
(err) => console.error(err), // not worth a banner: the jobs request will surface real problems
|
(err) => console.error(err), // not worth a banner: the jobs request will surface real problems
|
||||||
|
|||||||
@@ -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(', ');
|
||||||
|
}
|
||||||
@@ -143,6 +143,46 @@ export const mockApi = {
|
|||||||
return { email: null, name: null, is_admin: true };
|
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() {
|
async listKeys() {
|
||||||
await delay(200);
|
await delay(200);
|
||||||
return { keys: [...KEYS].sort((a, b) => (!!a.revoked_at - !!b.revoked_at) || a.name.localeCompare(b.name)) };
|
return { keys: [...KEYS].sort((a, b) => (!!a.revoked_at - !!b.revoked_at) || a.name.localeCompare(b.name)) };
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user