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.
230 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|