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.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
"""The account filter the activity page's lists share (runs, admin actions, server log, quota): one
|
||||
filter bar drives them all, so the value it sends means the same on every tab.
|
||||
|
||||
The value is an account id, or `none` — the rows no account made (the background) — a value to filter
|
||||
on, not the absence of a filter.
|
||||
"""
|
||||
|
||||
from sqlalchemy import ColumnElement
|
||||
|
||||
NONE = "none"
|
||||
# The `user` query parameter's shape on every route that takes it.
|
||||
PATTERN = r"^(none|[0-9]{1,10})$"
|
||||
|
||||
|
||||
def condition(column, value: str) -> ColumnElement[bool]:
|
||||
"""`column` (an account id column) narrowed to `value`."""
|
||||
return column.is_(None) if value == NONE else column == int(value)
|
||||
@@ -25,6 +25,7 @@ from app import logsink
|
||||
from app.auth import admin_user
|
||||
from app.db import get_db
|
||||
from app.models import LogEvent, User
|
||||
from app.routes import _account as account
|
||||
from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc
|
||||
from app.utils import like_escape
|
||||
|
||||
@@ -37,9 +38,9 @@ FACET_EVENTS_MAX = 100
|
||||
Level = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
# The filters a facet can leave out of its own count (`LogFilters.conditions`).
|
||||
Facet = Literal["levels", "sources", "user", "event"]
|
||||
# `none` selects the rows that have no account / no event code (a scheduler line, an anonymous
|
||||
# request) — a real value to filter on, not the absence of a filter.
|
||||
NONE = "none"
|
||||
# `none` selects the rows that have no event code, as the account filter's `none` (app/routes/_account)
|
||||
# selects the rows of no account — a real value to filter on, not the absence of a filter.
|
||||
NONE = account.NONE
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -67,11 +68,7 @@ class LogFilters:
|
||||
if self.sources and skip != "sources":
|
||||
out.append(LogEvent.source.in_(self.sources))
|
||||
if self.user is not None and skip != "user":
|
||||
out.append(
|
||||
LogEvent.user_id.is_(None)
|
||||
if self.user == NONE
|
||||
else LogEvent.user_id == int(self.user)
|
||||
)
|
||||
out.append(account.condition(LogEvent.user_id, self.user))
|
||||
if self.event is not None and skip != "event":
|
||||
out.append(
|
||||
LogEvent.event.is_(None) if self.event == NONE else LogEvent.event == self.event
|
||||
@@ -103,7 +100,7 @@ class LogFilters:
|
||||
def log_filters(
|
||||
levels: list[Level] = Query(default=[]),
|
||||
sources: list[str] = Query(default=[], max_length=8),
|
||||
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"),
|
||||
user: str | None = Query(default=None, pattern=account.PATTERN),
|
||||
event: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$"
|
||||
),
|
||||
|
||||
+18
-12
@@ -15,17 +15,15 @@ from app import audit
|
||||
from app.audit import AuditAction
|
||||
from app.auth import admin_user
|
||||
from app.db import get_db
|
||||
from app.models import AuditLog, User
|
||||
from app.models import AuditLog, LogEvent, User
|
||||
from app.routes import _account as account
|
||||
from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc
|
||||
from app.utils import iso, like_escape
|
||||
|
||||
router = APIRouter(prefix="/api/admin/audit", tags=["admin"])
|
||||
|
||||
# `none` selects the rows no account made (the background) — a value to filter on, not "any".
|
||||
NONE = "none"
|
||||
|
||||
|
||||
def _serialize(row: AuditLog) -> dict:
|
||||
def _serialize(row: AuditLog, lines: dict[str, int]) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
"created_at": iso(row.created_at),
|
||||
@@ -38,6 +36,9 @@ def _serialize(row: AuditLog) -> dict:
|
||||
"before": row.before,
|
||||
"after": row.after,
|
||||
"request_id": row.request_id,
|
||||
# How many of that request's log lines are still kept: they go after days, the row after
|
||||
# months, and a link to none would open an empty list.
|
||||
"log_lines": lines.get(row.request_id, 0) if row.request_id else 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -56,11 +57,7 @@ class AuditFilters:
|
||||
if self.action is not None and skip != "action":
|
||||
out.append(AuditLog.action == self.action)
|
||||
if self.actor is not None:
|
||||
out.append(
|
||||
AuditLog.actor_id.is_(None)
|
||||
if self.actor == NONE
|
||||
else AuditLog.actor_id == int(self.actor)
|
||||
)
|
||||
out.append(account.condition(AuditLog.actor_id, self.actor))
|
||||
if self.since is not None:
|
||||
out.append(AuditLog.created_at >= utc(self.since))
|
||||
if self.until is not None:
|
||||
@@ -82,7 +79,7 @@ class AuditFilters:
|
||||
|
||||
def audit_filters(
|
||||
action: str | None = Query(default=None, max_length=48, pattern=r"^[a-z][a-z0-9_]*$"),
|
||||
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"),
|
||||
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,
|
||||
@@ -109,8 +106,17 @@ def list_audit(
|
||||
limit,
|
||||
lambda r: r.created_at,
|
||||
)
|
||||
requests = {r.request_id for r in rows if r.request_id}
|
||||
lines: dict[str, int] = {}
|
||||
if requests:
|
||||
counted = db.execute(
|
||||
select(LogEvent.request_id, func.count())
|
||||
.where(LogEvent.request_id.in_(requests))
|
||||
.group_by(LogEvent.request_id)
|
||||
)
|
||||
lines = {rid: n for rid, n in counted.tuples() if rid is not None}
|
||||
return {
|
||||
"items": [_serialize(r) for r in rows],
|
||||
"items": [_serialize(r, lines) for r in rows],
|
||||
"accounts": accounts(db, {r.actor_id for r in rows if r.actor_id is not None}),
|
||||
"next_cursor": cursor,
|
||||
}
|
||||
|
||||
@@ -512,7 +512,9 @@ def update_channel(
|
||||
# so the whole catalogue lands in seconds instead of over the scheduler's paced 10-minute ticks.
|
||||
# The request returns right away; the channel page's live poll shows the count climb. The
|
||||
# scheduler still finishes a very large channel's tail on its normal runs (deep_requested stays set).
|
||||
if deep_turned_on:
|
||||
# A channel whose history is all in gets no run: it would only say "nothing to do", one kept row
|
||||
# per flip of the flag.
|
||||
if deep_turned_on and not (channel is not None and channel.backfill_done):
|
||||
scheduler.run_ad_hoc(
|
||||
scheduler.CHANNEL_BACKFILL,
|
||||
partial(backfill_channel_now, channel_id, user.id),
|
||||
|
||||
@@ -14,6 +14,7 @@ from app import quota
|
||||
from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
from app.models import QuotaEvent, User
|
||||
from app.routes import _account as account
|
||||
from app.routes._keyset import utc
|
||||
|
||||
router = APIRouter(prefix="/api/quota", tags=["quota"])
|
||||
@@ -48,7 +49,7 @@ def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db))
|
||||
def admin_usage(
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"),
|
||||
user: str | None = Query(default=None, pattern=account.PATTERN),
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
@@ -60,9 +61,7 @@ def admin_usage(
|
||||
if until is not None:
|
||||
window.append(QuotaEvent.created_at < utc(until))
|
||||
if user is not None:
|
||||
window.append(
|
||||
QuotaEvent.user_id.is_(None) if user == "none" else QuotaEvent.user_id == int(user)
|
||||
)
|
||||
window.append(account.condition(QuotaEvent.user_id, user))
|
||||
|
||||
totals = db.execute(
|
||||
select(QuotaEvent.user_id, func.sum(QuotaEvent.units))
|
||||
|
||||
@@ -19,14 +19,13 @@ 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"])
|
||||
|
||||
# `none` selects the runs no account started (the scheduled ones).
|
||||
NONE = "none"
|
||||
Facet = Literal["job", "status"]
|
||||
|
||||
|
||||
@@ -68,11 +67,7 @@ class RunFilters:
|
||||
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)
|
||||
)
|
||||
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:
|
||||
@@ -93,7 +88,7 @@ class RunFilters:
|
||||
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})$"),
|
||||
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,
|
||||
|
||||
@@ -17,8 +17,9 @@ from app.scheduler import (
|
||||
JOB_INTERVALS,
|
||||
MAX_INTERVAL,
|
||||
MIN_INTERVAL,
|
||||
apply_interval,
|
||||
reschedule,
|
||||
scheduler_snapshot,
|
||||
stage_interval,
|
||||
trigger_all,
|
||||
trigger_job,
|
||||
)
|
||||
@@ -42,14 +43,14 @@ def set_job_interval(
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Change how often a scheduler job runs (minutes). Persisted as an override and applied
|
||||
to the live scheduler immediately."""
|
||||
"""Change how often a scheduler job runs (minutes). Stored as an override together with its
|
||||
audit row, then applied to the live scheduler."""
|
||||
if job_id not in JOB_INTERVALS:
|
||||
raise not_found("job")
|
||||
minutes = body.interval_minutes
|
||||
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
||||
raise refused(400, "invalid_interval", min=MIN_INTERVAL, max=MAX_INTERVAL)
|
||||
applied = apply_interval(db, job_id, minutes)
|
||||
applied = stage_interval(db, job_id, minutes)
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
@@ -60,6 +61,7 @@ def set_job_interval(
|
||||
after={"interval_minutes": applied},
|
||||
)
|
||||
db.commit()
|
||||
reschedule(job_id, applied)
|
||||
return {"ok": True, "id": job_id, "interval_minutes": applied}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user