Files
siftlode/backend/app/routes/runs.py
T
peter fad656988a fix(activity): runs keep their true outcome and spend, the page reaches every kept row, a closing dialog leaves the server log alone
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.
2026-10-18 15:38:32 +02:00

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,
}