The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.
Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.
Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
162 lines
6.0 KiB
Python
162 lines
6.0 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._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"])
|
|
|
|
# `none` selects the runs no account started (the scheduled ones).
|
|
NONE = "none"
|
|
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(
|
|
JobRun.actor_id.is_(None)
|
|
if self.actor == NONE
|
|
else JobRun.actor_id == int(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=r"^(none|[0-9]{1,10})$"),
|
|
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,
|
|
}
|