Files
siftlode/frontend/src/components/AdminLogs.tsx
T
peter 13b7577d9f fix(activity): an ad-hoc run's row is one run's, a rollback keeps a run whole, Back reads one entry's filters
- The channel-backfill row says "running" from the runs this process has open, not from the table:
  a row whose outcome failed to write no longer reads as running until a restart. The run still
  going leads the row, so a newer run's outcome never sits under the older one's channel.
- Migration 0109's downgrade carries every run's trigger, spend and start in the audit row, and the
  upgrade restores them: a manual run whose admin was deleted stays manual.
- Back/Forward is read by the page's one popstate listener, both halves in one render; the server
  log adopts its half during that render, so no read goes out with one entry's filters and the
  other's range.
- The server log's failed facets use the other tabs' words (the menus may be short); the accounts
  message shows only when the menu really lacks them.
2026-10-18 17:32:22 +02:00

460 lines
17 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Copy, Radio } from "lucide-react";
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard";
import { formatDateTimeSec } from "../lib/format";
import { nextPageOnClick } from "../lib/nextPage";
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
import { sinceOf } from "../lib/activityUrlState";
import { logsParamsToState, logsParamUpdates, type LogsUrlState } from "../lib/logsUrlState";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { useUrlMirror } from "../lib/useUrlMirror";
import { HistoryFrame } from "./HistoryList";
import LogLine from "./LogLine";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
import { FilterFoldButton } from "./ui/FilterFold";
import { useFilterFold } from "../lib/useFilterFold";
import { DismissibleChip } from "./ui/Chip";
import type { Shared } from "./ActivityRuns";
// Activity → Server log: the server's stored log (`log_events`). One list, newest first; a line
// opens in place to its full record, and its request / job / run id narrows the list to that
// request, job or run (the page does that: a pivot is a new history entry with every other filter
// off). The time range, account and text come from the page's shared bar; the rest live in the URL
// (./logsUrlState), so a filtered view is a link. "Élő" re-reads the newest page every
// few seconds while the tab is visible — the newest page only: live is watching the top, so paging
// into older rows turns it off. A failed read stops the polling (the request already reported it
// once) and marks the list as possibly stale, instead of re-raising the failure every few seconds.
const LIVE_MS = 4000;
const GROUPS: readonly LevelGroup[] = ["all", "error", "warning", "info", "detail"];
function toQuery(state: LogsUrlState, shared: Shared): LogQuery {
return {
levels: state.group === "all" ? undefined : LEVEL_GROUPS[state.group],
sources: state.sources,
user: shared.user ?? undefined,
event: state.event ?? undefined,
request_id: state.requestId ?? undefined,
job_id: state.jobId ?? undefined,
run_id: state.runId ?? undefined,
q: shared.q.trim() || undefined,
};
}
/** The ids a line can narrow the list to. */
export type LogPivot = Pick<LogsUrlState, "requestId" | "jobId" | "runId">;
export default function AdminLogs({
shared,
landed,
onPivot,
}: {
shared: Shared;
/** The filters of the entry Back/Forward landed on (the page reads the address bar, once). */
landed: { logs: LogsUrlState } | null;
/** Show only one request's, job's or run's lines (the page clears every other filter). */
onPivot: (pivot: LogPivot) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const [state, setState] = useState<LogsUrlState>(() =>
logsParamsToState(new URLSearchParams(window.location.search)),
);
const [live, setLiveState] = useState(false);
// The facet row folds away when the screen is short.
const fold = useFilterFold();
const facetFilters = state.sources.length + (state.event ? 1 : 0);
// Read by `getNextPageParam` when a page is fetched, not when it renders: while live, a refetch
// stops after the newest page, however many pages the cache holds — a "Load older" that lands
// after Live was switched on, or a filter set revisited with older pages still cached.
const liveRef = useRef(false);
const setLive = (on: boolean) => {
liveRef.current = on;
setLiveState(on);
};
const [open, setOpen] = useState<number | null>(null);
useUrlMirror(logsParamUpdates(state));
// Back/Forward lands on another entry's filters; a dialog closing lands on these same ones, and
// changes nothing (Live and the open line stay). Adopted during the render that brings it, with
// the page's half.
const [seen, setSeen] = useState(landed);
if (landed !== seen) {
setSeen(landed);
if (landed && JSON.stringify(landed.logs) !== JSON.stringify(state)) {
setOpen(null);
setState(landed.logs);
}
}
const update = (patch: Partial<LogsUrlState>) => {
setOpen(null);
setState((s) => ({ ...s, ...patch }));
};
const query = toQuery(state, shared);
const key = qk.adminLogs({ ...query, window: shared.window });
const logs = useInfiniteQuery({
queryKey: key,
queryFn: ({ pageParam }) =>
api.adminLogs({ ...query, since: sinceOf(shared.window) }, pageParam),
initialPageParam: null as string | null,
getNextPageParam: (last) => (liveRef.current ? undefined : (last.next_cursor ?? undefined)),
placeholderData: keepPreviousData,
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS : false),
});
const facets = useQuery({
queryKey: qk.adminLogFacets({ ...query, window: shared.window }),
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(shared.window) }),
placeholderData: keepPreviousData,
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS * 4 : false),
});
// A failed read stops the polling (the intervals above); the button shows that, and pressing it
// again reads now.
const polling = live && !logs.isError;
// What "Load older" continues from — read off the data, since `hasNextPage` is off while live; not
// off placeholder rows, which are the previous filter's.
const olderCursor = logs.isPlaceholderData
? null
: (logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null);
// A read that failed while lines were on screen: they are kept, and the message says what the
// failure means for them. (With no lines, the list's own error state says it.) Without the facets
// the Source and Event menus offer only "Any", as on the other tabs, so the same words say it.
const failure = !logs.data
? null
: logs.isFetchNextPageError
? "adminLogs.olderFailed"
: logs.isError || (facets.isError && facets.data)
? "adminLogs.stale"
: facets.isError
? "activity.filtersFailed"
: null;
const retryFailed = () => {
if (logs.isFetchNextPageError)
void logs.fetchNextPage(nextPageOnClick(logs.isFetchingNextPage));
else if (logs.isError) void logs.refetch();
if (facets.isError) void facets.refetch();
};
const rows = useMemo(() => logs.data?.pages.flatMap((p) => p.items) ?? [], [logs.data]);
const accounts = useMemo(
() =>
Object.assign(
{},
facets.data?.accounts,
...(logs.data?.pages.map((p) => p.accounts) ?? []),
) as Record<string, string>,
[facets.data, logs.data],
);
const accountLabel = (id: string) =>
id === "none" ? t("adminLogs.noAccount") : (accounts[id] ?? `#${id}`);
const toggleLive = () => {
if (polling) {
setLive(false);
return;
}
// Watching the top: drop the older pages now; `liveRef` keeps each tick to the first page.
qc.setQueryData<typeof logs.data>(key, (d) =>
d ? { pages: d.pages.slice(0, 1), pageParams: d.pageParams.slice(0, 1) } : d,
);
setLive(true);
if (logs.isError) void logs.refetch();
if (facets.isError) void facets.refetch();
};
const loadOlder = () => {
setLive(false);
void logs.fetchNextPage(nextPageOnClick(logs.isFetchingNextPage));
};
const copy = async () => {
// Oldest first, as a log reads.
const text = [...rows]
.reverse()
.map((r) => JSON.stringify(r))
.join("\n");
const ok = await copyText(text);
notify(
ok
? { level: "success", message: t("adminLogs.copied", { count: rows.length }) }
: { level: "error", message: t("adminLogs.copyFailed") },
);
};
const groupCount = (group: LevelGroup) => {
const levels = facets.data?.levels ?? [];
const sum = (list: readonly LogFacet[]) => list.reduce((n, f) => n + f.count, 0);
if (group === "all") return facets.data?.total;
const inGroup: readonly string[] = LEVEL_GROUPS[group];
return facets.data ? sum(levels.filter((f) => inGroup.includes(f.value))) : undefined;
};
const pivot = (patch: Partial<LogPivot>) => {
setLive(false);
onPivot({ requestId: null, jobId: null, runId: null, ...patch });
};
// "Any", then what the facet counted. The value in use stays offered even at zero — under the
// other filters it may count nothing, and a menu without it would show no way back.
const facetOptions = (
list: readonly LogFacet[] | undefined,
selected: string | null,
label: (v: string) => string,
) => {
const facets = list ?? [];
const kept =
selected && !facets.some((f) => f.value === selected) ? [{ value: selected, count: 0 }] : [];
return [
{ value: "", label: t("adminLogs.any") },
...[...facets, ...kept].map((f) => ({
value: f.value,
label: label(f.value),
count: f.count,
})),
];
};
return (
<HistoryFrame
testId="admin-logs"
list={logs}
rowCount={rows.length}
rowsClassName="font-mono text-2xs"
failure={failure && { text: t(failure), onRetry: retryFailed }}
older={
olderCursor !== null
? { label: t("adminLogs.older"), onClick: loadOlder, disabled: logs.isFetchingNextPage }
: null
}
count={t("adminLogs.count", { count: rows.length })}
empty={t("adminLogs.empty")}
header={
// The page's own band carries the shared filters and stays in reach; this one scrolls.
<div>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterChipGroup
ariaLabel={t("adminLogs.levelLabel")}
value={state.group}
onChange={(group) => update({ group })}
options={GROUPS.map((g) => ({
value: g,
label: t(`settings.myLog.filters.${g}`),
count: groupCount(g),
}))}
/>
</div>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
{!fold.folded && (
<>
<FilterSelect
label={t("adminLogs.source")}
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
selected={[state.sources[0] ?? ""]}
onChange={([source]) => update({ sources: source ? [source] : [] })}
testId="logs-source"
/>
<FilterSelect
label={t("adminLogs.event")}
options={facetOptions(facets.data?.events, state.event, (v) =>
v === "none" ? t("adminLogs.noEvent") : v,
)}
selected={[state.event ?? ""]}
onChange={([event]) => update({ event: event || null })}
testId="logs-event"
/>
</>
)}
<span className="ml-auto flex gap-1.5">
<Button
variant="outline"
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
pressed={polling}
onClick={toggleLive}
>
{t("adminLogs.live")}
</Button>
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
// While a filter change loads, the rows on screen are the previous filter's.
disabled={rows.length === 0 || logs.isPlaceholderData}
onClick={() => void copy()}
>
{t("adminLogs.copy")}
</Button>
</span>
</div>
{(state.requestId || state.jobId !== null || state.runId !== null) && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
{state.requestId && (
<PivotChip
label={t("adminLogs.pivotRequest", { id: state.requestId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ requestId: null })}
/>
)}
{state.jobId !== null && (
<PivotChip
label={t("adminLogs.pivotJob", { id: state.jobId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ jobId: null })}
/>
)}
{state.runId !== null && (
<PivotChip
label={t("adminLogs.pivotRun", { id: state.runId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ runId: null })}
/>
)}
</div>
)}
</div>
}
>
{rows.map((row, index) => (
<LogLine
navIndex={index}
key={row.id}
ts={row.ts}
level={row.level}
event={row.event ?? "—"}
msg={row.msg}
title={formatDateTimeSec(row.ts, lang)}
open={open === row.id}
onToggle={() => setOpen(open === row.id ? null : row.id)}
>
<RowDetail
row={row}
account={row.user_id === null ? null : accountLabel(String(row.user_id))}
onRequest={(requestId) => pivot({ requestId })}
onJob={(jobId) => pivot({ jobId })}
onRun={(runId) => pivot({ runId })}
/>
</LogLine>
))}
</HistoryFrame>
);
}
function PivotChip({
label,
clearLabel,
onClear,
}: {
label: string;
clearLabel: string;
onClear: () => void;
}) {
return (
<DismissibleChip onDismiss={onClear} dismissLabel={clearLabel} className="font-mono">
{label}
</DismissibleChip>
);
}
function RowDetail({
row,
account,
onRequest,
onJob,
onRun,
}: {
row: ServerLogRow;
account: string | null;
onRequest: (id: string) => void;
onJob: (id: number) => void;
onRun: (id: number) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const pivotCls =
"font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid";
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 font-sans text-xs">
<dt>{t("adminLogs.fields.when")}</dt>
<dd className="tabular-nums">{formatDateTimeSec(row.ts, lang)}</dd>
<dt>{t("adminLogs.fields.source")}</dt>
<dd className="font-mono">
{row.source} · {row.logger}
</dd>
<dt>{t("adminLogs.fields.account")}</dt>
<dd>{account ?? "—"}</dd>
{row.request_id && (
<>
<dt>{t("adminLogs.fields.request")}</dt>
<dd>
<Button
variant="link"
className={pivotCls}
title={t("adminLogs.showRequest")}
onClick={() => onRequest(row.request_id!)}
>
{row.request_id}
</Button>
</dd>
</>
)}
{row.job_id !== null && (
<>
<dt>{t("adminLogs.fields.job")}</dt>
<dd>
<Button
variant="link"
className={pivotCls}
title={t("adminLogs.showJob")}
onClick={() => onJob(row.job_id!)}
>
{row.job_id}
</Button>
</dd>
</>
)}
{row.run_id !== null && (
<>
<dt>{t("adminLogs.fields.run")}</dt>
<dd>
<Button
variant="link"
className={pivotCls}
title={t("adminLogs.showRun")}
onClick={() => onRun(row.run_id!)}
>
{row.run_id}
</Button>
</dd>
</>
)}
{row.version && (
<>
<dt>{t("adminLogs.fields.version")}</dt>
<dd className="font-mono">{row.version}</dd>
</>
)}
</dl>
<pre className="overflow-x-auto whitespace-pre-wrap break-words">{row.msg}</pre>
{row.attrs && (
<pre className="overflow-x-auto whitespace-pre">{JSON.stringify(row.attrs, null, 2)}</pre>
)}
{row.exc && <pre className="overflow-x-auto whitespace-pre text-danger/90">{row.exc}</pre>}
</div>
);
}