Backend - A run's outcome write sits outside the job's own try: a database blip after a run that succeeded no longer records it as failed (the row waits for the next start's sweep). - An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does). - The Scheduler's ad-hoc row says running while any backfill runs, not only the newest. - recent_runs reads each job's last runs off its index instead of ranking the whole table. - An interval change is stored with its audit row in one transaction, then rescheduled. - One API instance per database is the stated contract (.env.example, jobruns). - Turning full history on for a channel already complete records no run. - Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are test-held to the enums; the account filter is one helper for the four activity reads. - An audit row says how many of its request's log lines are still kept. Frontend - An "All" period reaches every row still kept (runs and admin actions outlive 90 days). - Back/Forward is followed in place by the server log: a closing dialog no longer turns Live off; a pivot still opens a fresh log. - Runs moved over from the audit log read their one-liner in the reader's language. - Filter choices or accounts that failed to load say so beside the filters, with a retry. - An old server-log link keeps its account, text and range; a remembered retired page opens its tab. - A request whose lines are gone says so instead of linking to an empty list. - The log lines and the history rows share one row frame, and the server log one list frame with the other tabs; an empty list is announced; each day's quota is readable.
157 lines
5.8 KiB
Python
157 lines
5.8 KiB
Python
"""Admin background-run history: read `job_runs`, a page at a time.
|
|
|
|
Admin-only and read-only — rows leave the table through the retention job
|
|
(`job_run_retention_days`). Filtered on the server and paged by `_keyset`, like the server log and
|
|
the audit log beside it on the activity page. A row names what a reader needs without a second
|
|
request: the channel a user's ad-hoc run worked on, and how many log lines the run left (only
|
|
WARNING and up are stored, so most runs have none — the count says so before the click).
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import scheduler
|
|
from app.auth import admin_user
|
|
from app.db import get_db
|
|
from app.models import Channel, JobRun, LogEvent, User
|
|
from app.routes import _account as account
|
|
from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc
|
|
from app.runstatus import RunStatus
|
|
from app.utils import iso, like_escape
|
|
|
|
router = APIRouter(prefix="/api/admin/runs", tags=["admin"])
|
|
|
|
Facet = Literal["job", "status"]
|
|
|
|
|
|
def _serialize(run: JobRun, titles: dict[str, str], lines: dict[int, int]) -> dict:
|
|
return {
|
|
"id": run.id,
|
|
"job": run.job,
|
|
"trigger": run.trigger,
|
|
"actor_id": run.actor_id,
|
|
"target": run.target,
|
|
"target_title": titles.get(run.target) if run.target is not None else None,
|
|
"status": run.status,
|
|
"started_at": iso(run.started_at),
|
|
"finished_at": iso(run.finished_at),
|
|
"summary": run.summary,
|
|
"result": run.result,
|
|
"error": run.error,
|
|
"failure": run.failure,
|
|
"quota_units": run.quota_units,
|
|
"log_lines": lines.get(run.id, 0),
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RunFilters:
|
|
job: str | None
|
|
status: RunStatus | None
|
|
actor: str | None
|
|
q: str | None
|
|
since: datetime | None
|
|
until: datetime | None
|
|
|
|
def conditions(self, skip: Facet | None = None) -> list:
|
|
"""The WHERE conditions, ANDed. `skip` leaves one menu's own filter out, so that menu counts
|
|
its values under every other filter (the way back from a pick)."""
|
|
out = []
|
|
if self.job is not None and skip != "job":
|
|
out.append(JobRun.job == self.job)
|
|
if self.status is not None and skip != "status":
|
|
out.append(JobRun.status == self.status)
|
|
if self.actor is not None:
|
|
out.append(account.condition(JobRun.actor_id, self.actor))
|
|
if self.since is not None:
|
|
out.append(JobRun.started_at >= utc(self.since))
|
|
if self.until is not None:
|
|
out.append(JobRun.started_at < utc(self.until))
|
|
if self.q and self.q.strip():
|
|
like = f"%{like_escape(self.q.strip())}%"
|
|
out.append(
|
|
or_(
|
|
JobRun.job.ilike(like),
|
|
JobRun.target.ilike(like),
|
|
JobRun.summary.ilike(like),
|
|
JobRun.error.ilike(like),
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
def run_filters(
|
|
job: str | None = Query(default=None, max_length=40, pattern=r"^[a-z][a-z0-9_]*$"),
|
|
status: RunStatus | None = None,
|
|
user: str | None = Query(default=None, pattern=account.PATTERN),
|
|
q: str | None = Query(default=None, max_length=200),
|
|
since: datetime | None = None,
|
|
until: datetime | None = None,
|
|
) -> RunFilters:
|
|
return RunFilters(job, status, user, q, since, until)
|
|
|
|
|
|
@router.get("")
|
|
def list_runs(
|
|
filters: RunFilters = Depends(run_filters),
|
|
before: str | None = Query(default=None, max_length=200),
|
|
limit: int = Query(default=100, ge=1, le=PAGE_MAX),
|
|
_: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""One page of runs, newest first, matching every filter given; `before` continues from a
|
|
previous page's `next_cursor`."""
|
|
query = select(JobRun).where(*filters.conditions())
|
|
if before is not None:
|
|
query = query.where(older_than(JobRun.started_at, JobRun.id, before))
|
|
order = (JobRun.started_at.desc(), JobRun.id.desc())
|
|
runs, cursor = page(
|
|
list(db.execute(query.order_by(*order).limit(limit + 1)).scalars().all()),
|
|
limit,
|
|
lambda r: r.started_at,
|
|
)
|
|
channels = {r.target for r in runs if r.job in scheduler.AD_HOC_JOBS and r.target}
|
|
titles: dict[str, str] = {}
|
|
if channels:
|
|
rows = db.execute(select(Channel.id, Channel.title).where(Channel.id.in_(channels)))
|
|
titles = {cid: title for cid, title in rows.tuples() if title is not None}
|
|
lines: dict[int, int] = {}
|
|
if runs:
|
|
counted = db.execute(
|
|
select(LogEvent.run_id, func.count())
|
|
.where(LogEvent.run_id.in_([r.id for r in runs]))
|
|
.group_by(LogEvent.run_id)
|
|
)
|
|
lines = {rid: n for rid, n in counted.tuples() if rid is not None}
|
|
return {
|
|
"items": [_serialize(r, titles, lines) for r in runs],
|
|
"accounts": accounts(db, {r.actor_id for r in runs if r.actor_id is not None}),
|
|
"next_cursor": cursor,
|
|
}
|
|
|
|
|
|
@router.get("/facets")
|
|
def run_facets(
|
|
filters: RunFilters = Depends(run_filters),
|
|
_: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""What the job menu and the status chips offer, each counted under every filter but its own."""
|
|
|
|
def counts(col, skip: Facet) -> list[dict]:
|
|
n = func.count().label("n")
|
|
stmt = select(col, n).where(*filters.conditions(skip)).group_by(col).order_by(n.desc(), col)
|
|
return [{"value": v, "count": c} for v, c in db.execute(stmt).all()]
|
|
|
|
total = select(func.count()).select_from(JobRun).where(*filters.conditions("status"))
|
|
return {
|
|
"jobs": counts(JobRun.job, "job"),
|
|
"statuses": counts(JobRun.status, "status"),
|
|
"total": db.scalar(total) or 0,
|
|
}
|