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).
501 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|