/report.html shows one company's backups over the last 7, 30 or 90 days, chosen in the page and kept in the URL so reports can be bookmarked or linked. Reached from Reports in the top bar, or a company's name in a job's detail. - Summary (jobs, runs, success rate, warnings, failures, overdue now); per job: run counts, success rate (successful / finished runs), last result and last successful backup; a day-by-day strip; every warning and failure with its message - Print / Save as PDF: a print stylesheet prints light whatever the screen theme, hides the controls, keeps rows and strips whole across pages, and keeps the coloured cells and badges - Days before a job was first known show as "Not monitored yet" rather than as missed backups, so a 90-day report for a newly added client doesn't look like 60 days of failures - CSV of every run (full messages, times in the browser's time zone, UTF-8 BOM for Excel, formula-like values defused with a leading ') Backend: GET /api/v1/tenants, /api/v1/reports/tenant and /api/v1/reports/tenant.csv; periods capped at retention. tzdata added for systems without time zone data. Frontend status and day-strip helpers moved out of app.js into status.js / history.js for reuse. Tested: 9 new backend tests; in the browser against the API with 30 days of demo history (summary, issues, strips, 7/30/90 switching, CSV download with BOM, print styles applied on screen), mock mode and phone width. Co-Authored-By: Claude Opus 5.5 <[email protected]>
170 lines
6.7 KiB
Python
170 lines
6.7 KiB
Python
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 list_tenant_jobs(conn: sqlite3.Connection, tenant_id: int) -> list[dict[str, Any]]:
|
|
"""A company's jobs with their current status, for reports."""
|
|
rows = conn.execute(_JOB_SQL + " WHERE j.tenant_id = ? ORDER BY j.name", (tenant_id,)).fetchall()
|
|
fields = (*_SUMMARY_FIELDS, "last_status")
|
|
return [{k: row[k] for k in fields} for row in rows]
|
|
|
|
|
|
def get_job(conn: sqlite3.Connection, job_id: int) -> dict[str, Any] | None:
|
|
row = conn.execute(_JOB_SQL + " WHERE j.id = ?", (job_id,)).fetchone()
|
|
if row is None:
|
|
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
|