Merge feature/scheduler-controls: job tooltips, status legend, editable intervals
This commit is contained in:
commit
25dca48964
9 changed files with 301 additions and 61 deletions
30
backend/alembic/versions/0015_scheduler_settings.py
Normal file
30
backend/alembic/versions/0015_scheduler_settings.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""scheduler job interval overrides
|
||||||
|
|
||||||
|
Revision ID: 0015_scheduler_settings
|
||||||
|
Revises: 0014_demo_account
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
|
||||||
|
Adds scheduler_settings: a per-job run-interval override (minutes), editable from the admin
|
||||||
|
Scheduler dashboard. Absent row = use the env/config default, so this is purely additive.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0015_scheduler_settings"
|
||||||
|
down_revision: Union[str, None] = "0014_demo_account"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"scheduler_settings",
|
||||||
|
sa.Column("job_id", sa.String(length=40), primary_key=True),
|
||||||
|
sa.Column("interval_minutes", sa.Integer(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("scheduler_settings")
|
||||||
|
|
@ -325,6 +325,17 @@ class AppState(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerSetting(Base):
|
||||||
|
"""Admin override for a scheduler job's run interval (minutes). One row per job id;
|
||||||
|
absent = use the env/config default. DB-backed so it's editable from the admin UI at
|
||||||
|
runtime without a redeploy (env stays a fallback)."""
|
||||||
|
|
||||||
|
__tablename__ = "scheduler_settings"
|
||||||
|
|
||||||
|
job_id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
interval_minutes: Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
|
|
||||||
class Playlist(Base):
|
class Playlist(Base):
|
||||||
"""A per-user named, ordered collection of videos from the shared catalog.
|
"""A per-user named, ordered collection of videos from the shared catalog.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Admin Scheduler dashboard: a live view of what the background scheduler is doing —
|
"""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),
|
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
|
||||||
and the shared quota picture."""
|
and the shared quota picture."""
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -10,12 +10,36 @@ from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
from app.routes.admin import admin_user
|
from app.routes.admin import admin_user
|
||||||
from app.scheduler import scheduler_snapshot
|
from app.scheduler import JOB_INTERVALS, MAX_INTERVAL, MIN_INTERVAL, apply_interval, scheduler_snapshot
|
||||||
from app.sync.runner import estimate_deep_backfill
|
from app.sync.runner import estimate_deep_backfill
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
|
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/jobs/{job_id}")
|
||||||
|
def set_job_interval(
|
||||||
|
job_id: str,
|
||||||
|
payload: dict,
|
||||||
|
_: 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 HTTPException(status_code=404, detail="Unknown job")
|
||||||
|
try:
|
||||||
|
minutes = int(payload.get("interval_minutes"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="interval_minutes must be a number")
|
||||||
|
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
|
||||||
|
)
|
||||||
|
applied = apply_interval(db, job_id, minutes)
|
||||||
|
return {"id": job_id, "interval_minutes": applied}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def get_scheduler(
|
def get_scheduler(
|
||||||
_: User = Depends(admin_user), db: Session = Depends(get_db)
|
_: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@ import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import quota
|
from app import quota
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
|
from app.models import SchedulerSetting
|
||||||
from app.state import is_sync_paused
|
from app.state import is_sync_paused
|
||||||
from app.sync.autotag import run_autotag_all
|
from app.sync.autotag import run_autotag_all
|
||||||
from app.sync.playlists import sync_all_playlists
|
from app.sync.playlists import sync_all_playlists
|
||||||
|
|
@ -32,7 +34,8 @@ _scheduler: BackgroundScheduler | None = None
|
||||||
_activity: dict[str, dict] = {}
|
_activity: dict[str, dict] = {}
|
||||||
_activity_lock = threading.Lock()
|
_activity_lock = threading.Lock()
|
||||||
|
|
||||||
# Each interval job's configured period, surfaced so the dashboard can show "every N min".
|
# Each interval job's env/config default period (minutes). The admin can override any of
|
||||||
|
# these at runtime (stored in scheduler_settings, applied live); see load_intervals.
|
||||||
JOB_INTERVALS: dict[str, int] = {
|
JOB_INTERVALS: dict[str, int] = {
|
||||||
"rss_poll": settings.rss_poll_minutes,
|
"rss_poll": settings.rss_poll_minutes,
|
||||||
"enrich": settings.enrich_interval_minutes,
|
"enrich": settings.enrich_interval_minutes,
|
||||||
|
|
@ -43,6 +46,38 @@ JOB_INTERVALS: dict[str, int] = {
|
||||||
"playlist_sync": settings.playlist_sync_minutes,
|
"playlist_sync": settings.playlist_sync_minutes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Sane bounds for an admin-set interval (minutes).
|
||||||
|
MIN_INTERVAL = 1
|
||||||
|
MAX_INTERVAL = 1440 # one day
|
||||||
|
|
||||||
|
|
||||||
|
def load_intervals(db) -> dict[str, int]:
|
||||||
|
"""Effective per-job intervals: the env/config defaults overlaid with any admin overrides
|
||||||
|
saved in scheduler_settings."""
|
||||||
|
iv = dict(JOB_INTERVALS)
|
||||||
|
for row in db.execute(select(SchedulerSetting)).scalars():
|
||||||
|
if row.job_id in iv and row.interval_minutes:
|
||||||
|
iv[row.job_id] = row.interval_minutes
|
||||||
|
return iv
|
||||||
|
|
||||||
|
|
||||||
|
def apply_interval(db, job_id: str, minutes: int) -> int:
|
||||||
|
"""Persist a job's interval override and reschedule the live job immediately (if the
|
||||||
|
scheduler runs in this process). Returns the applied value."""
|
||||||
|
if job_id not in JOB_INTERVALS:
|
||||||
|
raise KeyError(job_id)
|
||||||
|
minutes = max(MIN_INTERVAL, min(MAX_INTERVAL, int(minutes)))
|
||||||
|
row = db.get(SchedulerSetting, job_id)
|
||||||
|
if row is None:
|
||||||
|
db.add(SchedulerSetting(job_id=job_id, interval_minutes=minutes))
|
||||||
|
else:
|
||||||
|
row.interval_minutes = minutes
|
||||||
|
db.commit()
|
||||||
|
if _scheduler is not None and _scheduler.get_job(job_id) is not None:
|
||||||
|
_scheduler.reschedule_job(job_id, trigger="interval", minutes=minutes)
|
||||||
|
logger.info("rescheduled job %s to every %s min", job_id, minutes)
|
||||||
|
return minutes
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
@ -90,19 +125,23 @@ def scheduler_snapshot() -> dict:
|
||||||
runs no scheduler), so the UI can say so while still showing DB-derived queue/quota."""
|
runs no scheduler), so the UI can say so while still showing DB-derived queue/quota."""
|
||||||
running_here = _scheduler is not None
|
running_here = _scheduler is not None
|
||||||
next_runs: dict[str, str | None] = {}
|
next_runs: dict[str, str | None] = {}
|
||||||
|
live_intervals: dict[str, int] = {}
|
||||||
if _scheduler is not None:
|
if _scheduler is not None:
|
||||||
for job in _scheduler.get_jobs():
|
for job in _scheduler.get_jobs():
|
||||||
nr = getattr(job, "next_run_time", None)
|
nr = getattr(job, "next_run_time", None)
|
||||||
next_runs[job.id] = nr.astimezone(timezone.utc).isoformat() if nr else None
|
next_runs[job.id] = nr.astimezone(timezone.utc).isoformat() if nr else None
|
||||||
|
iv = getattr(job.trigger, "interval", None)
|
||||||
|
if iv is not None:
|
||||||
|
live_intervals[job.id] = round(iv.total_seconds() / 60)
|
||||||
with _activity_lock:
|
with _activity_lock:
|
||||||
acts = {k: dict(v) for k, v in _activity.items()}
|
acts = {k: dict(v) for k, v in _activity.items()}
|
||||||
jobs = []
|
jobs = []
|
||||||
for job_id, interval in JOB_INTERVALS.items():
|
for job_id, default_interval in JOB_INTERVALS.items():
|
||||||
a = acts.get(job_id, {})
|
a = acts.get(job_id, {})
|
||||||
jobs.append(
|
jobs.append(
|
||||||
{
|
{
|
||||||
"id": job_id,
|
"id": job_id,
|
||||||
"interval_minutes": interval,
|
"interval_minutes": live_intervals.get(job_id, default_interval),
|
||||||
"next_run": next_runs.get(job_id),
|
"next_run": next_runs.get(job_id),
|
||||||
"running": a.get("running", False),
|
"running": a.get("running", False),
|
||||||
"status": a.get("status"),
|
"status": a.get("status"),
|
||||||
|
|
@ -166,43 +205,24 @@ def start_scheduler() -> None:
|
||||||
global _scheduler
|
global _scheduler
|
||||||
if not settings.scheduler_enabled or _scheduler is not None:
|
if not settings.scheduler_enabled or _scheduler is not None:
|
||||||
return
|
return
|
||||||
|
# Effective intervals = env defaults overlaid with any admin overrides from the DB.
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
iv = load_intervals(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
fns = {
|
||||||
|
"rss_poll": _rss_job,
|
||||||
|
"enrich": _enrich_job,
|
||||||
|
"backfill": _backfill_job,
|
||||||
|
"autotag": _autotag_job,
|
||||||
|
"shorts": _shorts_job,
|
||||||
|
"subscriptions": _subscriptions_job,
|
||||||
|
"playlist_sync": _playlist_sync_job,
|
||||||
|
}
|
||||||
scheduler = BackgroundScheduler(timezone="UTC")
|
scheduler = BackgroundScheduler(timezone="UTC")
|
||||||
scheduler.add_job(
|
for job_id, fn in fns.items():
|
||||||
_rss_job, "interval", minutes=settings.rss_poll_minutes, id="rss_poll"
|
scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
_enrich_job, "interval", minutes=settings.enrich_interval_minutes, id="enrich"
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
_backfill_job,
|
|
||||||
"interval",
|
|
||||||
minutes=settings.backfill_interval_minutes,
|
|
||||||
id="backfill",
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
_autotag_job,
|
|
||||||
"interval",
|
|
||||||
minutes=settings.autotag_interval_minutes,
|
|
||||||
id="autotag",
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
_shorts_job,
|
|
||||||
"interval",
|
|
||||||
minutes=settings.shorts_probe_interval_minutes,
|
|
||||||
id="shorts",
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
_subscriptions_job,
|
|
||||||
"interval",
|
|
||||||
minutes=settings.subscriptions_resync_minutes,
|
|
||||||
id="subscriptions",
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
_playlist_sync_job,
|
|
||||||
"interval",
|
|
||||||
minutes=settings.playlist_sync_minutes,
|
|
||||||
id="playlist_sync",
|
|
||||||
)
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
_scheduler = scheduler
|
_scheduler = scheduler
|
||||||
logger.info("scheduler started")
|
logger.info("scheduler started")
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
Clock,
|
Clock,
|
||||||
Database,
|
Database,
|
||||||
Gauge,
|
Gauge,
|
||||||
Pause,
|
Pause,
|
||||||
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
RadioTower,
|
RadioTower,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
|
|
@ -34,32 +37,109 @@ function fmtCountdown(s: number): string {
|
||||||
return `${h}h ${m % 60}m`;
|
return `${h}h ${m % 60}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDot({ job }: { job: SchedulerJob }) {
|
const DOT_CLASS: Record<string, string> = {
|
||||||
const color = job.running
|
running: "bg-accent animate-pulse",
|
||||||
? "bg-accent animate-pulse"
|
ok: "bg-emerald-500",
|
||||||
: job.status === "ok"
|
error: "bg-red-500",
|
||||||
? "bg-emerald-500"
|
skipped: "bg-amber-500",
|
||||||
: job.status === "error"
|
idle: "bg-border",
|
||||||
? "bg-red-500"
|
};
|
||||||
: job.status === "skipped"
|
|
||||||
? "bg-amber-500"
|
function statusKey(job: SchedulerJob): keyof typeof DOT_CLASS {
|
||||||
: "bg-border";
|
if (job.running) return "running";
|
||||||
return <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${color}`} />;
|
if (job.status === "ok") return "ok";
|
||||||
|
if (job.status === "error") return "error";
|
||||||
|
if (job.status === "skipped") return "skipped";
|
||||||
|
return "idle";
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobRow({ job }: { job: SchedulerJob }) {
|
function StatusDot({ k, withTooltip = true }: { k: keyof typeof DOT_CLASS; withTooltip?: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const dot = <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`} />;
|
||||||
|
return withTooltip ? <Tooltip hint={t(`scheduler.dot.${k}`)}>{dot}</Tooltip> : dot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusLegend() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap text-[11px] text-muted mb-2">
|
||||||
|
{(["running", "ok", "idle", "error", "skipped"] as const).map((k) => (
|
||||||
|
<span key={k} className="inline-flex items-center gap-1.5">
|
||||||
|
<StatusDot k={k} withTooltip={false} />
|
||||||
|
{t(`scheduler.dot.${k}`)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobRow({
|
||||||
|
job,
|
||||||
|
onSave,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
job: SchedulerJob;
|
||||||
|
onSave: (minutes: number) => void;
|
||||||
|
saving: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [val, setVal] = useState(String(job.interval_minutes));
|
||||||
const next = secsUntil(job.next_run);
|
const next = secsUntil(job.next_run);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) setVal(String(job.interval_minutes));
|
||||||
|
}, [job.interval_minutes, editing]);
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
const m = parseInt(val, 10);
|
||||||
|
if (Number.isFinite(m) && m >= 1 && m <= 1440 && m !== job.interval_minutes) onSave(m);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card rounded-xl p-3 flex items-center gap-3">
|
<div className="glass-card rounded-xl p-3 flex items-center gap-3">
|
||||||
<StatusDot job={job} />
|
<StatusDot k={statusKey(job)} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className="text-sm font-medium flex items-center gap-1.5 flex-wrap">
|
||||||
|
<Tooltip hint={t(`scheduler.jobDesc.${job.id}`)}>
|
||||||
|
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
|
||||||
{t(`scheduler.jobs.${job.id}`, job.id)}
|
{t(`scheduler.jobs.${job.id}`, job.id)}
|
||||||
<span className="text-muted font-normal">
|
|
||||||
{" "}
|
|
||||||
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
|
||||||
</span>
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
{editing ? (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1440}
|
||||||
|
value={val}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setVal(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") save();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
className="w-16 bg-card border border-border rounded px-1.5 py-0.5 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
|
||||||
|
<button onClick={save} disabled={saving} className="text-emerald-400 hover:opacity-80 disabled:opacity-50" aria-label={t("common.save")}>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditing(false)} className="text-muted hover:text-fg" aria-label={t("common.cancel")}>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Tooltip hint={t("scheduler.editInterval")}>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
|
||||||
|
>
|
||||||
|
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
||||||
|
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 transition" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted truncate">
|
<div className="text-[11px] text-muted truncate">
|
||||||
{job.running
|
{job.running
|
||||||
|
|
@ -140,6 +220,13 @@ export default function Scheduler() {
|
||||||
onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }),
|
onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const intervalMut = useMutation({
|
||||||
|
mutationFn: (v: { jobId: string; minutes: number }) =>
|
||||||
|
api.updateSchedulerJob(v.jobId, v.minutes),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
||||||
|
onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }),
|
||||||
|
});
|
||||||
|
|
||||||
if (q.isLoading && !data)
|
if (q.isLoading && !data)
|
||||||
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
||||||
if (!data)
|
if (!data)
|
||||||
|
|
@ -195,9 +282,15 @@ export default function Scheduler() {
|
||||||
{/* Jobs */}
|
{/* Jobs */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.jobsTitle")}</div>
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.jobsTitle")}</div>
|
||||||
|
<StatusLegend />
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{data.jobs.map((job) => (
|
{data.jobs.map((job) => (
|
||||||
<JobRow key={job.id} job={job} />
|
<JobRow
|
||||||
|
key={job.id}
|
||||||
|
job={job}
|
||||||
|
saving={intervalMut.isPending}
|
||||||
|
onSave={(minutes) => intervalMut.mutate({ jobId: job.id, minutes })}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,25 @@
|
||||||
"quotaUsed": "{{used}} / {{budget}} Einheiten heute verbraucht",
|
"quotaUsed": "{{used}} / {{budget}} Einheiten heute verbraucht",
|
||||||
"quotaRemaining": "{{formatted}} übrig",
|
"quotaRemaining": "{{formatted}} übrig",
|
||||||
"quotaNote": "Gemeinsames YouTube-Data-API-Budget; Reset um Mitternacht US-Pazifik. Backfill tritt zurück, um Spielraum für interaktive Nutzung zu lassen.",
|
"quotaNote": "Gemeinsames YouTube-Data-API-Budget; Reset um Mitternacht US-Pazifik. Backfill tritt zurück, um Spielraum für interaktive Nutzung zu lassen.",
|
||||||
|
"minutes": "Min",
|
||||||
|
"editInterval": "Klicken, um das Intervall zu ändern",
|
||||||
|
"intervalFailed": "Intervall konnte nicht geändert werden",
|
||||||
|
"dot": {
|
||||||
|
"running": "läuft gerade",
|
||||||
|
"ok": "letzter Lauf OK",
|
||||||
|
"idle": "untätig (in dieser Sitzung noch nicht gelaufen)",
|
||||||
|
"error": "letzter Lauf fehlgeschlagen",
|
||||||
|
"skipped": "übersprungen (Sync pausiert)"
|
||||||
|
},
|
||||||
|
"jobDesc": {
|
||||||
|
"rss_poll": "Prüft den RSS-Feed jedes Kanals auf neue Uploads (kostenlos, kein Kontingent). Fällt er aus, erscheinen neue Videos erst später, wenn ein Backfill sie erfasst — der Feed hinkt YouTube hinterher.",
|
||||||
|
"enrich": "Holt Videodetails (Dauer, Aufrufe, Live-/Shorts-Flags) und prüft Live-Videos erneut, bis sie enden. Fällt er aus, bleiben Videos ohne Dauer/Statistik und beendete Streams hängen als „live“ fest.",
|
||||||
|
"backfill": "Holt neue Uploads und dann den gesamten Verlauf vorgemerkter Kanäle, innerhalb des gemeinsamen Kontingents. Fällt er aus, werden ältere Videos und der volle Verlauf nicht ergänzt.",
|
||||||
|
"autotag": "Erkennt Sprache/Themen jedes Kanals und vergibt automatische Tags. Fällt er aus, bekommen neue Kanäle keine Auto-Tags, sodass Sprach-/Themenfilter sie verfehlen.",
|
||||||
|
"shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.",
|
||||||
|
"subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.",
|
||||||
|
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht."
|
||||||
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
||||||
"enrich": "Anreicherung + Live-Aktualisierung",
|
"enrich": "Anreicherung + Live-Aktualisierung",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,25 @@
|
||||||
"quotaUsed": "{{used}} / {{budget}} units used today",
|
"quotaUsed": "{{used}} / {{budget}} units used today",
|
||||||
"quotaRemaining": "{{formatted}} left",
|
"quotaRemaining": "{{formatted}} left",
|
||||||
"quotaNote": "Shared YouTube Data API budget; resets at midnight US Pacific. Backfill yields to leave headroom for interactive use.",
|
"quotaNote": "Shared YouTube Data API budget; resets at midnight US Pacific. Backfill yields to leave headroom for interactive use.",
|
||||||
|
"minutes": "min",
|
||||||
|
"editInterval": "Click to change how often this runs",
|
||||||
|
"intervalFailed": "Couldn't change the interval",
|
||||||
|
"dot": {
|
||||||
|
"running": "running now",
|
||||||
|
"ok": "last run OK",
|
||||||
|
"idle": "idle (not run yet this session)",
|
||||||
|
"error": "last run failed",
|
||||||
|
"skipped": "skipped (sync paused)"
|
||||||
|
},
|
||||||
|
"jobDesc": {
|
||||||
|
"rss_poll": "Checks each channel's RSS feed for new uploads (free, no quota). If it stops, new videos only appear later when a backfill pass catches them, so your feed lags behind YouTube.",
|
||||||
|
"enrich": "Fetches video details (duration, views, live/Shorts flags) and re-checks live videos until they end. If it stops, videos stay without a duration/stats and ended livestreams stay stuck as “live”.",
|
||||||
|
"backfill": "Pulls in recent uploads, then the full back-catalogue for opted-in channels, within the shared quota. If it stops, older videos and full history don't fill in.",
|
||||||
|
"autotag": "Detects each channel's language/topics and applies automatic tags. If it stops, new channels get no auto tags, so language/topic filters miss them.",
|
||||||
|
"shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.",
|
||||||
|
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
|
||||||
|
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally."
|
||||||
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS poll (new uploads)",
|
"rss_poll": "RSS poll (new uploads)",
|
||||||
"enrich": "Enrichment + live refresh",
|
"enrich": "Enrichment + live refresh",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,25 @@
|
||||||
"quotaUsed": "{{used}} / {{budget}} egység ma felhasználva",
|
"quotaUsed": "{{used}} / {{budget}} egység ma felhasználva",
|
||||||
"quotaRemaining": "{{formatted}} maradt",
|
"quotaRemaining": "{{formatted}} maradt",
|
||||||
"quotaNote": "Közös YouTube Data API-keret; éjfélkor (US Pacific) nullázódik. A backfill hátrébb sorol, hogy maradjon hely az interaktív használatnak.",
|
"quotaNote": "Közös YouTube Data API-keret; éjfélkor (US Pacific) nullázódik. A backfill hátrébb sorol, hogy maradjon hely az interaktív használatnak.",
|
||||||
|
"minutes": "perc",
|
||||||
|
"editInterval": "Kattints a gyakoriság módosításához",
|
||||||
|
"intervalFailed": "Nem sikerült módosítani a gyakoriságot",
|
||||||
|
"dot": {
|
||||||
|
"running": "most fut",
|
||||||
|
"ok": "utolsó futás OK",
|
||||||
|
"idle": "tétlen (ebben a munkamenetben még nem futott)",
|
||||||
|
"error": "utolsó futás hibázott",
|
||||||
|
"skipped": "kihagyva (szinkron szüneteltetve)"
|
||||||
|
},
|
||||||
|
"jobDesc": {
|
||||||
|
"rss_poll": "Az egyes csatornák RSS-feedjét nézi új feltöltésekért (ingyenes, nem fogyaszt kvótát). Ha leáll, az új videók csak később jelennek meg, amikor egy backfill behúzza őket — így a hírfolyam lemarad a YouTube mögött.",
|
||||||
|
"enrich": "Lekéri a videók adatait (hossz, megtekintés, élő/Shorts jelzők), és frissíti az élő videókat, amíg véget nem érnek. Ha leáll, a videók hossz/statisztika nélkül maradnak, a véget ért adások pedig „élő” állapotban ragadnak.",
|
||||||
|
"backfill": "Behúzza a friss feltöltéseket, majd a kijelölt csatornák teljes előzményét, a közös kvótán belül. Ha leáll, a régebbi videók és a teljes előzmény nem töltődik fel.",
|
||||||
|
"autotag": "Felismeri a csatornák nyelvét/témáit, és automatikus címkéket ad. Ha leáll, az új csatornák címke nélkül maradnak, így a nyelv/téma szűrők kihagyják őket.",
|
||||||
|
"shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.",
|
||||||
|
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
|
||||||
|
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan."
|
||||||
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
||||||
"enrich": "Adatdúsítás + élő frissítés",
|
"enrich": "Adatdúsítás + élő frissítés",
|
||||||
|
|
|
||||||
|
|
@ -434,6 +434,11 @@ export const api = {
|
||||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||||
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
||||||
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
|
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
|
||||||
|
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
|
||||||
|
req(`/api/admin/scheduler/jobs/${jobId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ interval_minutes: intervalMinutes }),
|
||||||
|
}),
|
||||||
|
|
||||||
// --- onboarding / admin ---
|
// --- onboarding / admin ---
|
||||||
requestAccess: (email: string): Promise<{ status: string }> =>
|
requestAccess: (email: string): Promise<{ status: string }> =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue