- 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.
460 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|