- PageShell names the page with a visually hidden h1; a view with its own title renders it as PageHeading and the hidden one steps aside (contract: no hand <h1> inside the shell). - Section captions are SectionLabel headings (h2, h3 in a dialog), ui/form Section included. - "Skip to content" is the first Tab stop and focuses the scroller's <main>. - Below 600px of height the band's filter row folds into "Filters · n" (Feed, Plex, server log), the same row shown in place. - The band holds controls only: Downloads and Notifications lose their title cards, the Config and channel-discovery intros scroll with the content, the Stats tabs and the audit pager join the band, and the server log's toolbar sticks with the fade under it.
530 lines
20 KiB
TypeScript
530 lines
20 KiB
TypeScript
import clsx from "clsx";
|
|
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 { FilterFoldButton } from "./ui/FilterFold";
|
|
import { useFilterFold } from "../lib/useFilterFold";
|
|
import { useStickyTop } from "./ui/stickyTop";
|
|
import { Section } from "./ui/form";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import { DismissibleChip } from "./ui/Chip";
|
|
import { InnerInput } from "./ui/Input";
|
|
import { fieldClass } from "./ui/fieldStyles";
|
|
|
|
// 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);
|
|
// The toolbar sticks at the scroller's top (./ui/stickyTop), and folds its facet row when short.
|
|
useStickyTop();
|
|
const fold = useFilterFold();
|
|
const facetFilters =
|
|
state.sources.length +
|
|
(state.user ? 1 : 0) +
|
|
(state.event ? 1 : 0) +
|
|
(state.window !== DEFAULT_LOGS_STATE.window ? 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);
|
|
// 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"
|
|
// overflow-clip, not -hidden: a hidden box is a scroll container, and the toolbar would stick
|
|
// to it instead of to the page.
|
|
className="overflow-clip rounded-xl border border-border bg-bg"
|
|
>
|
|
{/* The console's toolbar is the page's band (B4, B9): it sticks at the top with the fade
|
|
under it while the lines scroll, so a filter is in reach at line 500. */}
|
|
<div className="sticky top-0 z-base bg-bg fade-below">
|
|
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
|
<label
|
|
className={clsx(
|
|
fieldClass({ size: "sm", pill: true }),
|
|
"flex min-w-[12rem] flex-1 items-center gap-1.5",
|
|
)}
|
|
>
|
|
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
|
|
<InnerInput
|
|
type="search"
|
|
value={state.q}
|
|
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
|
|
placeholder={t("adminLogs.search")}
|
|
aria-label={t("adminLogs.search")}
|
|
/>
|
|
</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">
|
|
<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.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="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) && (
|
|
<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>
|
|
)}
|
|
</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 shape="table" />
|
|
) : 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>
|
|
);
|
|
}
|