feat(stats): consolidate per-user sync status into the Stats page

The Settings > Sync tab moves into the Stats module: Stats is now a per-user
page (Overview tab = sync status + your own API usage + manual actions) with the
admin instance-wide quota dashboard + background-sync pause as an admin-only
System tab. The Stats nav item is visible to all users (was admin-only); the
Settings Sync tab is removed. Sync i18n strings move from settings.sync.* to
stats.sync.* (EN/HU/DE).
This commit is contained in:
npeter83 2026-06-19 11:48:20 +02:00
parent dc70a978ed
commit 874520bd8a
10 changed files with 358 additions and 306 deletions

View file

@ -133,13 +133,12 @@ export default function NavSidebar({
{ page: "channels", icon: Tv, label: t("header.account.channels") },
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
];
const systemItems: NavItem[] =
me.role === "admin"
? [
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
]
? [{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") }]
: [];
const rowBase =

View file

@ -1,10 +1,9 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react";
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Invite, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format";
import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
@ -32,11 +31,10 @@ export interface PrefsController {
saveState: PrefsSaveState;
}
type TabId = "appearance" | "notifications" | "sync" | "account";
type TabId = "appearance" | "notifications" | "account";
const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor },
{ id: "notifications", icon: Bell },
{ id: "sync", icon: RefreshCw },
{ id: "account", icon: User },
];
@ -98,7 +96,6 @@ export default function SettingsPanel({
<div className="flex-1 min-w-0 p-4">
{tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "sync" && <Sync me={me} />}
{tab === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
</div>
</div>
@ -317,158 +314,6 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
);
}
function Sync({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
const s = status.data;
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
notify({
level: "success",
message: t("settings.sync.synced", { count: r.subscriptions ?? 0 }),
});
},
onError: () => notify({ level: "error", message: t("settings.sync.syncFailed") }),
});
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
notify({
level: "success",
message: t("settings.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
});
},
onError: () => notify({ level: "error", message: t("settings.sync.fullHistoryFailed") }),
});
return (
<>
<Section title={t("settings.sync.myStatus")}>
{s ? (
<div className="space-y-1.5 text-sm">
<Row label={t("settings.sync.channels")} hint={t("settings.sync.channelsHint")}>
{s.channels_total}
</Row>
<Row
label={t("settings.sync.recentSynced")}
hint={t("settings.sync.recentSyncedHint")}
>
{`${s.channels_recent_synced}/${s.channels_total}`}
</Row>
<Row
label={t("settings.sync.fullHistory")}
hint={t("settings.sync.fullHistoryHint")}
>
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
</Row>
{s.deep_pending_count > 0 && (
<Row
label={t("settings.sync.fullHistoryEta")}
hint={t("settings.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
>
{formatEta(s.deep_eta_seconds)}
</Row>
)}
<Row label={t("settings.sync.myVideos")} hint={t("settings.sync.myVideosHint")}>
{s.my_videos.toLocaleString()}
</Row>
<Row
label={t("settings.sync.quotaLeft")}
hint={t("settings.sync.quotaLeftHint")}
>
{s.quota_remaining_today.toLocaleString()}
</Row>
</div>
) : (
<div className="text-muted text-sm">{t("settings.sync.loading")}</div>
)}
</Section>
<Section title={t("settings.sync.apiUsage")}>
{usage.data ? (
<>
<div className="space-y-1.5 text-sm">
<Row label={t("settings.sync.today")} hint={t("settings.sync.todayHint")}>
{usage.data.today.toLocaleString()}
</Row>
<Row label={t("settings.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
<Row label={t("settings.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
<Row label={t("settings.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
</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("settings.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>
)}
</>
) : (
<div className="text-muted text-sm">{t("settings.sync.noUsage")}</div>
)}
</Section>
{!me.is_demo && (
<Section title={t("settings.sync.actions")}>
<Tooltip hint={t("settings.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("settings.sync.syncSubscriptions")}
</button>
</Tooltip>
<Tooltip hint={t("settings.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("settings.sync.backfillEverything")}
</button>
</Tooltip>
</Section>
)}
{me.role === "admin" && s && (
<Section title={t("settings.sync.admin")}>
<Tooltip hint={t("settings.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("settings.sync.resumeBackgroundSync")
: t("settings.sync.pauseBackgroundSync")}
</button>
</Tooltip>
</Section>
)}
</>
);
}
function AccessRow({
title,
granted,

View file

@ -1,24 +1,236 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { api, type AdminQuotaRow } from "../lib/api";
import { quotaActionLabel } from "../lib/format";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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 Tooltip from "./Tooltip";
const RANGES = [7, 30, 90] as const;
const STATS_TAB_KEY = "siftlode.statsTab";
type StatsTab = "overview" | "system";
export default function Stats() {
// 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({ me }: { me: Me }) {
const { t } = useTranslation();
const isAdmin = me.role === "admin";
const [tab, setTabState] = useState<StatsTab>(() => {
const v = localStorage.getItem(STATS_TAB_KEY);
return v === "system" && isAdmin ? "system" : "overview";
});
const setTab = (id: StatsTab) => {
setTabState(id);
localStorage.setItem(STATS_TAB_KEY, id);
};
return (
<div className="p-4 max-w-4xl mx-auto">
{/* Only admins have a second (System) tab; everyone else just sees the Overview. */}
{isAdmin && (
<div className="flex items-center gap-1 mb-4">
{(["overview", "system"] as StatsTab[]).map((id) => (
<button
key={id}
onClick={() => setTab(id)}
className={`px-3 py-1.5 rounded-full text-sm border transition ${
tab === id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t(`stats.tabs.${id}`)}
</button>
))}
</div>
)}
{tab === "system" && isAdmin ? <SystemStats /> : <Overview me={me} />}
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
{children}
</div>
);
}
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
<Tooltip hint={hint ?? ""}>
<span
className={
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
}
>
{label}
</span>
</Tooltip>
{children}
</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();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
const s = status.data;
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
notify({ level: "success", message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }) });
},
onError: () => notify({ level: "error", message: t("stats.sync.syncFailed") }),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["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">
<Row label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
{s.channels_total}
</Row>
<Row label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
{`${s.channels_recent_synced}/${s.channels_total}`}
</Row>
<Row label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
</Row>
{s.deep_pending_count > 0 && (
<Row
label={t("stats.sync.fullHistoryEta")}
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
>
{formatEta(s.deep_eta_seconds)}
</Row>
)}
<Row label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
{s.my_videos.toLocaleString()}
</Row>
<Row label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
{s.quota_remaining_today.toLocaleString()}
</Row>
</div>
) : (
<div className="text-muted text-sm">{t("stats.sync.loading")}</div>
)}
</Section>
<Section title={t("stats.sync.apiUsage")}>
{usage.data ? (
<>
<div className="space-y-1.5 text-sm">
<Row label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
{usage.data.today.toLocaleString()}
</Row>
<Row label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
<Row label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
<Row label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
</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>
)}
</>
) : (
<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: ["admin-quota", days],
queryFn: () => api.adminQuota(days),
const q = useQuery({ queryKey: ["admin-quota", days], queryFn: () => api.adminQuota(days) });
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
});
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 (
<div className="p-4 max-w-4xl mx-auto">
<>
{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>
<div className="flex items-center gap-1">
@ -53,25 +265,17 @@ export default function Stats() {
{/* 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(),
})}
{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) => (
// Each day gets a full-height track so the column is always visible
// (even on near-zero days); the accent fill renders the value on top.
<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(),
})}
title={t("stats.dailyTooltip", { day: d.day, units: d.total.toLocaleString() })}
>
<div
className="w-full bg-accent hover:opacity-80 rounded-t transition"
@ -95,7 +299,7 @@ export default function Stats() {
</div>
</>
)}
</div>
</>
);
}
@ -106,16 +310,11 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
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"
>
<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>
<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">