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.
199 lines
6.8 KiB
Python
199 lines
6.8 KiB
Python
"""Admin Scheduler dashboard: a live view of what the background scheduler is doing —
|
|
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
|
|
and the shared quota picture."""
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import and_, func, or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit, quota, state, sysconfig
|
|
from app.audit import AuditAction
|
|
from app.auth import admin_user
|
|
from app.config import settings
|
|
from app.db import get_db
|
|
from app.models import Channel, Subscription, User, Video
|
|
from app.scheduler import (
|
|
JOB_INTERVALS,
|
|
MAX_INTERVAL,
|
|
MIN_INTERVAL,
|
|
reschedule,
|
|
scheduler_snapshot,
|
|
stage_interval,
|
|
trigger_all,
|
|
trigger_job,
|
|
)
|
|
from app.sync.explore import followed_channel
|
|
from app.sync.runner import estimate_deep_backfill
|
|
from app.routes._common import not_found
|
|
from app.refusal import refused
|
|
from app.refusal import setting_refused
|
|
|
|
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
|
|
|
|
|
|
class JobIntervalIn(BaseModel):
|
|
interval_minutes: int
|
|
|
|
|
|
@router.patch("/jobs/{job_id}")
|
|
def set_job_interval(
|
|
job_id: str,
|
|
body: JobIntervalIn,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""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 = stage_interval(db, job_id, minutes)
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.SCHEDULER_INTERVAL,
|
|
target_type="job",
|
|
target_id=job_id,
|
|
summary=f"{job_id} → {applied} min/run",
|
|
after={"interval_minutes": applied},
|
|
)
|
|
db.commit()
|
|
reschedule(job_id, applied)
|
|
return {"ok": True, "id": job_id, "interval_minutes": applied}
|
|
|
|
|
|
@router.post("/jobs/{job_id}/run")
|
|
def run_job_now(
|
|
job_id: str,
|
|
user: User = Depends(admin_user),
|
|
) -> dict:
|
|
"""Trigger a job to run immediately (in a background thread), independent of its
|
|
interval. 409 if it's already running, 404 for an unknown job. On completion the
|
|
triggering admin gets an inbox notification with the result."""
|
|
try:
|
|
result = trigger_job(job_id, actor_id=user.id)
|
|
except KeyError:
|
|
raise not_found("job") from None
|
|
if result == "already_running":
|
|
raise refused(409, "job_running")
|
|
return {"ok": True, "id": job_id, "started": True}
|
|
|
|
|
|
@router.post("/run-all")
|
|
def run_all_now(user: User = Depends(admin_user)) -> dict:
|
|
"""Trigger every job that isn't already running. Returns the ids actually started."""
|
|
return {"ok": True, "started": trigger_all(actor_id=user.id)}
|
|
|
|
|
|
class MaintenanceSettingsIn(BaseModel):
|
|
revalidate_batch: int
|
|
|
|
|
|
@router.patch("/maintenance")
|
|
def set_maintenance_settings(
|
|
body: MaintenanceSettingsIn,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
|
|
run). Persisted via sysconfig; the job reads it each run."""
|
|
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
|
|
try:
|
|
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
|
|
except sysconfig.InvalidSetting as exc:
|
|
raise setting_refused(exc) from None
|
|
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.MAINTENANCE_TUNE,
|
|
summary=f"revalidate batch = {new}",
|
|
after={"revalidate_batch": new},
|
|
)
|
|
db.commit()
|
|
return {"ok": True, "revalidate_batch": new}
|
|
|
|
|
|
@router.get("")
|
|
def get_scheduler(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
|
snap = scheduler_snapshot(db)
|
|
|
|
# Work still queued (shared catalog, so these are instance-wide, not per-user).
|
|
deep_requested = (
|
|
select(Subscription.id)
|
|
.where(
|
|
Subscription.channel_id == Channel.id,
|
|
Subscription.deep_requested.is_(True),
|
|
)
|
|
.exists()
|
|
)
|
|
deep_channels = (
|
|
db.execute(select(Channel).where(Channel.backfill_done.is_(False), deep_requested))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
deep_eta = estimate_deep_backfill(db, deep_channels)
|
|
|
|
queue = {
|
|
"channels_recent_pending": db.scalar(
|
|
# The same selection run_recent_backfill works through, or the card counts channels
|
|
# the job will never touch and never reaches zero.
|
|
select(func.count())
|
|
.select_from(Channel)
|
|
.where(Channel.recent_synced_at.is_(None), followed_channel())
|
|
)
|
|
or 0,
|
|
# The whole opted-in BACKLOG, deliberately not `run_deep_backfill`'s next-pass selection
|
|
# (which also wants `recent_synced_at`): the same figure /api/sync/my-status shows the user,
|
|
# and a channel missing its recent pull is one the same job tick fetches first, not one the
|
|
# job will never touch. Narrowing it here would under-report work that is genuinely queued.
|
|
"channels_deep_pending": deep_eta["channels_pending"],
|
|
"deep_eta_seconds": deep_eta["eta_seconds"],
|
|
"videos_pending_enrich": db.scalar(
|
|
select(func.count()).select_from(Video).where(Video.enriched_at.is_(None))
|
|
)
|
|
or 0,
|
|
"videos_pending_shorts": db.scalar(
|
|
select(func.count())
|
|
.select_from(Video)
|
|
.where(Video.shorts_probed.is_(False), Video.enriched_at.is_not(None))
|
|
)
|
|
or 0,
|
|
"videos_live_refresh": db.scalar(
|
|
select(func.count())
|
|
.select_from(Video)
|
|
.where(
|
|
or_(
|
|
Video.live_status.in_(("live", "upcoming")),
|
|
and_(Video.live_status == "was_live", Video.duration_seconds.is_(None)),
|
|
)
|
|
)
|
|
)
|
|
or 0,
|
|
}
|
|
|
|
used = quota.units_used_today(db)
|
|
quota_info = {
|
|
"used_today": used,
|
|
"remaining_today": quota.remaining_today(db),
|
|
"daily_budget": sysconfig.get_int(db, "quota_daily_budget"),
|
|
"backfill_reserve": sysconfig.get_int(db, "backfill_quota_reserve"),
|
|
}
|
|
maint = sysconfig.spec("maintenance_revalidate_batch")
|
|
|
|
return {
|
|
**snap,
|
|
"paused": state.is_sync_paused(db),
|
|
"queue": queue,
|
|
"quota": quota_info,
|
|
"maintenance": {
|
|
"revalidate_batch": sysconfig.get_int(db, "maintenance_revalidate_batch"),
|
|
"revalidate_batch_default": settings.maintenance_revalidate_batch,
|
|
"min": maint.min,
|
|
"max": maint.max,
|
|
},
|
|
}
|