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:
@@ -0,0 +1,214 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import { api, type AuditQuery, type AuditRow } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { sinceOf } from "../lib/activityUrlState";
|
||||
import { auditActionLabel, auditText } from "../lib/auditText";
|
||||
import { formatDateTime, formatDateTimeSec } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useHistoryList } from "../lib/useHistoryList";
|
||||
import type { Shared } from "./ActivityRuns";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import HistoryList from "./HistoryList";
|
||||
import HistoryRow from "./HistoryRow";
|
||||
import { Button } from "./ui/Button";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
|
||||
// Activity → Admin actions: the append-only who/what/when trail, newest first, filtered on the
|
||||
// server. A row opens to its before/after and to the lines its request logged. Clear stays here,
|
||||
// leaving one row that says who cleared it.
|
||||
|
||||
const GRID =
|
||||
"grid-cols-[minmax(0,1fr)_auto] sm:grid-cols-[minmax(0,12rem)_minmax(0,12rem)_8rem_minmax(0,1fr)]";
|
||||
|
||||
export default function ActivityAudit({
|
||||
shared,
|
||||
action,
|
||||
onAction,
|
||||
onRequest,
|
||||
}: {
|
||||
shared: Shared;
|
||||
action: string | null;
|
||||
onAction: (action: string | null) => void;
|
||||
/** Open the server log at this request's lines. */
|
||||
onRequest: (requestId: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const query: AuditQuery = {
|
||||
user: shared.user ?? undefined,
|
||||
q: shared.q.trim() || undefined,
|
||||
action: action ?? undefined,
|
||||
};
|
||||
const key = { ...query, window: shared.window };
|
||||
const state = useHistoryList(qk.adminAudit(key), (before) =>
|
||||
api.adminAudit({ ...query, since: sinceOf(shared.window) }, before),
|
||||
);
|
||||
const facets = useQuery({
|
||||
queryKey: qk.adminAuditFacets(key),
|
||||
queryFn: () => api.adminAuditFacets({ ...query, since: sinceOf(shared.window) }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const actions = [
|
||||
...(facets.data?.actions ?? []),
|
||||
...(action && !facets.data?.actions.some((f) => f.value === action)
|
||||
? [{ value: action, count: 0 }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.clearAudit(),
|
||||
onSuccess: (r) => {
|
||||
void qc.invalidateQueries({ queryKey: ["admin-audit"] });
|
||||
void qc.invalidateQueries({ queryKey: ["admin-audit-facets"] });
|
||||
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 actor = (row: AuditRow) =>
|
||||
row.actor_id === null ? null : (state.accounts[String(row.actor_id)] ?? `#${row.actor_id}`);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterSelect
|
||||
label={t("activity.audit.action")}
|
||||
options={[
|
||||
{ value: "", label: t("activity.any") },
|
||||
...actions.map((f) => ({
|
||||
value: f.value,
|
||||
label: auditActionLabel(t, f.value),
|
||||
count: f.count,
|
||||
})),
|
||||
]}
|
||||
selected={[action ?? ""]}
|
||||
onChange={([value]) => onAction(value || null)}
|
||||
testId="audit-action"
|
||||
/>
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
className="ml-auto"
|
||||
onClick={() => void onClear()}
|
||||
disabled={clear.isPending}
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
>
|
||||
{t("audit.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
<HistoryList
|
||||
state={state}
|
||||
empty={t("audit.empty")}
|
||||
count={t("activity.audit.count", { count: state.rows.length })}
|
||||
>
|
||||
{state.rows.map((row, index) => {
|
||||
const { head, sub } = auditText(t, row, lang);
|
||||
const who = actor(row);
|
||||
return (
|
||||
<HistoryRow
|
||||
key={row.id}
|
||||
navIndex={index}
|
||||
navText={`${auditActionLabel(t, row.action)} ${head ?? ""}`}
|
||||
grid={GRID}
|
||||
open={open === row.id}
|
||||
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
||||
title={formatDateTimeSec(row.created_at, lang)}
|
||||
cells={
|
||||
<>
|
||||
<span className="truncate font-medium">{auditActionLabel(t, row.action)}</span>
|
||||
<span className="tabular-nums text-muted sm:hidden">
|
||||
{formatDateTime(row.created_at, lang)}
|
||||
</span>
|
||||
<span
|
||||
className={`hidden truncate sm:block ${who ? "text-muted" : "italic text-muted"}`}
|
||||
>
|
||||
{who ?? t("audit.system")}
|
||||
</span>
|
||||
<span className="hidden tabular-nums text-muted sm:block">
|
||||
{formatDateTime(row.created_at, lang)}
|
||||
</span>
|
||||
<span className="col-span-2 truncate text-muted sm:col-span-1">
|
||||
{[head, sub].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AuditDetail row={row} who={who} onRequest={onRequest} />
|
||||
</HistoryRow>
|
||||
);
|
||||
})}
|
||||
</HistoryList>
|
||||
{facets.isError && <p className="text-2xs text-danger">{t("activity.countsFailed")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditDetail({
|
||||
row,
|
||||
who,
|
||||
onRequest,
|
||||
}: {
|
||||
row: AuditRow;
|
||||
who: string | null;
|
||||
onRequest: (requestId: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { head, sub } = auditText(t, row, lang);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5">
|
||||
<dt>{t("audit.cols.actor")}</dt>
|
||||
<dd>{who ?? t("audit.system")}</dd>
|
||||
<dt>{t("audit.cols.when")}</dt>
|
||||
<dd className="tabular-nums">{formatDateTimeSec(row.created_at, lang)}</dd>
|
||||
{(head || sub) && (
|
||||
<>
|
||||
<dt>{t("audit.cols.details")}</dt>
|
||||
<dd className="wrap-break-word">{[head, sub].filter(Boolean).join(" · ")}</dd>
|
||||
</>
|
||||
)}
|
||||
{row.target_id && (
|
||||
<>
|
||||
<dt>{t("activity.audit.target")}</dt>
|
||||
<dd className="font-mono">{row.target_id}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{(row.before || row.after) && (
|
||||
<pre className="overflow-x-auto whitespace-pre font-mono">
|
||||
{JSON.stringify({ before: row.before, after: row.after }, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{row.request_id && (
|
||||
<div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="underline decoration-dotted underline-offset-2 hover:decoration-solid"
|
||||
onClick={() => onRequest(row.request_id!)}
|
||||
>
|
||||
{t("activity.audit.requestLines")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user