siftlode/frontend/src/components/SyncStatus.tsx
npeter83 258aa5cc84 perf(header): poll sync status faster while work is in flight
The header polled every 30s, so the live "syncing" state (now gated on a real
running job) was usually missed between ticks. Poll every 8s while a job is
running or channels are pending, easing back to 30s once everything's settled,
via react-query's functional refetchInterval.
2026-06-19 03:24:45 +02:00

124 lines
5.1 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
import { formatViews } from "../lib/format";
import Tooltip from "./Tooltip";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
// history (clickable -> channel manager filtered to those). The pause control is admin-only.
export default function SyncStatus({
isAdmin,
onGoToFullHistory,
}: {
isAdmin: boolean;
onGoToFullHistory: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["my-status"],
queryFn: api.myStatus,
// Track the scheduler near-real-time: poll even when the tab is backgrounded, and
// refresh immediately on tab focus (the global default disables focus refetch), so the
// "N without full history" count keeps ticking down without a manual reload. Poll faster
// while a job is running or work is pending — so the live "syncing" state is actually
// caught — and ease back to a slow idle tick once everything's settled.
refetchInterval: (query) => {
const d = query.state.data as MyStatus | undefined;
const busy =
!!d &&
(d.sync_active || d.channels_recent_pending > 0 || d.channels_deep_pending > 0);
return busy ? 8_000 : 30_000;
},
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
staleTime: 5_000,
});
const toggle = useMutation({
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
});
if (!data) return null;
const syncing = data.channels_recent_pending;
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 (
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
<Tooltip hint={t("header.sync.countTooltip")}>
<span className="flex items-center gap-1.5 cursor-default">
<Database className="w-3.5 h-3.5" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
</span>
<span className="opacity-40">/</span>
<span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</span>
</Tooltip>
{showMain && (
<>
<span className="opacity-40">·</span>
{data.paused ? (
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
) : active ? (
// A sync job is running right now — spin and name what it's doing.
<span className="flex items-center gap-1">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{syncing > 0
? t("header.sync.syncing", { count: syncing })
: notFull > 0
? t("header.sync.backfillingHistory")
: t("header.sync.working")}
</span>
) : syncing > 0 ? (
// Recent uploads queued for the next run, but nothing running now — static.
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{t("header.sync.recentQueued", { count: syncing })}
</span>
) : (
<span>{t("header.sync.allSynced")}</span>
)}
</>
)}
{notFull > 0 && (
<>
<span className="opacity-40">·</span>
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
<button
onClick={onGoToFullHistory}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
</Tooltip>
</>
)}
{/* Pause only makes sense when there's work to pause (recent OR deep backfill);
Resume must always show while paused so it can be lifted. Hidden entirely when
idle and not paused. */}
{isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
<button
onClick={() => toggle.mutate()}
disabled={toggle.isPending}
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
className="ml-1 p-1 rounded-md hover:bg-card hover:text-fg transition"
>
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
</button>
)}
</div>
);
}