The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { History, Pause, Play, RefreshCw } from "lucide-react";
|
|
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
|
import { formatEta, quotaActionLabel } from "../lib/format";
|
|
import { notify } from "../lib/notifications";
|
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
|
import { useWizard } from "./WizardProvider";
|
|
import { LS, useAccountPersistedState } from "../lib/storage";
|
|
import Tooltip from "./ui/Tooltip";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import FilterChipGroup from "./ui/FilterChipGroup";
|
|
import Tabs from "./ui/Tabs";
|
|
import { Section, SettingRow } from "./ui/form";
|
|
import { useMe } from "../lib/useMe";
|
|
|
|
const RANGES = [7, 30, 90] as const;
|
|
type StatsTab = "overview" | "system";
|
|
|
|
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
|
|
// "System" is the admin-only instance-wide quota dashboard + background-sync control. The
|
|
// per-user content used to live in Settings → Sync; it moved here so Settings stays config-only.
|
|
export default function Stats() {
|
|
const me = useMe();
|
|
const { t } = useTranslation();
|
|
const isAdmin = me.role === "admin";
|
|
const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview");
|
|
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
|
|
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
|
|
|
|
return (
|
|
<div className="p-4 max-w-4xl mx-auto">
|
|
{/* Only admins have a second (System) tab; everyone else just sees the Overview. */}
|
|
{isAdmin && (
|
|
<Tabs
|
|
active={tab}
|
|
onChange={setTab}
|
|
tabs={(["overview", "system"] as StatsTab[]).map((id) => ({
|
|
id,
|
|
label: t(`stats.tabs.${id}`),
|
|
}))}
|
|
/>
|
|
)}
|
|
|
|
{tab === "system" && isAdmin ? <SystemStats /> : <Overview me={me} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
|
|
function Overview({ me }: { me: Me }) {
|
|
const { t } = useTranslation();
|
|
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: () => notify({ level: "error", message: t("stats.sync.fullHistoryFailed") }),
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<Section title={t("stats.sync.myStatus")}>
|
|
{s ? (
|
|
<div className="space-y-1.5 text-sm">
|
|
<SettingRow label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
|
|
{s.channels_total}
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("stats.sync.recentSynced")}
|
|
hint={t("stats.sync.recentSyncedHint")}
|
|
>
|
|
{`${s.channels_recent_synced}/${s.channels_total}`}
|
|
</SettingRow>
|
|
<SettingRow label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
|
|
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
|
</SettingRow>
|
|
{s.deep_pending_count > 0 && (
|
|
<SettingRow
|
|
label={t("stats.sync.fullHistoryEta")}
|
|
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
|
|
>
|
|
{formatEta(s.deep_eta_seconds)}
|
|
</SettingRow>
|
|
)}
|
|
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
|
|
{s.my_videos.toLocaleString()}
|
|
</SettingRow>
|
|
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
|
|
{s.quota_remaining_today.toLocaleString()}
|
|
</SettingRow>
|
|
</div>
|
|
) : 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 ? (
|
|
<>
|
|
<div className="space-y-1.5 text-sm">
|
|
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
|
|
{usage.data.today.toLocaleString()}
|
|
</SettingRow>
|
|
<SettingRow label={t("stats.sync.last7d")}>
|
|
{usage.data.last_7d.toLocaleString()}
|
|
</SettingRow>
|
|
<SettingRow label={t("stats.sync.last30d")}>
|
|
{usage.data.last_30d.toLocaleString()}
|
|
</SettingRow>
|
|
<SettingRow label={t("stats.sync.allTime")}>
|
|
{usage.data.all_time.toLocaleString()}
|
|
</SettingRow>
|
|
</div>
|
|
{Object.keys(usage.data.by_action).length > 0 && (
|
|
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
|
|
<div className="uppercase tracking-wide">{t("stats.sync.byAction")}</div>
|
|
{Object.entries(usage.data.by_action)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([action, units]) => (
|
|
<div key={action} className="flex justify-between gap-2">
|
|
<span>{quotaActionLabel(action)}</span>
|
|
<span className="tabular-nums">{units.toLocaleString()}</span>
|
|
</div>
|
|
))}
|
|
</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>
|
|
|
|
{!me.is_demo && (
|
|
<Section title={t("stats.sync.actions")}>
|
|
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
|
|
<button
|
|
onClick={() => syncSubs.mutate()}
|
|
disabled={syncSubs.isPending}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
|
{t("stats.sync.syncSubscriptions")}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
|
|
<button
|
|
onClick={() => deepAll.mutate()}
|
|
disabled={deepAll.isPending}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
|
>
|
|
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
|
{t("stats.sync.backfillEverything")}
|
|
</button>
|
|
</Tooltip>
|
|
</Section>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle.
|
|
function SystemStats() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const [days, setDays] = useState<number>(30);
|
|
const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) });
|
|
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
|
const pauseResume = useMutation({
|
|
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
|
});
|
|
const data = q.data;
|
|
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
|
|
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
|
|
const s = status.data;
|
|
|
|
return (
|
|
<>
|
|
{s && (
|
|
<Section title={t("stats.sync.admin")}>
|
|
<Tooltip hint={t("stats.sync.adminPauseHint")}>
|
|
<button
|
|
onClick={() => pauseResume.mutate(s.paused)}
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
|
|
>
|
|
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
|
{s.paused
|
|
? t("stats.sync.resumeBackgroundSync")
|
|
: t("stats.sync.pauseBackgroundSync")}
|
|
</button>
|
|
</Tooltip>
|
|
</Section>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between gap-3 mb-4">
|
|
<h2 className="text-lg font-semibold">{t("stats.title")}</h2>
|
|
<FilterChipGroup
|
|
ariaLabel={t("stats.rangeLabel")}
|
|
className="flex items-center gap-1.5"
|
|
options={RANGES.map((d) => ({
|
|
value: String(d),
|
|
label: t("stats.rangeDays", { count: d }),
|
|
}))}
|
|
value={String(days)}
|
|
onChange={(v) => setDays(Number(v))}
|
|
/>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted mb-4 leading-relaxed">
|
|
{t("stats.introBefore")}
|
|
<b className="text-fg/80">{t("stats.introSystem")}</b>
|
|
{t("stats.introAfter")}
|
|
</p>
|
|
|
|
{q.isError && !data ? (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : q.isLoading ? (
|
|
<LoadingState label={t("stats.loading")} />
|
|
) : !data ? (
|
|
<div className="text-muted py-8">{t("stats.noData")}</div>
|
|
) : (
|
|
<>
|
|
{/* Daily totals (instance-wide, Pacific days) */}
|
|
<div className="glass-card rounded-xl p-3 mb-4">
|
|
<div className="text-xs text-muted mb-2">
|
|
{t("stats.dailyTotal", {
|
|
count: data.range_days,
|
|
units: grandTotal.toLocaleString(),
|
|
})}
|
|
</div>
|
|
{data.daily.length === 0 ? (
|
|
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
|
|
) : (
|
|
<div className="flex items-end gap-0.5 h-24">
|
|
{data.daily.map((d) => (
|
|
<div
|
|
key={d.day}
|
|
className="flex-1 h-full flex items-end bg-card/50 rounded-t"
|
|
title={t("stats.dailyTooltip", { day: d.day, units: d.total.toLocaleString() })}
|
|
>
|
|
<div
|
|
className="w-full bg-accent hover:opacity-80 rounded-t transition"
|
|
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Per-user breakdown */}
|
|
<div className="flex flex-col gap-1.5">
|
|
{data.rows.length === 0 ? (
|
|
<div className="text-muted py-4">{t("stats.noUsageInRange")}</div>
|
|
) : (
|
|
data.rows.map((r) => (
|
|
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
|
|
const isSystem = row.user_id === null;
|
|
return (
|
|
<div className="glass-card rounded-xl p-3">
|
|
<button
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="w-full flex items-center gap-3 text-left"
|
|
>
|
|
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
|
{isSystem ? t("stats.system") : row.email}
|
|
</span>
|
|
<span className="text-sm font-semibold tabular-nums shrink-0">
|
|
{row.total.toLocaleString()}
|
|
</span>
|
|
</button>
|
|
{/* Proportion bar */}
|
|
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-accent rounded-full"
|
|
style={{ width: `${Math.max(2, (row.total / max) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
{open && actions.length > 0 && (
|
|
<div className="mt-2 space-y-1 text-xs text-muted">
|
|
{actions.map(([action, units]) => (
|
|
<div key={action} className="flex items-center justify-between gap-2">
|
|
<span>{quotaActionLabel(action)}</span>
|
|
<span className="tabular-nums">{units.toLocaleString()}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|