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 <[email protected]>
This commit is contained in:
2026-10-04 21:53:24 -06:00
co-authored by Claude Opus 5.5
parent 44e3065efa
commit bf752a904f
25 changed files with 2148 additions and 1 deletions
View File
+42
View File
@@ -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"]
+99
View File
@@ -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()
+118
View File
@@ -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
+63
View File
@@ -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)")
+162
View File
@@ -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
+22
View File
@@ -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",
)
+12
View File
@@ -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)
+116
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
[pytest]
pythonpath = .
+2
View File
@@ -0,0 +1,2 @@
httpx
pytest
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.115
uvicorn[standard]>=0.30
View File
+124
View File
@@ -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