Files
siftlode/frontend/src/components/Stats.tsx
T
peter 24448e7c6f feat(activity): one Activity page — runs, admin actions, server log and quota under one filter bar
The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.

Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.

Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
2026-10-18 03:45:18 +02:00

193 lines
7.4 KiB
TypeScript

import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { History, RefreshCw } from "lucide-react";
import { api } from "../lib/api";
import { formatEta, formatNumber, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { useWizard } from "./wizardContext";
import Tooltip from "./ui/Tooltip";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { Section } from "./ui/form";
import { SectionLabel } from "./ui/Headings";
import { StatBars, StatTile, StatTiles } from "./ui/StatTiles";
import { notifyActionError } from "../lib/actionErrors";
import { Button } from "./ui/Button";
// Usage & stats page: your sync status, your own API usage and the manual sync actions (they used to
// live in Settings → Sync; they moved here so Settings stays config-only). The instance-wide quota
// view is the activity page's Quota tab.
export default function Stats() {
return (
<div className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
<Overview />
</div>
);
}
// Per-user view: sync status + your own API usage + the manual sync actions.
function Overview() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
// The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here.
const { openWizard } = useWizard();
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const usage = useQuery({ queryKey: qk.myUsage(), queryFn: api.myUsage });
const s = status.data;
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.channels() });
notify({
level: "success",
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
});
},
// The same helper and the same wizard handle as the Channels page: it translates the 403 into
// "connect YouTube" WITH a button that opens the wizard — instead of surfacing the route's bare
// English detail under a Hungarian UI — and delegates everything else to notifyActionError so
// the server's own reason wins, which is what makes the SEC-14 cooldown read as a pace limit
// rather than "Sync failed".
onError: (e) => notifyYouTubeActionError(e, t("stats.sync.syncFailed"), openWizard),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.channels() });
notify({
level: "success",
message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
});
},
onError: (e) => notifyActionError(e, t("stats.sync.fullHistoryFailed")),
});
return (
<>
<Section title={t("stats.sync.myStatus")}>
{s ? (
<StatTiles>
<StatTile
label={t("stats.sync.channels")}
hint={t("stats.sync.channelsHint")}
value={formatNumber(s.channels_total, lang)}
/>
<StatTile
label={t("stats.sync.recentSynced")}
hint={t("stats.sync.recentSyncedHint")}
value={`${formatNumber(s.channels_recent_synced, lang)}/${formatNumber(s.channels_total, lang)}`}
/>
<StatTile
label={t("stats.sync.fullHistory")}
hint={t("stats.sync.fullHistoryHint")}
value={`${formatNumber(s.channels_deep_done, lang)}/${formatNumber(s.channels_deep_requested, lang)}`}
/>
{s.deep_pending_count > 0 && (
<StatTile
label={t("stats.sync.fullHistoryEta")}
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
value={formatEta(t, s.deep_eta_seconds)}
/>
)}
<StatTile
label={t("stats.sync.myVideos")}
hint={t("stats.sync.myVideosHint")}
value={formatNumber(s.my_videos, lang)}
/>
<StatTile
label={t("stats.sync.quotaLeft")}
hint={t("stats.sync.quotaLeftHint")}
value={formatNumber(s.quota_remaining_today, lang)}
/>
</StatTiles>
) : status.isError ? (
<StateMessage tone="error" onRetry={() => status.refetch()}>
{t("common.loadError")}
</StateMessage>
) : (
<LoadingState label={t("stats.sync.loading")} />
)}
</Section>
<Section title={t("stats.sync.apiUsage")}>
{usage.data ? (
<>
<StatTiles>
<StatTile
label={t("stats.sync.today")}
hint={t("stats.sync.todayHint")}
value={formatNumber(usage.data.today, lang)}
/>
<StatTile
label={t("stats.sync.last7d")}
value={formatNumber(usage.data.last_7d, lang)}
/>
<StatTile
label={t("stats.sync.last30d")}
value={formatNumber(usage.data.last_30d, lang)}
/>
<StatTile
label={t("stats.sync.allTime")}
value={formatNumber(usage.data.all_time, lang)}
/>
</StatTiles>
{Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-4">
<SectionLabel level={3} className="mb-2">
{t("stats.sync.byAction")}
</SectionLabel>
<StatBars
label={t("stats.sync.byAction")}
format={(n) => formatNumber(n, lang)}
rows={Object.entries(usage.data.by_action).map(([action, units]) => ({
id: action,
label: quotaActionLabel(t, action),
value: units,
}))}
/>
</div>
)}
</>
) : usage.isError ? (
<StateMessage tone="error" onRetry={() => usage.refetch()}>
{t("common.loadError")}
</StateMessage>
) : usage.isLoading ? (
<LoadingState label={t("stats.sync.loading")} />
) : (
<div className="text-muted text-sm">{t("stats.sync.noUsage")}</div>
)}
</Section>
<Section title={t("stats.sync.actions")}>
<div className="flex flex-wrap gap-2">
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
<Button
variant="glass"
icon={<RefreshCw />}
loading={syncSubs.isPending}
onClick={() => syncSubs.mutate()}
>
{t("stats.sync.syncSubscriptions")}
</Button>
</Tooltip>
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
<Button
variant="glass"
icon={<History />}
loading={deepAll.isPending}
onClick={() => deepAll.mutate()}
>
{t("stats.sync.backfillEverything")}
</Button>
</Tooltip>
</div>
</Section>
</>
);
}