- PageShell names the page with a visually hidden h1; a view with its own title renders it as PageHeading and the hidden one steps aside (contract: no hand <h1> inside the shell). - Section captions are SectionLabel headings (h2, h3 in a dialog), ui/form Section included. - "Skip to content" is the first Tab stop and focuses the scroller's <main>. - Below 600px of height the band's filter row folds into "Filters · n" (Feed, Plex, server log), the same row shown in place. - The band holds controls only: Downloads and Notifications lose their title cards, the Config and channel-discovery intros scroll with the content, the Stats tabs and the audit pager join the band, and the server log's toolbar sticks with the fade under it.
102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
import { useMemo } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Trash2 } from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import { notify } from "../lib/notifications";
|
|
import { LS } from "../lib/storage";
|
|
import { Button } from "./ui/Button";
|
|
import { Section } from "./ui/form";
|
|
import { useConfirm } from "./confirmContext";
|
|
import DataTable from "./ui/DataTable";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import { auditColumns } from "./auditColumns";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
|
|
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
|
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
|
|
// DataTable; the API returns the most recent window (capped) — the log stays small by design.
|
|
export default function AuditLog() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
|
|
const data = q.data;
|
|
const rows = useMemo(() => data?.items ?? [], [data]);
|
|
|
|
const actionOptions = useMemo(() => {
|
|
const present = Array.from(new Set(rows.map((r) => r.action))).sort();
|
|
return present.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) }));
|
|
}, [rows, t]);
|
|
const columns = useMemo(() => auditColumns(t, actionOptions), [t, actionOptions]);
|
|
|
|
const clear = useMutation({
|
|
mutationFn: () => api.clearAudit(),
|
|
onSuccess: (r) => {
|
|
qc.invalidateQueries({ queryKey: qk.adminAudit() });
|
|
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
|
},
|
|
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
|
|
});
|
|
const onClear = async () => {
|
|
if (
|
|
await confirm({
|
|
title: t("audit.clearTitle"),
|
|
message: t("audit.clearConfirm"),
|
|
confirmLabel: t("audit.clear"),
|
|
danger: true,
|
|
})
|
|
)
|
|
clear.mutate();
|
|
};
|
|
|
|
const truncated = !!data && data.total > data.returned;
|
|
|
|
return (
|
|
<div className="p-4 max-w-5xl w-full mx-auto">
|
|
<Section card title={t("audit.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
|
|
{q.isError && !q.data ? (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : q.isLoading ? (
|
|
<LoadingState shape="table" />
|
|
) : rows.length === 0 ? (
|
|
<p className="text-sm text-muted">{t("audit.empty")}</p>
|
|
) : (
|
|
<>
|
|
<DataTable
|
|
rows={rows}
|
|
columns={columns}
|
|
rowKey={(r) => String(r.id)}
|
|
persistKey={LS.auditTable}
|
|
controlsPosition="top"
|
|
controlsInBand
|
|
controlsBandClassName="px-4 max-w-5xl mx-auto"
|
|
controlsLeading={
|
|
<Button
|
|
variant="outline-danger"
|
|
size="xs"
|
|
onClick={onClear}
|
|
disabled={clear.isPending}
|
|
icon={<Trash2 className="w-3.5 h-3.5" />}
|
|
>
|
|
{t("audit.clear")}
|
|
</Button>
|
|
}
|
|
emptyText={t("audit.empty")}
|
|
/>
|
|
{truncated && (
|
|
<p className="text-2xs text-muted mt-2">
|
|
{t("audit.truncated", { returned: data!.returned, total: data!.total })}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</Section>
|
|
</div>
|
|
);
|
|
}
|