"""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, apply_interval, scheduler_snapshot, 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). Persisted as an override and applied to the live scheduler immediately.""" 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) 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() 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, }, }