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.
This commit is contained in:
@@ -1,67 +1,27 @@
|
||||
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 } from "../lib/api";
|
||||
import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/format";
|
||||
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 { LS, useTabPersistedState } 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 { useTabPanel } from "./ui/tabPanel";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { Section } from "./ui/form";
|
||||
import { SectionLabel } from "./ui/Headings";
|
||||
import { StatBars, StatTile, StatTiles } from "./ui/StatTiles";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
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.
|
||||
// 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() {
|
||||
const me = useMe();
|
||||
const { t } = useTranslation();
|
||||
const isAdmin = me.role === "admin";
|
||||
const [tabRaw, setTab] = useTabPersistedState(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";
|
||||
const panel = useTabPanel();
|
||||
|
||||
return (
|
||||
// A tab panel only where there are tabs: everyone but an admin sees the Overview alone.
|
||||
<div {...(isAdmin ? panel.panelProps(tab) : {})} className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
|
||||
{/* Only admins have a second (System) tab; everyone else just sees the Overview. The tabs are
|
||||
the page's band (B4), so they stay in reach on the long System tab. */}
|
||||
{isAdmin && (
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
tabs={(["overview", "system"] as StatsTab[]).map((id) => ({
|
||||
id,
|
||||
label: t(`stats.tabs.${id}`),
|
||||
}))}
|
||||
className="flex flex-wrap gap-1 pb-2"
|
||||
/>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
)}
|
||||
|
||||
{tab === "system" && isAdmin ? <SystemStats /> : <Overview />}
|
||||
<div className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
|
||||
<Overview />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -230,165 +190,3 @@ function Overview() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle.
|
||||
function SystemStats() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
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() }),
|
||||
onError: (e, paused) =>
|
||||
notifyActionError(
|
||||
e,
|
||||
t(paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
|
||||
),
|
||||
});
|
||||
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
|
||||
variant="glass"
|
||||
icon={s.paused ? <Play /> : <Pause />}
|
||||
loading={pauseResume.isPending}
|
||||
onClick={() => pauseResume.mutate(s.paused)}
|
||||
>
|
||||
{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", { d: formatUnit(d, "day", lang) }),
|
||||
}))}
|
||||
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={`${SURFACE.card} p-3 mb-4`}>
|
||||
<div className="text-xs text-muted mb-2">
|
||||
{t("stats.dailyTotal", {
|
||||
d: formatUnit(data.range_days, "day", lang),
|
||||
units: formatNumber(grandTotal, lang),
|
||||
})}
|
||||
</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}
|
||||
// The day under the pointer is lit by the B3 layer over its whole column, not by
|
||||
// dimming the bar: an opacity dim reads as "disabled".
|
||||
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
|
||||
title={t("stats.dailyTooltip", {
|
||||
day: d.day,
|
||||
units: formatNumber(d.total, lang),
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-accent rounded-t"
|
||||
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, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
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={`${SURFACE.card} p-3`}>
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-3"
|
||||
>
|
||||
<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">
|
||||
{formatNumber(row.total, lang)}
|
||||
</span>
|
||||
</Pressable>
|
||||
{/* 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(t, action)}</span>
|
||||
<span className="tabular-nums">{formatNumber(units, lang)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user