From bf752a904ff9c2a41ad21e09f1615dfa56dac37d Mon Sep 17 00:00:00 2001 From: kyle Date: Sun, 4 Oct 2026 21:53:24 -0600 Subject: [PATCH] Add backend API, running status and 30-day run history - FastAPI + SQLite backend: tenants, jobs and runs, with an idempotent /api/v1/ingest endpoint authenticated by per-collector API keys - Runs store started_at and finished_at; "Last Run" is the most recent finish, and in-progress runs show as Running - Job modal shows a 30-day daily strip, success rate, and a filterable run list with per-run messages - nginx: HTTPS plus basic auth, ingest endpoint exempt; systemd unit - manage.py for API keys, job cleanup and demo data; pytest suite Co-Authored-By: Claude Opus 5.5 --- .gitignore | 8 + README.md | 100 +++++- backend/app/__init__.py | 0 backend/app/auth.py | 42 +++ backend/app/db.py | 99 ++++++ backend/app/main.py | 118 +++++++ backend/app/models.py | 63 ++++ backend/app/repo.py | 162 +++++++++ backend/app/settings.py | 22 ++ backend/app/timeutil.py | 12 + backend/manage.py | 116 ++++++ backend/pytest.ini | 2 + backend/requirements-dev.txt | 2 + backend/requirements.txt | 2 + backend/tests/__init__.py | 0 backend/tests/test_api.py | 124 +++++++ deploy/backup-dashboard-api.service | 23 ++ deploy/nginx.conf | 52 +++ public/css/styles.css | 341 ++++++++++++++++++ public/index.html | 85 +++++ public/js/api.js | 61 ++++ public/js/app.js | 527 ++++++++++++++++++++++++++++ public/js/config.js | 15 + public/js/format.js | 44 +++ public/js/mock.js | 129 +++++++ 25 files changed, 2148 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 backend/app/__init__.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/db.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/repo.py create mode 100644 backend/app/settings.py create mode 100644 backend/app/timeutil.py create mode 100644 backend/manage.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements-dev.txt create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_api.py create mode 100644 deploy/backup-dashboard-api.service create mode 100644 deploy/nginx.conf create mode 100644 public/css/styles.css create mode 100644 public/index.html create mode 100644 public/js/api.js create mode 100644 public/js/app.js create mode 100644 public/js/config.js create mode 100644 public/js/format.js create mode 100644 public/js/mock.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d63083b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +.pytest_cache/ +*.db +*.db-wal +*.db-shm diff --git a/README.md b/README.md index ead920e..aba50a8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,101 @@ # Backup-Dashboard -Proof of concept backup dashboard, intended to display backup jobs from multiple sources \ No newline at end of file +Proof of concept backup dashboard, intended to display backup jobs from multiple sources. + +``` +collectors ──POST /api/v1/ingest (API key)──▶ ┌──────────────┐ + │ FastAPI + │ +browser ──nginx (basic auth, TLS)──▶ /api/* ─▶│ SQLite │ + └─ static files: public/ └──────────────┘ +``` + +- `public/`: static frontend (no build step). Add `?mock=1` to the URL to use built-in sample data. +- `backend/`: FastAPI API backed by a single SQLite file. +- `deploy/`: nginx site and systemd unit. + +## Data model + +- **Tenant** (company) → **Job** → **Run**. +- A job is keyed by `(tenant, source_system, job.external_id)`, so renaming a job at the source updates it instead of creating a duplicate. +- A run has `status` (`running` | `success` | `warning` | `failed`), `started_at` and `finished_at`. + The dashboard's **Last Run** is the most recent `finished_at`. While a job is running it shows + **Running**, and Last Run still shows when the previous run finished. +- Runs older than `BACKUP_DASH_RETENTION_DAYS` (default 90, minimum 30) are purged every 6 hours. + +## Reporting results (collectors) + +Each collector (a script on a Veeam server, a webhook relay, etc.) gets its own API key: + +```bash +cd backend && python manage.py create-key veeam-acme +``` + +Then it POSTs one report per run. Send `running` when a run starts (optional) and the final status +when it ends. Reports with the same `started_at` (or `run.external_id`) update the same run, and +retries are safe. + +```bash +curl -X POST https://backups.example.com/api/v1/ingest \ + -H "Authorization: Bearer bdk_..." -H "Content-Type: application/json" \ + -d '{ + "tenant": "Acme Corp", + "source_system": "Veeam B&R", + "job": { "external_id": "4f1c…", "name": "File Server – Nightly", "next_run_at": "2026-10-05T22:00:00-04:00" }, + "run": { + "status": "success", + "started_at": "2026-10-04T22:00:05-04:00", + "finished_at": "2026-10-04T22:47:12-04:00", + "message": "Job completed successfully.", + "size_bytes": 128849018880, + "details": { "repository": "REPO-01" } + } + }' +``` + +- All timestamps must include a timezone offset (`Get-Date -Format o` in PowerShell does this). +- `job.next_run_at` controls the **Overdue** flag. If you omit it, the stored value is kept. If you send `null`, it's cleared. +- `run` can be omitted to only register or update a job. +- Unknown fields are rejected (HTTP 422), so typos in collector scripts fail loudly. + +## API (read) + +| Endpoint | Returns | +|---|---| +| `GET /api/v1/jobs` | All jobs with current status, `running_since`, `last_run_at`, `next_run_at` | +| `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/health` | `{"status": "ok"}` | + +The OpenAPI schema is at `/api/v1/openapi.json`. + +## Authentication + +- **Dashboard users:** nginx basic auth over HTTPS (`deploy/nginx.conf`). Every user can see every tenant. + *Planned:* Microsoft 365 sign-in through [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) + with the Entra ID provider and nginx `auth_request`. That's an nginx-only change. The frontend + already tells the user to reload when a session expires (HTTP 401). +- **Collectors:** per-collector bearer keys, checked by the app. Only a SHA-256 hash is stored. + To revoke one, run `python manage.py revoke-key NAME`. + +## Development + +```bash +cd backend +python -m venv .venv && . .venv/bin/activate +pip install -r requirements.txt -r requirements-dev.txt +pytest +python manage.py seed-demo # 30 days of fake history +BACKUP_DASH_SERVE_STATIC=1 uvicorn --factory app.main:create_app --reload +# → http://localhost:8000 +``` + +## Deployment (Linux, nginx) + +1. Copy the repo to `/opt/backup-dashboard`, create a venv at `/opt/backup-dashboard/venv`, and run `pip install -r backend/requirements.txt`. +2. Create a `backupdash` system user, then install `deploy/backup-dashboard-api.service` into `/etc/systemd/system/` and enable it. +3. Copy `public/` to `/var/www/backup-dashboard/public`, install `deploy/nginx.conf`, get a certificate with certbot, and create users with `htpasswd -B`. +4. Run the `manage.py` commands as the service user with `BACKUP_DASH_DB=/var/lib/backup-dashboard/backup-dashboard.db`. + +Back up the SQLite file with `sqlite3 backup-dashboard.db ".backup copy.db"`. Don't use a plain `cp` +while the service is running. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..8c669c9 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,42 @@ +"""Collector (machine) authentication. + +Dashboard *users* are authenticated by nginx in front of the app (basic auth today, +oauth2-proxy + Microsoft Entra ID later), so nothing here deals with people. +""" + +import hashlib +import secrets +import sqlite3 + +from fastapi import Depends, Header, HTTPException + +from .db import get_db +from .timeutil import to_db, utcnow + +KEY_PREFIX = "bdk_" + + +def generate_key() -> str: + return KEY_PREFIX + secrets.token_urlsafe(32) + + +def hash_key(key: str) -> str: + # Keys are 256-bit random, so a fast unsalted hash is fine (no dictionary to attack). + return hashlib.sha256(key.encode()).hexdigest() + + +def require_ingest_key( + authorization: str | None = Header(None), + conn: sqlite3.Connection = Depends(get_db), +) -> str: + scheme, _, token = (authorization or "").partition(" ") + token = token.strip() + if scheme.lower() != "bearer" or not token: + raise HTTPException(401, "Missing bearer token", headers={"WWW-Authenticate": "Bearer"}) + row = conn.execute( + "SELECT id, name FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL", (hash_key(token),) + ).fetchone() + if row is None: + raise HTTPException(401, "Invalid or revoked API key", headers={"WWW-Authenticate": "Bearer"}) + conn.execute("UPDATE api_keys SET last_used_at = ? WHERE id = ?", (to_db(utcnow()), row["id"])) + return row["name"] diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..fb315dd --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,99 @@ +import sqlite3 +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +from fastapi import Request + +SCHEMA_VERSION = 1 + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS tenants ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE +); + +-- A job is identified by (tenant, source system, the source's own job id), so two +-- products can't collide and renaming a job in Veeam doesn't create a duplicate. +CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + source_system TEXT NOT NULL, + external_id TEXT NOT NULL, + name TEXT NOT NULL, + next_run_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (tenant_id, source_system, external_id) +); + +CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY, + job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + external_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'success', 'warning', 'failed')), + started_at TEXT NOT NULL, + finished_at TEXT, + message TEXT NOT NULL DEFAULT '', + size_bytes INTEGER, + details TEXT NOT NULL DEFAULT '{}', + received_at TEXT NOT NULL, + CHECK ((status = 'running') = (finished_at IS NULL)), + UNIQUE (job_id, external_id) +); +CREATE INDEX IF NOT EXISTS runs_job_started ON runs (job_id, started_at); +CREATE INDEX IF NOT EXISTS runs_job_finished ON runs (job_id, finished_at); + +-- Keys used by collectors to push results. Only the SHA-256 is stored. +CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + key_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + last_used_at TEXT, + revoked_at TEXT +); +""" + + +def connect(path: Path | str) -> sqlite3.Connection: + # Autocommit mode; writes that must be atomic go through transaction(). + # check_same_thread=False: FastAPI may run a dependency and its endpoint on different + # threadpool threads, but a connection is only ever used by one request at a time. + conn = sqlite3.connect(path, timeout=10, isolation_level=None, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA busy_timeout = 10000") + return conn + + +def init_db(path: Path | str) -> None: + conn = connect(path) + try: + conn.execute("PRAGMA journal_mode = WAL") + version = conn.execute("PRAGMA user_version").fetchone()[0] + if version > SCHEMA_VERSION: + raise RuntimeError(f"Database schema v{version} is newer than this app (v{SCHEMA_VERSION})") + conn.executescript(SCHEMA) + conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + finally: + conn.close() + + +@contextmanager +def transaction(conn: sqlite3.Connection) -> Iterator[None]: + conn.execute("BEGIN IMMEDIATE") + try: + yield + except BaseException: + conn.execute("ROLLBACK") + raise + conn.execute("COMMIT") + + +def get_db(request: Request) -> Iterator[sqlite3.Connection]: + conn = connect(request.app.state.settings.db_path) + try: + yield conn + finally: + conn.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..7c39580 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,118 @@ +import asyncio +import logging +import sqlite3 +from contextlib import asynccontextmanager +from datetime import timedelta +from pathlib import Path + +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request + +from . import db, repo +from .auth import require_ingest_key +from .models import IngestIn +from .settings import HISTORY_DAYS, Settings, load_settings +from .timeutil import to_db, utcnow + +log = logging.getLogger("backup_dashboard") +PURGE_INTERVAL_SECONDS = 6 * 3600 + +router = APIRouter(prefix="/api/v1") + + +@router.get("/health") +def health(conn: sqlite3.Connection = Depends(db.get_db)): + conn.execute("SELECT 1") + return {"status": "ok"} + + +@router.get("/jobs") +def list_jobs(conn: sqlite3.Connection = Depends(db.get_db)): + return {"generated_at": to_db(utcnow()), "jobs": repo.list_jobs(conn)} + + +@router.get("/jobs/{job_id}") +def get_job(job_id: int, conn: sqlite3.Connection = Depends(db.get_db)): + job = repo.get_job(conn, job_id) + if job is None: + raise HTTPException(404, "Job not found") + return job + + +@router.get("/jobs/{job_id}/runs") +def list_runs( + job_id: int, + days: int = Query(HISTORY_DAYS, ge=1, le=HISTORY_DAYS), + conn: sqlite3.Connection = Depends(db.get_db), +): + if conn.execute("SELECT 1 FROM jobs WHERE id = ?", (job_id,)).fetchone() is None: + raise HTTPException(404, "Job not found") + since = utcnow() - timedelta(days=days) + return {"job_id": job_id, "since": to_db(since), "runs": repo.list_runs(conn, job_id, since)} + + +@router.get("/runs/{run_id}") +def get_run(run_id: int, conn: sqlite3.Connection = Depends(db.get_db)): + run = repo.get_run(conn, run_id) + if run is None: + raise HTTPException(404, "Run not found") + return run + + +@router.post("/ingest") +def ingest( + item: IngestIn, + key_name: str = Depends(require_ingest_key), + conn: sqlite3.Connection = Depends(db.get_db), +): + job_id, run_id = repo.ingest(conn, item) + log.info("ingest key=%s tenant=%r job=%r run=%s", key_name, item.tenant, item.job.name, run_id) + return {"job_id": job_id, "run_id": run_id} + + +async def _purge_loop(settings: Settings) -> None: + def purge() -> int: + conn = db.connect(settings.db_path) + try: + return repo.purge_old_runs(conn, settings.retention_days) + finally: + conn.close() + + while True: + try: + if deleted := await asyncio.to_thread(purge): + log.info("purged %d runs older than %d days", deleted, settings.retention_days) + except Exception: + log.exception("run purge failed") + await asyncio.sleep(PURGE_INTERVAL_SECONDS) + + +def create_app(settings: Settings | None = None) -> FastAPI: + settings = settings or load_settings() + + @asynccontextmanager + async def lifespan(app: FastAPI): + db.init_db(settings.db_path) + task = asyncio.create_task(_purge_loop(settings)) + try: + yield + finally: + task.cancel() + + # Interactive docs are off: nginx's strict CSP would block their CDN assets anyway. + app = FastAPI( + title="Backup Dashboard API", + lifespan=lifespan, + docs_url=None, + redoc_url=None, + openapi_url="/api/v1/openapi.json", + ) + app.state.settings = settings + app.include_router(router) + + if settings.serve_static: + from fastapi.staticfiles import StaticFiles + + public = Path(__file__).resolve().parents[2] / "public" + app.mount("/", StaticFiles(directory=public, html=True), name="static") + + return app diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..53f5070 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,63 @@ +import json +from typing import Annotated, Any, Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, StringConstraints, field_validator, model_validator + +RunStatus = Literal["running", "success", "warning", "failed"] +Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=200)] + +MAX_MESSAGE_CHARS = 32_000 +MAX_DETAILS_BYTES = 16_000 + + +class Strict(BaseModel): + # Reject unknown fields so a typo in a collector script fails loudly instead of being dropped. + model_config = ConfigDict(extra="forbid") + + +class JobIn(Strict): + external_id: Name = Field(description="The source system's own stable ID for the job") + name: Name + # Omit to leave the stored value untouched; send null to clear it. + next_run_at: AwareDatetime | None = None + + +class RunIn(Strict): + external_id: Name | None = Field( + None, description="The source's ID for this run. Defaults to started_at, so a 'running' " + "report and its later completion report merge into one run.") + status: RunStatus + started_at: AwareDatetime + # Omit for a finished run to use the time the report was received. + finished_at: AwareDatetime | None = None + message: str = "" + size_bytes: int | None = Field(None, ge=0) + details: dict[str, Any] = Field(default_factory=dict) + + @field_validator("message") + @classmethod + def truncate_message(cls, v: str) -> str: + # Backup logs can be huge; keep the head, which is where the summary usually is. + return v if len(v) <= MAX_MESSAGE_CHARS else v[:MAX_MESSAGE_CHARS] + "\n… [truncated]" + + @field_validator("details") + @classmethod + def limit_details(cls, v: dict[str, Any]) -> dict[str, Any]: + if len(json.dumps(v)) > MAX_DETAILS_BYTES: + raise ValueError(f"details must serialize to at most {MAX_DETAILS_BYTES} bytes") + return v + + @model_validator(mode="after") + def check_times(self) -> "RunIn": + if self.status == "running" and self.finished_at is not None: + raise ValueError("a running run cannot have finished_at") + if self.finished_at is not None and self.finished_at < self.started_at: + raise ValueError("finished_at is before started_at") + return self + + +class IngestIn(Strict): + tenant: Name = Field(description="Company name as shown on the dashboard") + source_system: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)] + job: JobIn + run: RunIn | None = Field(None, description="Omit to only register/update the job (e.g. next_run_at)") diff --git a/backend/app/repo.py b/backend/app/repo.py new file mode 100644 index 0000000..7328cf5 --- /dev/null +++ b/backend/app/repo.py @@ -0,0 +1,162 @@ +import json +import sqlite3 +from datetime import datetime, timedelta +from typing import Any + +from .db import transaction +from .models import IngestIn +from .timeutil import to_db, utcnow + +# One row per job: `cur` is the most recent run (may be in progress), `fin` the most recent +# finished one. "Last run" on the dashboard is fin.finished_at, so a job that's currently +# running still shows when its previous run completed. +_JOB_SQL = """ +SELECT j.id, t.name AS company_name, j.name AS job_name, j.source_system, j.next_run_at, + cur.status, + CASE WHEN cur.status = 'running' THEN cur.started_at END AS running_since, + fin.status AS last_status, + fin.started_at AS last_started_at, + fin.finished_at AS last_run_at, + unixepoch(fin.finished_at) - unixepoch(fin.started_at) AS duration_seconds, + fin.size_bytes, + fin.details, + COALESCE(fin.message, cur.message, '') AS message +FROM jobs j +JOIN tenants t ON t.id = j.tenant_id +LEFT JOIN runs cur ON cur.id = ( + SELECT r.id FROM runs r WHERE r.job_id = j.id + ORDER BY r.started_at DESC, r.id DESC LIMIT 1) +LEFT JOIN runs fin ON fin.id = ( + SELECT r.id FROM runs r WHERE r.job_id = j.id AND r.finished_at IS NOT NULL + ORDER BY r.finished_at DESC, r.id DESC LIMIT 1) +""" + +_SUMMARY_FIELDS = ( + "id", "company_name", "job_name", "source_system", "status", + "running_since", "last_run_at", "next_run_at", "message", +) + + +def list_jobs(conn: sqlite3.Connection) -> list[dict[str, Any]]: + rows = conn.execute(_JOB_SQL + " ORDER BY t.name, j.name").fetchall() + return [{k: row[k] for k in _SUMMARY_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: + return None + job = dict(row) + job["details"] = json.loads(job["details"]) if job["details"] else {} + return job + + +def list_runs(conn: sqlite3.Connection, job_id: int, since: datetime) -> list[dict[str, Any]]: + # Messages/details are left out: an hourly job has ~720 runs a month and logs can be large. + # The UI fetches a single run's full record on demand. + rows = conn.execute( + """ + SELECT id, status, started_at, finished_at, + unixepoch(finished_at) - unixepoch(started_at) AS duration_seconds, size_bytes + FROM runs WHERE job_id = ? AND started_at >= ? + ORDER BY started_at DESC, id DESC + """, + (job_id, to_db(since)), + ).fetchall() + return [dict(r) for r in rows] + + +def get_run(conn: sqlite3.Connection, run_id: int) -> dict[str, Any] | None: + row = conn.execute( + """ + SELECT id, job_id, status, started_at, finished_at, + unixepoch(finished_at) - unixepoch(started_at) AS duration_seconds, + size_bytes, message, details + FROM runs WHERE id = ? + """, + (run_id,), + ).fetchone() + if row is None: + return None + run = dict(row) + run["details"] = json.loads(run["details"]) + return run + + +def ingest(conn: sqlite3.Connection, item: IngestIn) -> tuple[int, int | None]: + """Upsert tenant → job → run. Safe to call repeatedly with the same payload.""" + now = utcnow() + now_s = to_db(now) + with transaction(conn): + conn.execute("INSERT INTO tenants (name) VALUES (?) ON CONFLICT (name) DO NOTHING", (item.tenant,)) + tenant_id = conn.execute("SELECT id FROM tenants WHERE name = ?", (item.tenant,)).fetchone()[0] + + job_id = conn.execute( + """ + INSERT INTO jobs (tenant_id, source_system, external_id, name, next_run_at, created_at, updated_at) + VALUES (:tenant_id, :source, :ext, :name, :next_run, :now, :now) + ON CONFLICT (tenant_id, source_system, external_id) DO UPDATE SET + name = excluded.name, + next_run_at = CASE WHEN :set_next_run THEN excluded.next_run_at ELSE jobs.next_run_at END, + updated_at = excluded.updated_at + RETURNING id + """, + { + "tenant_id": tenant_id, + "source": item.source_system, + "ext": item.job.external_id, + "name": item.job.name, + "next_run": to_db(item.job.next_run_at) if item.job.next_run_at else None, + "set_next_run": "next_run_at" in item.job.model_fields_set, + "now": now_s, + }, + ).fetchone()[0] + + if item.run is None: + return job_id, None + + run = item.run + started = to_db(run.started_at) + finished = None + if run.status != "running": + finished = to_db(run.finished_at or max(now, run.started_at)) + params = { + "job_id": job_id, + "ext": run.external_id or started, + "status": run.status, + "started": started, + "finished": finished, + "message": run.message, + "size": run.size_bytes, + "details": json.dumps(run.details), + "now": now_s, + } + # The WHERE stops a late/retried "running" report from reopening a run that already finished. + row = conn.execute( + """ + INSERT INTO runs (job_id, external_id, status, started_at, finished_at, message, + size_bytes, details, received_at) + VALUES (:job_id, :ext, :status, :started, :finished, :message, :size, :details, :now) + ON CONFLICT (job_id, external_id) DO UPDATE SET + status = excluded.status, + started_at = excluded.started_at, + finished_at = excluded.finished_at, + message = excluded.message, + size_bytes = excluded.size_bytes, + details = excluded.details, + received_at = excluded.received_at + WHERE NOT (runs.status != 'running' AND excluded.status = 'running') + RETURNING id + """, + params, + ).fetchone() + if row is None: + row = conn.execute( + "SELECT id FROM runs WHERE job_id = :job_id AND external_id = :ext", params + ).fetchone() + return job_id, row[0] + + +def purge_old_runs(conn: sqlite3.Connection, retention_days: int) -> int: + cutoff = to_db(utcnow() - timedelta(days=retention_days)) + return conn.execute("DELETE FROM runs WHERE started_at < ?", (cutoff,)).rowcount diff --git a/backend/app/settings.py b/backend/app/settings.py new file mode 100644 index 0000000..005a73a --- /dev/null +++ b/backend/app/settings.py @@ -0,0 +1,22 @@ +import os +from dataclasses import dataclass +from pathlib import Path + +# The modal shows this many days of run history; retention can never be shorter. +HISTORY_DAYS = 30 + + +@dataclass(frozen=True) +class Settings: + db_path: Path + retention_days: int = 90 + # Dev only: serve ../public from the API so you can run without nginx. + serve_static: bool = False + + +def load_settings() -> Settings: + return Settings( + db_path=Path(os.environ.get("BACKUP_DASH_DB", "backup-dashboard.db")), + retention_days=max(HISTORY_DAYS, int(os.environ.get("BACKUP_DASH_RETENTION_DAYS", "90"))), + serve_static=os.environ.get("BACKUP_DASH_SERVE_STATIC") == "1", + ) diff --git a/backend/app/timeutil.py b/backend/app/timeutil.py new file mode 100644 index 0000000..18acd07 --- /dev/null +++ b/backend/app/timeutil.py @@ -0,0 +1,12 @@ +from datetime import UTC, datetime + +# Every timestamp in the DB uses this exact format, so string comparison == chronological comparison. +DB_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +def utcnow() -> datetime: + return datetime.now(UTC).replace(microsecond=0) + + +def to_db(dt: datetime) -> str: + return dt.astimezone(UTC).strftime(DB_FORMAT) diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..b4189db --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Admin CLI. Uses the same BACKUP_DASH_DB env var as the API. + + python manage.py create-key veeam-acme # prints the key once + python manage.py list-keys + python manage.py revoke-key veeam-acme + python manage.py list-jobs + python manage.py delete-job 12 # e.g. a decommissioned job stuck as Overdue + python manage.py seed-demo # 30 days of fake history for trying the UI +""" + +import argparse +import random +import sys +from datetime import timedelta + +from app import db, repo +from app.auth import generate_key, hash_key +from app.models import IngestIn +from app.settings import load_settings +from app.timeutil import to_db, utcnow + + +def create_key(conn, args): + key = generate_key() + try: + conn.execute( + "INSERT INTO api_keys (name, key_hash, created_at) VALUES (?, ?, ?)", + (args.name, hash_key(key), to_db(utcnow())), + ) + except db.sqlite3.IntegrityError: + sys.exit(f"A key named {args.name!r} already exists") + print(f"API key for {args.name!r} (shown once, store it in the collector's config):\n{key}") + + +def list_keys(conn, args): + for r in conn.execute("SELECT name, created_at, last_used_at, revoked_at FROM api_keys ORDER BY name"): + state = f"revoked {r['revoked_at']}" if r["revoked_at"] else "active" + print(f"{r['name']:<30} {state:<30} created {r['created_at']} last used {r['last_used_at'] or 'never'}") + + +def revoke_key(conn, args): + cur = conn.execute( + "UPDATE api_keys SET revoked_at = ? WHERE name = ? AND revoked_at IS NULL", (to_db(utcnow()), args.name) + ) + sys.exit(0 if cur.rowcount else f"No active key named {args.name!r}") + + +def list_jobs(conn, args): + for j in repo.list_jobs(conn): + print(f"{j['id']:>5} {j['company_name']:<28} {j['job_name']:<40} {j['status'] or '-':<8} last {j['last_run_at'] or 'never'}") + + +def delete_job(conn, args): + cur = conn.execute("DELETE FROM jobs WHERE id = ?", (args.job_id,)) + sys.exit(0 if cur.rowcount else f"No job with id {args.job_id}") + + +DEMO = [ + ("Acme Corp", "Veeam B&R", "File Server - Nightly", 24), + ("Acme Corp", "Veeam B&R", "SQL01 - Transaction Logs", 4), + ("Bluewater Dental", "Datto", "Workstations - Daily", 24), + ("Bluewater Dental", "Datto", "Imaging Server - Hourly", 1), + ("Northfield Logistics", "Acronis", "Exchange - Nightly", 24), +] + + +def seed_demo(conn, args): + rng = random.Random(42) + now = utcnow() + for tenant, source, name, every_h in DEMO: + ext = name.lower().replace(" ", "-") + # Offset so runs don't all line up exactly on the current time + start = now - timedelta(days=30) + timedelta(minutes=rng.randint(5, every_h * 60 - 5)) + while start < now: + dur = timedelta(minutes=rng.randint(2, 50 if every_h > 1 else 15)) + end = start + dur + status = "running" if end > now else rng.choices(["success", "warning", "failed"], [90, 7, 3])[0] + run = {"status": status, "started_at": start.isoformat()} + if status != "running": + run |= {"finished_at": end.isoformat(), "size_bytes": rng.randint(10**8, 10**11), + "message": {"success": "Job completed successfully.", + "warning": "Completed with warnings: 1 file skipped (locked).", + "failed": "Error: target repository unreachable."}[status]} + repo.ingest(conn, IngestIn.model_validate({ + "tenant": tenant, "source_system": source, + "job": {"external_id": ext, "name": name, + "next_run_at": (start + timedelta(hours=every_h)).isoformat()}, + "run": run, + })) + start += timedelta(hours=every_h) + print(f"Seeded {len(DEMO)} demo jobs") + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = p.add_subparsers(required=True) + s = sub.add_parser("create-key"); s.add_argument("name"); s.set_defaults(fn=create_key) + s = sub.add_parser("list-keys"); s.set_defaults(fn=list_keys) + s = sub.add_parser("revoke-key"); s.add_argument("name"); s.set_defaults(fn=revoke_key) + s = sub.add_parser("list-jobs"); s.set_defaults(fn=list_jobs) + s = sub.add_parser("delete-job"); s.add_argument("job_id", type=int); s.set_defaults(fn=delete_job) + s = sub.add_parser("seed-demo"); s.set_defaults(fn=seed_demo) + args = p.parse_args() + + settings = load_settings() + db.init_db(settings.db_path) + conn = db.connect(settings.db_path) + try: + args.fn(conn, args) + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..fa5cdcb --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,2 @@ +httpx +pytest diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c60a450 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..30fe42a --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,124 @@ +from datetime import 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") + 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 ('test', ?, ?)", + (hash_key(key), to_db(utcnow()))) + conn.close() + c.headers["Authorization"] = f"Bearer {key}" + yield c + + +def iso(delta: timedelta) -> str: + return (utcnow() + delta).isoformat() + + +def payload(run=None, **job): + body = {"tenant": "Acme Corp", "source_system": "Veeam B&R", + "job": {"external_id": "job-1", "name": "File Server", **job}} + if run is not None: + body["run"] = run + return body + + +def test_ingest_requires_valid_key(client): + assert client.post("/api/v1/ingest", json=payload(), headers={"Authorization": ""}).status_code == 401 + assert client.post("/api/v1/ingest", json=payload(), headers={"Authorization": "Bearer nope"}).status_code == 401 + + +def test_running_then_finished_merges_into_one_run(client): + started = iso(-timedelta(hours=1)) + r = client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": started})) + assert r.status_code == 200, r.text + job_id = r.json()["job_id"] + + job = client.get("/api/v1/jobs").json()["jobs"][0] + assert job["status"] == "running" + assert job["running_since"] is not None + assert job["last_run_at"] is None # nothing has finished yet + + finished = iso(-timedelta(minutes=10)) + client.post("/api/v1/ingest", json=payload( + {"status": "warning", "started_at": started, "finished_at": finished, "message": "1 file skipped"})) + + detail = client.get(f"/api/v1/jobs/{job_id}").json() + assert detail["status"] == "warning" + assert detail["running_since"] is None + assert detail["last_run_at"] == to_db(utcnow().fromisoformat(finished)) + assert detail["duration_seconds"] == 50 * 60 + assert detail["message"] == "1 file skipped" + assert len(client.get(f"/api/v1/jobs/{job_id}/runs").json()["runs"]) == 1 + + +def test_late_running_report_does_not_reopen_finished_run(client): + started = iso(-timedelta(hours=1)) + client.post("/api/v1/ingest", json=payload({"status": "success", "started_at": started, "finished_at": iso(-timedelta(minutes=5))})) + client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": started})) + assert client.get("/api/v1/jobs").json()["jobs"][0]["status"] == "success" + + +def test_last_run_is_previous_finish_while_new_run_in_progress(client): + prev_end = iso(-timedelta(hours=23)) + client.post("/api/v1/ingest", json=payload({"status": "failed", "started_at": iso(-timedelta(hours=24)), "finished_at": prev_end, "message": "boom"})) + client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": iso(-timedelta(minutes=5))})) + job = client.get("/api/v1/jobs").json()["jobs"][0] + assert job["status"] == "running" + assert job["last_run_at"] == to_db(utcnow().fromisoformat(prev_end)) + detail = client.get(f"/api/v1/jobs/{job['id']}").json() + assert detail["last_status"] == "failed" + assert detail["message"] == "boom" + + +def test_next_run_at_only_changes_when_sent(client): + nxt = iso(timedelta(hours=5)) + client.post("/api/v1/ingest", json=payload(next_run_at=nxt)) + client.post("/api/v1/ingest", json=payload()) # omitted → keep + assert client.get("/api/v1/jobs").json()["jobs"][0]["next_run_at"] == to_db(utcnow().fromisoformat(nxt)) + client.post("/api/v1/ingest", json=payload(next_run_at=None)) # explicit null → clear + assert client.get("/api/v1/jobs").json()["jobs"][0]["next_run_at"] is None + + +def test_history_window_and_run_detail(client): + for days_ago in (40, 20, 1): + start = iso(-timedelta(days=days_ago)) + client.post("/api/v1/ingest", json=payload({"status": "success", "started_at": start, + "finished_at": start, "message": f"{days_ago}d"})) + job_id = client.get("/api/v1/jobs").json()["jobs"][0]["id"] + runs = client.get(f"/api/v1/jobs/{job_id}/runs").json()["runs"] + assert len(runs) == 2 # the 40-day-old run is outside the 30-day window + assert "message" not in runs[0] + run = client.get(f"/api/v1/runs/{runs[0]['id']}").json() + assert run["message"] == "1d" + assert client.get(f"/api/v1/jobs/{job_id}/runs?days=31").status_code == 422 + + +@pytest.mark.parametrize("run", [ + {"status": "running", "started_at": iso(-timedelta(hours=1)), "finished_at": iso(timedelta(0))}, + {"status": "success", "started_at": iso(timedelta(0)), "finished_at": iso(-timedelta(hours=1))}, + {"status": "success", "started_at": "2026-01-01T00:00:00"}, # no timezone + {"status": "done", "started_at": iso(timedelta(0))}, +]) +def test_rejects_bad_runs(client, run): + assert client.post("/api/v1/ingest", json=payload(run)).status_code == 422 + + +def test_tenant_name_is_case_insensitive(client): + client.post("/api/v1/ingest", json=payload()) + body = payload() + body["tenant"] = "ACME CORP" + client.post("/api/v1/ingest", json=body) + assert len(client.get("/api/v1/jobs").json()["jobs"]) == 1 diff --git a/deploy/backup-dashboard-api.service b/deploy/backup-dashboard-api.service new file mode 100644 index 0000000..6bea917 --- /dev/null +++ b/deploy/backup-dashboard-api.service @@ -0,0 +1,23 @@ +[Unit] +Description=Backup Dashboard API +After=network.target + +[Service] +User=backupdash +Group=backupdash +WorkingDirectory=/opt/backup-dashboard/backend +Environment=BACKUP_DASH_DB=/var/lib/backup-dashboard/backup-dashboard.db +Environment=BACKUP_DASH_RETENTION_DAYS=90 +# Bound to localhost: only nginx can reach it, so nginx's auth can't be bypassed from outside. +# One worker is plenty at this scale and keeps SQLite writes simple. +ExecStart=/opt/backup-dashboard/venv/bin/uvicorn --factory app.main:create_app --host 127.0.0.1 --port 8000 --proxy-headers +Restart=on-failure + +StateDirectory=backup-dashboard +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..685b247 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,52 @@ +server { + listen 80; + server_name backups.example.com; + # Basic auth sends the password with every request — never serve this over plain HTTP + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + http2 on; + server_name backups.example.com; + + # Paths as created by `certbot certonly --nginx -d backups.example.com` + ssl_certificate /etc/letsencrypt/live/backups.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/backups.example.com/privkey.pem; + + root /var/www/backup-dashboard/public; + index index.html; + + # Strict CSP works because there is no inline JS/CSS + add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Strict-Transport-Security "max-age=31536000" always; + + # Dashboard users. Create with: htpasswd -B -c /etc/nginx/backup-dashboard.htpasswd + # To move to Microsoft 365 sign-in later, replace these two lines with an auth_request to + # oauth2-proxy (provider=entra-id); the app itself doesn't need to change. + auth_basic "Backup Monitor"; + auth_basic_user_file /etc/nginx/backup-dashboard.htpasswd; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Collectors authenticate with their own API key (Authorization: Bearer …), checked by the app + location = /api/v1/ingest { + auth_basic off; + client_max_body_size 256k; + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 0000000..46619d7 --- /dev/null +++ b/public/css/styles.css @@ -0,0 +1,341 @@ +:root { + color-scheme: light dark; + --bg: #f6f7f9; + --surface: #ffffff; + --surface-alt: #f0f2f5; + --border: #d9dde3; + --text: #1f2328; + --muted: #656d76; + --accent: #0969da; + + /* Status palette: each has fg/bg/border so badges stay legible in both themes */ + --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; + + --radius: 8px; + --shadow: 0 1px 2px rgb(0 0 0 / 6%), 0 4px 12px rgb(0 0 0 / 4%); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 15px; + line-height: 1.45; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --surface: #161b22; + --surface-alt: #1c2128; + --border: #30363d; + --text: #e6edf3; + --muted: #8d96a0; + --accent: #4493f8; + --success-fg: #6fdd8b; --success-bg: #12261e; --success-bd: #2ea043; + --warning-fg: #f2cc60; --warning-bg: #272115; --warning-bd: #9e6a03; + --failed-fg: #ffa198; --failed-bg: #2d1214; --failed-bd: #da3633; + --unknown-fg: #9da7b3; --unknown-bg: #21262d; --unknown-bd: #484f58; + --running-fg: #79c0ff; --running-bg: #0c2d4b; --running-bd: #1f6feb; + --shadow: none; + } +} + +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); +} + +.muted { color: var(--muted); } + +.visually-hidden { + position: absolute !important; + width: 1px; height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* ---------- Layout ---------- */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + padding: 0.75rem 1.5rem; + background: var(--surface); + border-bottom: 1px solid var(--border); +} +.topbar__title { margin: 0; font-size: 1.25rem; } +.topbar__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; } + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 1.25rem 1.5rem 3rem; +} + +/* ---------- Controls ---------- */ +.btn { + font: inherit; + padding: 0.4rem 0.9rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-alt); + color: var(--text); + cursor: pointer; +} +.btn:hover { border-color: var(--muted); } +.btn:disabled { opacity: 0.6; cursor: progress; } +.btn--icon { padding: 0.25rem 0.55rem; line-height: 1; } + +.input { + font: inherit; + width: min(100%, 360px); + padding: 0.45rem 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); +} + +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.toolbar { margin: 1rem 0; } + +/* ---------- Banner ---------- */ +.banner { + padding: 0.75rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; + border: 1px solid; +} +.banner--error { background: var(--failed-bg); color: var(--failed-fg); border-color: var(--failed-bd); } + +/* ---------- Summary cards ---------- */ +.summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.75rem; +} +.summary-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + padding: 0.9rem 1rem; + font: inherit; + text-align: left; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-left-width: 4px; + border-radius: var(--radius); + box-shadow: var(--shadow); + cursor: pointer; +} +.summary-card[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: -1px; } +.summary-card__count { font-size: 1.75rem; font-weight: 600; font-variant-numeric: tabular-nums; } +.summary-card__label { font-size: 0.85rem; color: var(--muted); } +.summary-card--failed { border-left-color: var(--failed-bd); } +.summary-card--warning { border-left-color: var(--warning-bd); } +.summary-card--running { border-left-color: var(--running-bd); } +.summary-card--success { border-left-color: var(--success-bd); } +.summary-card--overdue { border-left-color: var(--unknown-bd); } + +/* ---------- Table ---------- */ +.table-wrap { + overflow-x: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.jobs { width: 100%; border-collapse: collapse; } +.jobs th, .jobs td { + padding: 0.65rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.jobs tbody tr:last-child td { border-bottom: none; } +.jobs tbody tr:hover { background: var(--surface-alt); } +.jobs th { background: var(--surface-alt); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; } + +.sort { + all: unset; + cursor: pointer; + color: var(--muted); + font-weight: 600; +} +.sort::after { content: " ↕"; opacity: 0.4; } +th[aria-sort="ascending"] .sort::after { content: " ↑"; opacity: 1; } +th[aria-sort="descending"] .sort::after { content: " ↓"; opacity: 1; } +th[aria-sort="ascending"] .sort, +th[aria-sort="descending"] .sort { color: var(--text); } + +.time { display: flex; flex-direction: column; } +.time__rel { font-size: 0.8rem; color: var(--muted); } + +.empty { padding: 2rem; text-align: center; color: var(--muted); margin: 0; } + +/* ---------- Status badges ---------- */ +.badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.65rem; + font: inherit; + font-size: 0.85rem; + font-weight: 600; + border: 1px solid; + border-radius: 999px; +} +button.badge { cursor: pointer; } +button.badge:hover { filter: brightness(0.95); } +/* Icon + text, not colour alone, so it's readable for colour-blind users */ +.badge__icon { font-size: 0.75rem; } +.badge--success { color: var(--success-fg); background: var(--success-bg); border-color: var(--success-bd); } +.badge--warning { color: var(--warning-fg); background: var(--warning-bg); border-color: var(--warning-bd); } +.badge--failed { color: var(--failed-fg); background: var(--failed-bg); border-color: var(--failed-bd); } +.badge--unknown { color: var(--unknown-fg); background: var(--unknown-bg); border-color: var(--unknown-bd); } +.badge--running { color: var(--running-fg); background: var(--running-bg); border-color: var(--running-bd); } +@media (prefers-reduced-motion: no-preference) { + .badge--running .badge__icon { animation: spin 2s linear infinite; } +} +@keyframes spin { to { transform: rotate(360deg); } } + +.tag-overdue { + margin-left: 0.5rem; + padding: 0.05rem 0.45rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 4px; + color: var(--failed-fg); + background: var(--failed-bg); +} + +/* ---------- Modal ---------- */ +.modal { + width: min(720px, calc(100vw - 2rem)); + max-height: calc(100vh - 4rem); + padding: 0; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; +} +.modal::backdrop { background: rgb(0 0 0 / 45%); } +.modal__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); +} +.modal__title { margin: 0; font-size: 1.15rem; } +.modal__header p { margin: 0.15rem 0 0; } +.modal__body { padding: 1rem 1.25rem 1.25rem; } + +.detail-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.4rem 1.25rem; + margin: 1rem 0; +} +.detail-grid dt { color: var(--muted); } +.detail-grid dd { margin: 0; overflow-wrap: anywhere; } + +.detail-section-title { font-size: 0.85rem; text-transform: uppercase; color: var(--muted); margin: 1.25rem 0 0.5rem; } + +.message { + margin: 0; + padding: 0.75rem 1rem; + max-height: 320px; + overflow: auto; + white-space: pre-wrap; /* preserve backup-software line breaks */ + overflow-wrap: anywhere; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 0.85rem; + background: var(--surface-alt); + border: 1px solid var(--border); + border-left-width: 4px; + border-radius: 6px; +} +.message--failed { border-left-color: var(--failed-bd); } +.message--warning { border-left-color: var(--warning-bd); } +.message--success { border-left-color: var(--success-bd); } +.message--running { border-left-color: var(--running-bd); } + +.detail-status { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } + +.link { + all: unset; + color: var(--accent); + cursor: pointer; + text-decoration: underline; +} + +/* ---------- Run history ---------- */ +.history-stats { margin: 0 0 0.6rem; font-size: 0.9rem; } + +/* One cell per day, oldest → today. Colour + tooltip; the runs table below is the accessible detail. */ +.day-strip { + display: grid; + grid-template-columns: repeat(30, 1fr); + gap: 3px; +} +.day { + height: 28px; + padding: 0; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--surface-alt); + cursor: pointer; +} +.day:disabled { cursor: default; opacity: 0.6; } +.day:not(:disabled):hover { filter: brightness(0.9); } +.day[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: 1px; } +.day--success { background: var(--success-bd); border-color: var(--success-bd); } +.day--warning { background: var(--warning-bd); border-color: var(--warning-bd); } +.day--failed { background: var(--failed-bd); border-color: var(--failed-bd); } +.day--running { background: var(--running-bd); border-color: var(--running-bd); } +.day--unknown { background: var(--unknown-bd); border-color: var(--unknown-bd); } +.day-strip__ticks { + display: flex; + justify-content: space-between; + margin-top: 0.25rem; + font-size: 0.75rem; +} + +.history-filter { margin: 0.75rem 0 0; font-size: 0.85rem; } + +.runs-wrap { + margin-top: 0.75rem; + max-height: 300px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 6px; +} +.runs { width: 100%; border-collapse: collapse; font-size: 0.85rem; } +.runs th, .runs td { + padding: 0.4rem 0.75rem; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid var(--border); +} +.runs th { + position: sticky; + top: 0; + background: var(--surface-alt); + color: var(--muted); + font-size: 0.75rem; + text-transform: uppercase; +} +.runs tbody tr:last-child td { border-bottom: none; } +.runs tr.is-selected td { background: color-mix(in srgb, var(--accent) 16%, transparent); } +.runs .badge { font-size: 0.75rem; padding: 0.1rem 0.5rem; } \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..1ffdef3 --- /dev/null +++ b/public/index.html @@ -0,0 +1,85 @@ + + + + + + Backup Monitor + + + + + +
+

Backup Monitor

+
+ Loading… + +
+
+ +
+ + + +
+ + + + + + +
+ +
+ + +
+ +
+ + + + + + + + + + + +
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..6ba1f27 --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,61 @@ +import { CONFIG } from './config.js'; + +export class ApiError extends Error { + constructor(message, status = null) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +async function request(path) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs); + try { + const res = await fetch(`${CONFIG.apiBaseUrl}${path}`, { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', // ready for cookie/session auth behind nginx + signal: controller.signal, + }); + // 401 means the nginx/SSO session lapsed; a reload sends the user back through sign-in + if (res.status === 401) throw new ApiError('Your session has expired. Reload the page to sign in again', 401); + if (!res.ok) throw new ApiError(`Server returned HTTP ${res.status}`, res.status); + return await res.json(); + } catch (err) { + if (err.name === 'AbortError') throw new ApiError('Request timed out'); + if (err instanceof ApiError) throw err; + throw new ApiError(`Network error: ${err.message}`); + } finally { + clearTimeout(timer); + } +} + +const httpApi = { + /** GET /jobs → { generated_at, jobs: Job[] } */ + async listJobs() { + const data = await request('/jobs'); + if (!Array.isArray(data?.jobs)) throw new ApiError('Unexpected response shape from /jobs'); + return data; + }, + /** GET /jobs/:id → JobDetail */ + getJob(id) { + return request(`/jobs/${encodeURIComponent(id)}`); + }, + /** GET /jobs/:id/runs?days=N → { job_id, since, runs: RunSummary[] } (newest first, no messages) */ + listRuns(id, days) { + return request(`/jobs/${encodeURIComponent(id)}/runs?days=${encodeURIComponent(days)}`); + }, + /** GET /runs/:id → Run (with message and details) */ + getRun(id) { + return request(`/runs/${encodeURIComponent(id)}`); + }, +}; + +// Mock is loaded lazily so it never ships in the production request path. +export async function getApi() { + if (CONFIG.useMock) { + const { mockApi } = await import('./mock.js'); + return mockApi; + } + return httpApi; +} \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..5d49221 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,527 @@ +import { CONFIG } from './config.js'; +import { getApi } from './api.js'; +import { + parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes, +} from './format.js'; + +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 { + id: String(j.id), + company: j.company_name ?? '', + name: j.job_name ?? '', + status: normalizeStatus(j.status), + message: j.message ?? '', + runningSince: parseDate(j.running_since), + // When the most recent run *finished*; a job that's running keeps showing its previous finish + lastRun: parseDate(j.last_run_at), + nextRun: parseDate(j.next_run_at), + }; +} + +// A running job isn't silently dead, so it's never overdue (a long run can pass its next slot) +const isOverdue = (job, now = Date.now()) => + job.status !== 'running' && !!job.nextRun && job.nextRun.getTime() + CONFIG.overdueGraceMinutes * 60_000 < now; + +// ---------- State ---------- +const state = { + jobs: [], + filter: 'all', + search: '', + sort: { key: 'status', dir: 1 }, + lastSuccess: null, +}; + +// ---------- DOM ---------- +const $ = (sel) => document.querySelector(sel); +const dom = { + tbody: $('#jobs-body'), + empty: $('#empty-state'), + search: $('#search'), + refreshBtn: $('#refresh-btn'), + lastUpdated: $('#last-updated'), + errorBanner: $('#error-banner'), + dialog: $('#detail-dialog'), + detailTitle: $('#detail-title'), + detailSubtitle: $('#detail-subtitle'), + detailBody: $('#detail-body'), +}; + +// Tiny element builder. All text goes through append() → text nodes, so API data can never inject HTML. +function el(tag, props = {}, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(props)) { + if (v == null || v === false) continue; + if (k === 'class') node.className = v; + else if (k === 'dataset') Object.assign(node.dataset, v); + else node.setAttribute(k, v === true ? '' : v); + } + node.append(...children.flat().filter((c) => c != null && c !== false)); + return node; +} + +// ---------- 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); + +function timeCell(date, extra = null) { + if (!date) return el('td', {}, el('span', { class: 'muted' }, 'Never')); + return el('td', {}, + el('time', { class: 'time', datetime: date.toISOString(), title: date.toString() }, + el('span', {}, formatDateTime(date), extra), + el('span', { class: 'time__rel' }, formatRelative(date)))); +} + +function renderRow(job) { + const overdueTag = isOverdue(job) ? el('span', { class: 'tag-overdue' }, 'Overdue') : null; + return el('tr', {}, + el('td', {}, job.company), + el('td', {}, job.name), + el('td', {}, statusBadge(job.status, { + asButton: true, opensDialog: true, dataset: { jobId: job.id }, title: runningTitle(job.runningSince), + })), + timeCell(job.lastRun), + timeCell(job.nextRun, overdueTag)); +} + +const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }); + +function cmpDate(a, b) { + if (!a && !b) return 0; + if (!a) return 1; + if (!b) return -1; + return a - b; +} + +const SORTERS = { + company: (a, b) => collator.compare(a.company, b.company), + name: (a, b) => collator.compare(a.name, b.name), + status: (a, b) => STATUS[a.status].rank - STATUS[b.status].rank, + lastRun: (a, b) => cmpDate(a.lastRun, b.lastRun), + nextRun: (a, b) => cmpDate(a.nextRun, b.nextRun), +}; + +function visibleJobs() { + const q = state.search.trim().toLowerCase(); + const now = Date.now(); + const { key, dir } = state.sort; + return state.jobs + .filter((j) => { + if (state.filter === 'overdue') return isOverdue(j, now); + return state.filter === 'all' || j.status === state.filter; + }) + .filter((j) => !q || j.company.toLowerCase().includes(q) || j.name.toLowerCase().includes(q)) + // Stable tie-breakers keep row order from jumping around on each refresh + .sort((a, b) => SORTERS[key](a, b) * dir || SORTERS.company(a, b) || SORTERS.name(a, b)); +} + +function renderSummary() { + const now = Date.now(); + const counts = { all: state.jobs.length, failed: 0, warning: 0, running: 0, success: 0, overdue: 0 }; + for (const j of state.jobs) { + if (j.status in counts) counts[j.status]++; + if (isOverdue(j, now)) counts.overdue++; + } + for (const [k, v] of Object.entries(counts)) { + const node = document.querySelector(`[data-count="${k}"]`); + if (node) node.textContent = v; + } + document.querySelectorAll('.summary-card').forEach((card) => { + card.setAttribute('aria-pressed', String(card.dataset.filter === state.filter)); + }); +} + +function renderSortHeaders() { + document.querySelectorAll('th[aria-sort]').forEach((th) => { + const key = th.querySelector('.sort').dataset.sort; + th.setAttribute('aria-sort', + key === state.sort.key ? (state.sort.dir === 1 ? 'ascending' : 'descending') : 'none'); + }); +} + +function render() { + const rows = visibleJobs(); + dom.tbody.replaceChildren(...rows.map(renderRow)); + dom.empty.hidden = rows.length > 0; + renderSummary(); + renderSortHeaders(); +} + +// ---------- Data loading ---------- +let inFlight = null; +let pollTimer = null; + +function refresh() { + if (inFlight) return inFlight; // de-dupe overlapping manual + scheduled refreshes + dom.refreshBtn.disabled = true; + inFlight = (async () => { + try { + const data = await api.listJobs(); + state.jobs = data.jobs.map(normalizeJob); + state.lastSuccess = new Date(); + dom.errorBanner.hidden = true; + dom.lastUpdated.textContent = `Updated ${formatTime(state.lastSuccess)}`; + } catch (err) { + // Keep showing stale data, but make it obvious it's stale + dom.errorBanner.textContent = state.lastSuccess + ? `Couldn't refresh (${err.message}). Showing data from ${formatTime(state.lastSuccess)}.` + : `Couldn't load backup status (${err.message}).`; + dom.errorBanner.hidden = false; + dom.lastUpdated.textContent = 'Update failed'; + console.error(err); + } finally { + render(); + dom.refreshBtn.disabled = false; + inFlight = null; + } + })(); + return inFlight; +} + +function schedulePoll() { + clearTimeout(pollTimer); + pollTimer = setTimeout(async () => { + await refresh(); + schedulePoll(); + }, CONFIG.refreshIntervalMs); +} + +// Don't poll from background tabs (e.g. a wall display that's been switched away); catch up on return +document.addEventListener('visibilitychange', () => { + if (document.hidden) clearTimeout(pollTimer); + else refresh().then(schedulePoll); +}); + +// ---------- Detail modal ---------- +// Every async result checks reqId first, so a slow response for job A can never paint over job B. +const modal = { + reqId: 0, + job: null, + detail: null, + runs: null, // null = loading, [] = none + runsError: null, + dayFilter: null, // local YYYY-MM-DD, or null for all days + selectedRunId: null, // null = the job's latest finished run (from the detail call) + runMessage: null, // { status, text, startedAt } for the selected run + 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)]; +} + +function renderSummarySection() { + const { job, detail } = modal; + const status = detail ? normalizeStatus(detail.status) : job.status; + const runningSince = detail ? parseDate(detail.running_since) : job.runningSince; + 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; + + return el('section', {}, + el('div', { class: 'detail-status' }, + statusBadge(status), + runningSince ? el('span', { class: 'muted' }, `since ${formatDateTime(runningSince)}`) : null), + el('dl', { class: 'detail-grid' }, + // While a job is running, its "last run" is the previous one — say how that went + status === 'running' && lastStatus ? detailRow('Previous result', STATUS[lastStatus].label) : [], + detailRow('Last finished', formatDateTime(detail ? parseDate(detail.last_run_at) : job.lastRun)), + detailRow('Next scheduled', formatDateTime(detail ? parseDate(detail.next_run_at) : job.nextRun)), + detailRow('Duration', formatDuration(detail?.duration_seconds)), + detailRow('Size', formatBytes(detail?.size_bytes)), + detailRow('Source', detail?.source_system))); +} + +function renderHistorySection() { + const section = el('section', { id: 'detail-history' }, + el('h3', { class: 'detail-section-title' }, `Last ${CONFIG.historyDays} days`)); + + if (modal.runsError) { + section.append(el('p', { class: 'banner banner--error' }, `Couldn't load run history: ${modal.runsError}`)); + return section; + } + if (!modal.runs) { + section.append(el('p', { class: 'muted' }, 'Loading history…')); + return section; + } + + const days = buildDays(modal.runs, CONFIG.historyDays); + const finished = modal.runs.filter((r) => r.finishedAt); + const ok = finished.filter((r) => r.status === 'success').length; + const totals = {}; + for (const r of modal.runs) totals[r.status] = (totals[r.status] ?? 0) + 1; + + section.append( + el('p', { class: 'history-stats' }, + modal.runs.length + ? `${modal.runs.length} runs · ${finished.length ? Math.round((ok / finished.length) * 100) : 0}% successful · ${describeCounts(totals)}` + : 'No runs recorded in this period.'), + el('div', { class: 'day-strip', role: 'group', 'aria-label': 'Daily results, oldest to newest. Select a day to filter runs.' }, + days.map((d) => { + const label = `${dayLabelFmt.format(d.date)}: ${d.total ? describeCounts(d.counts) : 'no runs'}`; + return el('button', { + type: 'button', + class: `day day--${d.status}`, + title: label, + 'aria-label': label, + 'aria-pressed': String(modal.dayFilter === d.key), + disabled: !d.total, + dataset: { day: d.key }, + }); + })), + el('div', { class: 'day-strip__ticks muted', 'aria-hidden': 'true' }, + el('span', {}, dayTickFmt.format(days[0].date)), + el('span', {}, 'Today')), + ); + + const rows = modal.dayFilter + ? modal.runs.filter((r) => localDayKey(r.startedAt) === modal.dayFilter) + : modal.runs; + if (!rows.length) return section; + + if (modal.dayFilter) { + const date = days.find((d) => d.key === modal.dayFilter)?.date; + section.append(el('p', { class: 'muted history-filter' }, + `Showing ${dayLabelFmt.format(date)} · `, + el('button', { type: 'button', class: 'link', dataset: { day: modal.dayFilter } }, 'Show all days'))); + } + + section.append(el('div', { class: 'runs-wrap' }, + el('table', { class: 'runs' }, + el('thead', {}, el('tr', {}, + ['Status', 'Started', 'Finished', 'Duration', 'Size'].map((h) => el('th', { scope: 'col' }, h)))), + el('tbody', {}, rows.map((r) => el('tr', { class: r.id === modal.selectedRunId ? 'is-selected' : null }, + el('td', {}, statusBadge(r.status, { asButton: true, dataset: { runId: r.id } })), + el('td', {}, formatDateTime(r.startedAt)), + el('td', {}, r.finishedAt ? formatDateTime(r.finishedAt) : el('span', { class: 'muted' }, 'In progress')), + el('td', {}, formatDuration(r.duration) ?? '—'), + el('td', {}, formatBytes(r.size) ?? '—'))))))); + return section; +} + +function renderMessageSection() { + const { detail, job } = modal; + let status; + let text; + let heading = 'Message'; + if (modal.selectedRunId) { + const run = modal.runs?.find((r) => r.id === modal.selectedRunId); + heading = `Message · run started ${formatDateTime(run?.startedAt)}`; + status = run?.status ?? 'unknown'; + text = modal.runMessageError + ? `Couldn't load this run: ${modal.runMessageError}` + : modal.runMessage?.text ?? 'Loading…'; + } else { + status = detail?.last_status ? normalizeStatus(detail.last_status) : job.status; + text = detail?.message ?? job.message; + } + + const extras = !modal.selectedRunId && detail?.details && typeof detail.details === 'object' + ? Object.entries(detail.details).flatMap(([k, v]) => + detailRow(k.replace(/_/g, ' '), typeof v === 'object' ? JSON.stringify(v) : String(v))) + : modal.runMessage?.extras ?? []; + + return el('section', { id: 'detail-message' }, + el('h3', { class: 'detail-section-title' }, heading), + el('pre', { class: `message message--${status}` }, text || 'No message reported.'), + extras.length ? el('h3', { class: 'detail-section-title' }, 'Additional details') : null, + extras.length ? el('dl', { class: 'detail-grid' }, extras) : null); +} + +function renderModal({ loading = false, error = null } = {}) { + dom.detailBody.replaceChildren(...[ + renderSummarySection(), + loading ? el('p', { class: 'muted' }, 'Loading full details…') : null, + error ? el('p', { class: 'banner banner--error' }, `Couldn't load full details: ${error}`) : null, + renderMessageSection(), + renderHistorySection(), + ].filter(Boolean)); +} + +// Re-render just the history section, keeping the runs table's scroll position +function renderHistory() { + const wrap = dom.detailBody.querySelector('.runs-wrap'); + const scrollTop = wrap?.scrollTop ?? 0; + dom.detailBody.querySelector('#detail-history')?.replaceWith(renderHistorySection()); + const newWrap = dom.detailBody.querySelector('.runs-wrap'); + if (newWrap) newWrap.scrollTop = scrollTop; +} + +function renderMessage() { + dom.detailBody.querySelector('#detail-message')?.replaceWith(renderMessageSection()); +} + +async function openDetail(jobId) { + const job = state.jobs.find((j) => j.id === jobId); + if (!job) return; + const reqId = ++modal.reqId; + Object.assign(modal, { + job, detail: null, runs: null, runsError: null, dayFilter: null, + selectedRunId: null, runMessage: null, runMessageError: null, + }); + renderModal({ loading: true }); // show what we have immediately + if (!dom.dialog.open) dom.dialog.showModal(); + + const current = () => reqId === modal.reqId && dom.dialog.open; + const detailP = api.getJob(jobId).then( + (detail) => { if (current()) { modal.detail = detail; renderModal(); } }, + (err) => { if (current()) renderModal({ error: err.message }); }, + ); + const runsP = api.listRuns(jobId, CONFIG.historyDays).then( + (data) => { + if (!current()) return; + modal.runs = (data.runs ?? []).map(normalizeRun).filter((r) => r.startedAt); + renderHistory(); + }, + (err) => { if (current()) { modal.runsError = err.message; renderHistory(); } }, + ); + await Promise.all([detailP, runsP]); +} + +async function selectRun(runId) { + const reqId = modal.reqId; + modal.selectedRunId = runId; + modal.runMessage = null; + modal.runMessageError = null; + renderHistory(); + renderMessage(); + try { + const run = await api.getRun(runId); + if (reqId !== modal.reqId || modal.selectedRunId !== runId) return; + modal.runMessage = { + text: run.message, + extras: Object.entries(run.details ?? {}).flatMap(([k, v]) => + detailRow(k.replace(/_/g, ' '), typeof v === 'object' ? JSON.stringify(v) : String(v))), + }; + } catch (err) { + if (reqId !== modal.reqId || modal.selectedRunId !== runId) return; + modal.runMessageError = err.message; + } + renderMessage(); + dom.detailBody.querySelector('#detail-message')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); +} + +// ---------- Events ---------- +dom.tbody.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-job-id]'); + if (btn) openDetail(btn.dataset.jobId); +}); + +dom.dialog.addEventListener('click', (e) => { + // Click on the backdrop (the dialog element itself, outside its content) or the close button + if (e.target === dom.dialog || e.target.closest('[data-close]')) dom.dialog.close(); +}); + +dom.detailBody.addEventListener('click', (e) => { + const day = e.target.closest('button[data-day]'); + if (day) { + modal.dayFilter = modal.dayFilter === day.dataset.day ? null : day.dataset.day; + renderHistory(); + return; + } + const run = e.target.closest('button[data-run-id]'); + if (run) selectRun(run.dataset.runId); +}); + +document.querySelector('.summary').addEventListener('click', (e) => { + const card = e.target.closest('.summary-card'); + if (!card) return; + state.filter = card.dataset.filter; + render(); +}); + +document.querySelector('.jobs thead').addEventListener('click', (e) => { + const btn = e.target.closest('.sort'); + if (!btn) return; + const key = btn.dataset.sort; + state.sort = state.sort.key === key ? { key, dir: -state.sort.dir } : { key, dir: 1 }; + render(); +}); + +dom.search.addEventListener('input', () => { + state.search = dom.search.value; + render(); +}); + +dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll)); + +// ---------- Boot ---------- +refresh().then(schedulePoll); \ No newline at end of file diff --git a/public/js/config.js b/public/js/config.js new file mode 100644 index 0000000..9a5f73a --- /dev/null +++ b/public/js/config.js @@ -0,0 +1,15 @@ +// Central runtime config. Kept as a static file so ops can change it without a build. +const params = new URLSearchParams(window.location.search); + +export const CONFIG = Object.freeze({ + apiBaseUrl: '/api/v1', + refreshIntervalMs: 60_000, + requestTimeoutMs: 15_000, + // A job whose next scheduled run is this far in the past is flagged "Overdue". + // Catches the silent failure mode: a job stops reporting and stays green forever. + overdueGraceMinutes: 30, + // Run history shown in the job modal (the API caps this at 30) + historyDays: 30, + // ?mock=1 → use in-browser sample data, no backend needed + useMock: params.has('mock'), +}); \ No newline at end of file diff --git a/public/js/format.js b/public/js/format.js new file mode 100644 index 0000000..92459d0 --- /dev/null +++ b/public/js/format.js @@ -0,0 +1,44 @@ +const dateTimeFmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +const timeFmt = new Intl.DateTimeFormat(undefined, { timeStyle: 'medium' }); +const relFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + +const REL_UNITS = [ + ['year', 31_536_000], ['month', 2_592_000], ['week', 604_800], + ['day', 86_400], ['hour', 3_600], ['minute', 60], +]; + +export function parseDate(value) { + if (!value) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; +} + +export const formatDateTime = (d) => (d ? dateTimeFmt.format(d) : '—'); +export const formatTime = (d) => timeFmt.format(d); + +export function formatRelative(d, now = Date.now()) { + if (!d) return ''; + const diffSec = Math.round((d.getTime() - now) / 1000); + for (const [unit, secs] of REL_UNITS) { + if (Math.abs(diffSec) >= secs) return relFmt.format(Math.round(diffSec / secs), unit); + } + return relFmt.format(diffSec, 'second'); +} + +export function formatDuration(totalSec) { + if (totalSec == null || Number.isNaN(Number(totalSec))) return null; + const s = Math.max(0, Math.round(totalSec)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return [h && `${h}h`, (h || m) && `${m}m`, `${sec}s`].filter(Boolean).join(' '); +} + +export function formatBytes(bytes) { + if (bytes == null || Number.isNaN(Number(bytes))) return null; + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let n = Number(bytes); + let i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} \ No newline at end of file diff --git a/public/js/mock.js b/public/js/mock.js new file mode 100644 index 0000000..77c53c6 --- /dev/null +++ b/public/js/mock.js @@ -0,0 +1,129 @@ +// Sample data matching the API contract. Timestamps are relative to page load so it always looks "live". +const now = Date.now(); +const MIN = 60_000; +const HR = 60 * MIN; +const DAY = 24 * HR; +const iso = (offsetMs) => new Date(now + offsetMs).toISOString(); + +// every_h drives the generated run history; it isn't part of the API. +const JOBS = [ + { + id: 1, company_name: 'Acme Corp', job_name: 'File Server – Nightly', status: 'success', every_h: 24, + last_run_at: iso(-6 * HR), next_run_at: iso(18 * HR), + message: 'Job completed successfully. 1,284 files processed.', + source_system: 'Veeam B&R', duration_seconds: 2840, size_bytes: 128_849_018_880, + }, + { + id: 2, company_name: 'Acme Corp', job_name: 'SQL01 – Transaction Logs', status: 'warning', every_h: 1, + last_run_at: iso(-25 * MIN), next_run_at: iso(35 * MIN), + message: 'Processing SQL01 Warning:\nFailed to truncate transaction logs for database "ERP_PROD".\nLog backup completed but truncation was skipped.', + source_system: 'Veeam B&R', duration_seconds: 312, size_bytes: 2_147_483_648, + details: { retry_count: 0, repository: 'REPO-ONSITE-01' }, + }, + { + // Running now; "last run" is still the previous completion + id: 7, company_name: 'Acme Corp', job_name: 'Hyper-V Hosts – Daily', status: 'running', every_h: 24, + running_since: iso(-12 * MIN), last_status: 'success', + last_run_at: iso(-DAY + 40 * MIN), next_run_at: iso(DAY - 12 * MIN), + message: 'Completed successfully. 6 VMs processed.', + source_system: 'Veeam B&R', duration_seconds: 3120, size_bytes: 412_316_860_416, + }, + { + id: 3, company_name: 'Bluewater Dental', job_name: 'Workstations – Daily', status: 'failed', every_h: 24, + last_run_at: iso(-3 * HR), next_run_at: iso(21 * HR), + message: 'Error: Unable to connect to backup repository \\\\NAS02\\Backups.\nThe network path was not found. (0x80070035)\nJob terminated after 3 retries.', + source_system: 'Datto', duration_seconds: 95, + details: { retry_count: 3, target: '\\\\NAS02\\Backups' }, + }, + { + id: 4, company_name: 'Bluewater Dental', job_name: 'Imaging Server – Hourly', status: 'success', every_h: 1, + last_run_at: iso(-50 * MIN), next_run_at: iso(10 * MIN), + message: 'Completed.', source_system: 'Datto', duration_seconds: 420, size_bytes: 8_589_934_592, + }, + { + // Green status but next run is in the past → should show as Overdue + id: 5, company_name: 'Northfield Logistics', job_name: 'Exchange – Nightly', status: 'success', every_h: 24, + last_run_at: iso(-30 * HR), next_run_at: iso(-6 * HR), + message: 'Completed successfully.', source_system: 'Acronis', duration_seconds: 5400, + }, + { + // Never run / unrecognised status → rendered as Unknown + id: 6, company_name: 'Northfield Logistics', job_name: 'New NAS – Weekly', status: null, + last_run_at: null, next_run_at: iso(2 * DAY), + message: '', source_system: 'Synology Hyper Backup', + }, +]; + +const MESSAGES = { + success: 'Job completed successfully.', + warning: 'Completed with warnings: 1 file skipped (file in use).', + failed: 'Error: Target repository unreachable. Job terminated.', +}; + +// Deterministic pseudo-random so history doesn't reshuffle on every modal open +function rng(seed) { + let x = seed; + return () => ((x = (x * 1_103_515_245 + 12_345) % 2_147_483_648) / 2_147_483_648); +} + +const RUNS = new Map(); // job id → runs, newest first +for (const job of JOBS) { + const runs = []; + if (job.running_since) { + runs.push({ id: `${job.id}-r`, status: 'running', started_at: job.running_since, finished_at: null, message: 'In progress…' }); + } + if (job.last_run_at && job.every_h) { + const rand = rng(job.id * 7919); + const dur = (job.duration_seconds ?? 600) * 1000; + let end = Date.parse(job.last_run_at); + for (let i = 0; end - dur > now - 30 * DAY; i++, end -= job.every_h * HR) { + const r = rand(); + const status = i === 0 ? (job.last_status ?? job.status) : r < 0.03 ? 'failed' : r < 0.1 ? 'warning' : 'success'; + const thisDur = dur * (0.8 + rand() * 0.4); + runs.push({ + id: `${job.id}-${i}`, + status, + started_at: new Date(end - thisDur).toISOString(), + finished_at: new Date(end).toISOString(), + duration_seconds: Math.round(thisDur / 1000), + size_bytes: status === 'failed' ? null : job.size_bytes, + message: i === 0 ? job.message : MESSAGES[status], + details: i === 0 ? job.details ?? {} : {}, + }); + } + } + RUNS.set(String(job.id), runs); +} + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); +const summary = ({ details, every_h, last_status, duration_seconds, size_bytes, ...rest }) => rest; + +export const mockApi = { + async listJobs() { + await delay(250); + return { generated_at: new Date().toISOString(), jobs: JOBS.map(summary) }; + }, + async getJob(id) { + await delay(200); + const job = JOBS.find((j) => String(j.id) === String(id)); + if (!job) throw new Error('Job not found'); + const { every_h, ...rest } = job; + return { last_status: job.status === 'running' ? null : job.status, ...rest, details: job.details ?? {} }; + }, + async listRuns(id, days) { + await delay(300); + const since = now - days * DAY; + const runs = (RUNS.get(String(id)) ?? []) + .filter((r) => Date.parse(r.started_at) >= since) + .map(({ message, details, ...r }) => r); + return { job_id: id, since: new Date(since).toISOString(), runs }; + }, + async getRun(id) { + await delay(150); + for (const runs of RUNS.values()) { + const run = runs.find((r) => r.id === id); + if (run) return { details: {}, ...run }; + } + throw new Error('Run not found'); + }, +};