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
+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()