"""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.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 User from app.catalog import queries as catalog_queries from app.scheduler import ( JOB_INTERVALS, MAX_INTERVAL, MIN_INTERVAL, reschedule, scheduler_snapshot, stage_interval, trigger_all, trigger_job, ) 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_eta = estimate_deep_backfill(db, catalog_queries.deep_backlog(db)) queue = catalog_queries.work_queue(db) # 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. queue["channels_deep_pending"] = deep_eta["channels_pending"] queue["deep_eta_seconds"] = deep_eta["eta_seconds"] 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, }, }