Files
siftlode/frontend/src/components/AdminLogs.tsx
T
peter 81936e9f56 feat(design): every button on its family, the chip primitive, the lint rule (E28 S3b2)
The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.

- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
  floor, 44 on touch) replace five chip geometries; FilterChipGroup
  renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
  round, overlay; 44 on touch); the YT modal bar is on the sm rung with
  pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
  new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
  add) announce checked/mixed; a one-letter tag chip was 21px wide;
  descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
  control-primitive files is a lint error (contract-tested, seen red).
2026-09-24 23:03:23 +02:00

501 lines
18 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Copy, Radio, Search } from "lucide-react";
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
import {
DEFAULT_LOGS_STATE,
logsParamsToState,
logsParamUpdates,
WINDOW_HOURS,
type LogsUrlState,
type LogWindow,
} from "../lib/logsUrlState";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import { urlWithParams } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import LogLine from "./LogLine";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { DismissibleChip } from "./ui/Chip";
// Admin → Szervernapló: the server's stored log (`log_events`, E22.6). One list, newest first; a line
// opens in place to its full record, and its request id / job id narrow the list to that request or
// job. Filters live in the URL, 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"];
const WINDOWS: readonly LogWindow[] = ["1h", "24h", "7d"];
// A pivot shows everything about one request or job: the other filters would only hide part of it.
const PIVOT_RESET: Omit<LogsUrlState, "requestId" | "jobId"> = {
...DEFAULT_LOGS_STATE,
window: "7d",
};
function sinceOf(window: LogWindow): string {
return new Date(Date.now() - WINDOW_HOURS[window] * 3_600_000).toISOString();
}
function toQuery(state: LogsUrlState, q: string): LogQuery {
return {
levels: state.group === "all" ? undefined : LEVEL_GROUPS[state.group],
sources: state.sources,
user: state.user ?? undefined,
event: state.event ?? undefined,
request_id: state.requestId ?? undefined,
job_id: state.jobId ?? undefined,
q: q.trim() || undefined,
};
}
export default function AdminLogs() {
const { t } = useTranslation();
const qc = useQueryClient();
const [state, setState] = useState<LogsUrlState>(() =>
logsParamsToState(new URLSearchParams(window.location.search)),
);
const [live, setLiveState] = useState(false);
// 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);
// Bumped when the view changes by a jump (a pivot, Back/Forward) rather than by typing: the
// search box's text then applies at once instead of after the typing debounce.
const [jumps, setJumps] = useState(0);
const q = useDebounced(state.q, 300, jumps);
useUrlMirror(logsParamUpdates(state));
// Back/Forward lands on another view of the console (a pivot pushed one, below): read it back.
useEffect(() => {
const onPop = () => {
setOpen(null);
setJumps((n) => n + 1);
setState(logsParamsToState(new URLSearchParams(window.location.search)));
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
const update = (patch: Partial<LogsUrlState>) => {
setOpen(null);
setState((s) => ({ ...s, ...patch }));
};
const query = toQuery(state, q);
const key = qk.adminLogs({ ...query, window: state.window });
const logs = useInfiniteQuery({
queryKey: key,
queryFn: ({ pageParam }) =>
api.adminLogs({ ...query, since: sinceOf(state.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: state.window }),
queryFn: () => api.adminLogFacets({ ...query, since: sinceOf(state.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.)
const failure = !logs.data
? null
: logs.isFetchNextPageError
? "olderFailed"
: logs.isError || (facets.isError && facets.data)
? "stale"
: facets.isError
? "countsFailed"
: null;
const retryFailed = () => {
if (logs.isFetchNextPageError) void logs.fetchNextPage();
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();
};
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;
};
// A pivot follows a link to another view, so it is its own history entry and Back returns to the
// view it was made from. The other filters are adjustments of the view on screen and replace it.
const pivot = (patch: Pick<LogsUrlState, "requestId" | "jobId">) => {
setLive(false);
const next = { ...state, ...PIVOT_RESET, ...patch };
window.history.pushState(
{ ...rememberNavPosition(), sfScroll: nextNavId() },
"",
urlWithParams(logsParamUpdates(next)),
);
notifyNav();
setJumps((n) => n + 1);
update(next);
};
// "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 (
<div className="p-4 max-w-6xl w-full mx-auto">
<Section card title={t("adminLogs.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("adminLogs.intro")}</p>
<div
data-testid="admin-logs"
className="overflow-hidden rounded-xl border border-border bg-bg"
>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label className="flex min-w-[12rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<input
type="search"
value={state.q}
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
placeholder={t("adminLogs.search")}
aria-label={t("adminLogs.search")}
className="min-w-0 flex-1 bg-transparent outline-none"
/>
</label>
<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">
<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.account")}
options={facetOptions(facets.data?.users, state.user, accountLabel)}
selected={[state.user ?? ""]}
onChange={([user]) => update({ user: user || null })}
testId="logs-user"
/>
<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"
/>
<FilterSelect
label={t("adminLogs.window")}
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
selected={[state.window]}
onChange={([window]) => update({ window: window as LogWindow })}
testId="logs-window"
/>
<span className="ml-auto flex gap-1.5">
<Button
variant={polling ? "outline-accent" : "outline"}
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
aria-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) && (
<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 })}
/>
)}
</div>
)}
{failure && (
<StateMessage
tone="error"
compact
onRetry={retryFailed}
className="border-b border-border"
>
{t(`adminLogs.${failure}`)}
</StateMessage>
)}
<div
className={`font-mono text-2xs ${logs.isPlaceholderData ? "opacity-60" : ""}`}
data-testid="admin-logs-lines"
aria-busy={logs.isPlaceholderData}
>
{logs.isError && !logs.data ? (
<StateMessage tone="error" onRetry={() => void logs.refetch()}>
{t("common.loadError")}
</StateMessage>
) : logs.isLoading ? (
<LoadingState />
) : rows.length === 0 ? (
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
{t("adminLogs.empty")}
</p>
) : (
<NavGrid>
{rows.map((row, index) => (
<LogLine
navIndex={index}
key={row.id}
ts={row.ts}
level={row.level}
event={row.event ?? "—"}
msg={row.msg}
title={formatDateTime(row.ts)}
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, jobId: null })}
onJob={(jobId) => pivot({ requestId: null, jobId })}
/>
</LogLine>
))}
</NavGrid>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
<span>{t("adminLogs.count", { count: rows.length })}</span>
{olderCursor !== null && (
<Button
variant="outline"
size="xs"
disabled={logs.isFetchingNextPage}
onClick={loadOlder}
>
{t("adminLogs.older")}
</Button>
)}
</div>
</div>
</Section>
</div>
);
}
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,
}: {
row: ServerLogRow;
account: string | null;
onRequest: (id: string) => void;
onJob: (id: number) => void;
}) {
const { t } = useTranslation();
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">{formatDateTime(row.ts)}</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.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>
);
}