Files
siftlode/frontend/src/components/ActivityAudit.tsx
T
peter fad656988a fix(activity): runs keep their true outcome and spend, the page reaches every kept row, a closing dialog leaves the server log alone
Backend
- A run's outcome write sits outside the job's own try: a database blip after a run that
  succeeded no longer records it as failed (the row waits for the next start's sweep).
- An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does).
- The Scheduler's ad-hoc row says running while any backfill runs, not only the newest.
- recent_runs reads each job's last runs off its index instead of ranking the whole table.
- An interval change is stored with its audit row in one transaction, then rescheduled.
- One API instance per database is the stated contract (.env.example, jobruns).
- Turning full history on for a channel already complete records no run.
- Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are
  test-held to the enums; the account filter is one helper for the four activity reads.
- An audit row says how many of its request's log lines are still kept.

Frontend
- An "All" period reaches every row still kept (runs and admin actions outlive 90 days).
- Back/Forward is followed in place by the server log: a closing dialog no longer turns
  Live off; a pivot still opens a fresh log.
- Runs moved over from the audit log read their one-liner in the reader's language.
- Filter choices or accounts that failed to load say so beside the filters, with a retry.
- An old server-log link keeps its account, text and range; a remembered retired page
  opens its tab.
- A request whose lines are gone says so instead of linking to an empty list.
- The log lines and the history rows share one row frame, and the server log one list
  frame with the other tabs; an empty list is announced; each day's quota is readable.
2026-10-18 15:38:32 +02:00

230 lines
7.9 KiB
TypeScript

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 { 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 { StateMessage } from "./ui/QueryState";
import HistoryList from "./HistoryList";
import HistoryRow from "./HistoryRow";
import { RelativeTime } from "./ui/RelativeTime";
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(10rem,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: qk.adminAudit() });
void qc.invalidateQueries({ queryKey: qk.adminAuditFacets() });
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>
{/* The action menu above is built from these counts: without them it offers only "Any". */}
{facets.isError && (
<StateMessage tone="error" compact onRetry={() => void facets.refetch()}>
{t("activity.filtersFailed")}
</StateMessage>
)}
<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>
<RelativeTime
at={row.created_at}
seconds
className="tabular-nums text-muted sm:hidden"
/>
<span
className={`hidden truncate sm:block ${who ? "text-muted" : "italic text-muted"}`}
>
{who ?? t("audit.system")}
</span>
<RelativeTime
at={row.created_at}
seconds
className="hidden tabular-nums text-muted sm:block"
/>
<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>
</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>
)}
{/* A request's lines are kept days, its row months: a link to none would open an empty list. */}
{row.request_id &&
(row.log_lines > 0 ? (
<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>
) : (
<p>{t("activity.audit.noRequestLines")}</p>
))}
</div>
);
}