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.
193 lines
7.4 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|