Merge improvement/header-activity-coherent: header reflects live sync activity, not pending work

This commit is contained in:
npeter83 2026-06-19 03:07:35 +02:00
commit 72a26fb455
7 changed files with 48 additions and 18 deletions

View file

@ -13,6 +13,7 @@ from app.sync.runner import (
run_rss_poll, run_rss_poll,
) )
from app.sync.subscriptions import import_subscriptions from app.sync.subscriptions import import_subscriptions
from app.scheduler import running_job_ids
router = APIRouter(prefix="/api/sync", tags=["sync"]) router = APIRouter(prefix="/api/sync", tags=["sync"])
@ -170,6 +171,9 @@ def my_status(
"quota_used_today": quota.units_used_today(db), "quota_used_today": quota.units_used_today(db),
"quota_remaining_today": quota.remaining_today(db), "quota_remaining_today": quota.remaining_today(db),
"paused": state.is_sync_paused(db), "paused": state.is_sync_paused(db),
# True only while a job that advances this view (channel sync) is actually running,
# so the header shows live activity rather than a perpetual spinner over pending work.
"sync_active": bool({"backfill", "rss_poll"} & running_job_ids()),
} }

View file

@ -272,6 +272,13 @@ def _is_running(job_id: str) -> bool:
return bool(_activity.get(job_id, {}).get("running")) return bool(_activity.get(job_id, {}).get("running"))
def running_job_ids() -> set[str]:
"""Ids of jobs executing right now (any trigger). Lets non-admin surfaces — e.g. the
header's sync indicator — reflect real activity instead of just pending-work counts."""
with _activity_lock:
return {jid for jid, a in _activity.items() if a.get("running")}
def trigger_job(job_id: str, actor_id: int | None = None) -> str: def trigger_job(job_id: str, actor_id: int | None = None) -> str:
"""Run a job immediately in a background thread, independent of its interval schedule. """Run a job immediately in a background thread, independent of its interval schedule.
The wrapper handles its own DB session, activity tracking and pause-skip, so the live The wrapper handles its own DB session, activity tracking and pause-skip, so the live

View file

@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Database, History, Loader2, Pause, Play } from "lucide-react"; import { Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { formatViews } from "../lib/format"; import { formatViews } from "../lib/format";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -38,6 +38,10 @@ export default function SyncStatus({
const syncing = data.channels_recent_pending; const syncing = data.channels_recent_pending;
const notFull = data.channels_deep_pending; // your channels without full history yet const notFull = data.channels_deep_pending; // your channels without full history yet
// Spin only while a sync job is actually running; otherwise pending work shows as a calm,
// static state (or, for deep history, just the "N without full history" link below).
const active = data.sync_active;
const showMain = data.paused || active || syncing > 0 || notFull === 0;
return ( return (
<div className="hidden lg:flex items-center gap-2 text-xs text-muted"> <div className="hidden lg:flex items-center gap-2 text-xs text-muted">
@ -54,24 +58,32 @@ export default function SyncStatus({
</span> </span>
</span> </span>
</Tooltip> </Tooltip>
{showMain && (
<>
<span className="opacity-40">·</span> <span className="opacity-40">·</span>
{data.paused ? ( {data.paused ? (
<span className="text-accent font-medium">{t("header.sync.paused")}</span> <span className="text-accent font-medium">{t("header.sync.paused")}</span>
) : syncing > 0 ? ( ) : active ? (
// A sync job is running right now — spin and name what it's doing.
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("header.sync.syncing", { count: syncing })} {syncing > 0
? t("header.sync.syncing", { count: syncing })
: notFull > 0
? t("header.sync.backfillingHistory")
: t("header.sync.working")}
</span> </span>
) : notFull > 0 ? ( ) : syncing > 0 ? (
// Recent uploads are in, but the scheduler is still backfilling full history — // Recent uploads queued for the next run, but nothing running now — static.
// so "all synced" would be misleading. Reflect the deep work as active.
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Clock className="w-3.5 h-3.5" />
{t("header.sync.backfillingHistory")} {t("header.sync.recentQueued", { count: syncing })}
</span> </span>
) : ( ) : (
<span>{t("header.sync.allSynced")}</span> <span>{t("header.sync.allSynced")}</span>
)} )}
</>
)}
{notFull > 0 && ( {notFull > 0 && (
<> <>
<span className="opacity-40">·</span> <span className="opacity-40">·</span>

View file

@ -31,6 +31,8 @@
"paused": "pausiert", "paused": "pausiert",
"syncing": "{{count}} werden synchronisiert", "syncing": "{{count}} werden synchronisiert",
"backfillingHistory": "Verlauf wird geladen", "backfillingHistory": "Verlauf wird geladen",
"working": "Synchronisierung…",
"recentQueued": "{{count}} in Warteschlange",
"allSynced": "alles synchronisiert", "allSynced": "alles synchronisiert",
"withoutFullHistory": "{{count}} ohne vollständigen Verlauf", "withoutFullHistory": "{{count}} ohne vollständigen Verlauf",
"fullHistoryTooltip": "Einige deiner Kanäle haben nur ihre neuesten Uploads. Klicke, um die Kanalverwaltung (auf diese gefiltert) zu öffnen und ihren vollständigen Katalog anzufordern.", "fullHistoryTooltip": "Einige deiner Kanäle haben nur ihre neuesten Uploads. Klicke, um die Kanalverwaltung (auf diese gefiltert) zu öffnen und ihren vollständigen Katalog anzufordern.",

View file

@ -31,6 +31,8 @@
"paused": "paused", "paused": "paused",
"syncing": "{{count}} syncing", "syncing": "{{count}} syncing",
"backfillingHistory": "fetching history", "backfillingHistory": "fetching history",
"working": "syncing…",
"recentQueued": "{{count}} queued",
"allSynced": "all synced", "allSynced": "all synced",
"withoutFullHistory": "{{count}} without full history", "withoutFullHistory": "{{count}} without full history",
"fullHistoryTooltip": "Some of your channels only have their recent uploads. Click to open the channel manager (filtered to these) and request their full back-catalog.", "fullHistoryTooltip": "Some of your channels only have their recent uploads. Click to open the channel manager (filtered to these) and request their full back-catalog.",

View file

@ -31,6 +31,8 @@
"paused": "szüneteltetve", "paused": "szüneteltetve",
"syncing": "{{count}} szinkronizálás alatt", "syncing": "{{count}} szinkronizálás alatt",
"backfillingHistory": "előzmény letöltése", "backfillingHistory": "előzmény letöltése",
"working": "szinkronizálás…",
"recentQueued": "{{count}} sorban",
"allSynced": "minden szinkronban", "allSynced": "minden szinkronban",
"withoutFullHistory": "{{count}} teljes előzmény nélkül", "withoutFullHistory": "{{count}} teljes előzmény nélkül",
"fullHistoryTooltip": "Néhány csatornádnak csak a legutóbbi feltöltései vannak meg. Kattints a csatornakezelő megnyitásához (ezekre szűrve), és kérd le a teljes archívumukat.", "fullHistoryTooltip": "Néhány csatornádnak csak a legutóbbi feltöltései vannak meg. Kattints a csatornakezelő megnyitásához (ezekre szűrve), és kérd le a teljes archívumukat.",

View file

@ -316,6 +316,7 @@ export interface MyStatus {
quota_used_today: number; quota_used_today: number;
quota_remaining_today: number; quota_remaining_today: number;
paused: boolean; paused: boolean;
sync_active: boolean; // a channel-sync job (backfill/rss) is running right now
} }
export interface MyUsage { export interface MyUsage {