fix(diagnostics): honest live console after review
- Live stops polling on a failed read and marks the list as possibly stale (with Retry), instead of re-raising the error dialog every tick. - While live, a refetch reads the newest page only, whatever the cache holds (a late "Load older", a revisited filter set). - Copy is disabled and the list dimmed while a filter change loads. - Dates follow the UI language; the pivot chip uses the shared .chip. - Tests: query-string wire test, Copy, every facet's skip key.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
keepPreviousData,
|
||||
@@ -10,6 +10,7 @@ import { Copy, Radio, Search, X } 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 { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
|
||||
import {
|
||||
DEFAULT_LOGS_STATE,
|
||||
@@ -35,7 +36,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
// 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.
|
||||
// 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"];
|
||||
@@ -68,7 +70,15 @@ export default function AdminLogs() {
|
||||
const [state, setState] = useState<LogsUrlState>(() =>
|
||||
logsParamsToState(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
const [live, setLive] = useState(false);
|
||||
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);
|
||||
const q = useDebounced(state.q, 300);
|
||||
useUrlMirror(logsParamUpdates(state));
|
||||
@@ -85,16 +95,25 @@ export default function AdminLogs() {
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.adminLogs({ ...query, since: sinceOf(state.window) }, pageParam),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
getNextPageParam: (last) => (liveRef.current ? undefined : (last.next_cursor ?? undefined)),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: live ? LIVE_MS : false,
|
||||
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: live ? LIVE_MS * 4 : false,
|
||||
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS * 4 : false),
|
||||
});
|
||||
// What "Load older" continues from — read off the data, since `hasNextPage` is off while live.
|
||||
const olderCursor = logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null;
|
||||
// A read failed while rows (or counts) were already on screen: they are kept, and said to be old.
|
||||
const stale = (logs.isError && !!logs.data) || facets.isError;
|
||||
const retryStale = () => {
|
||||
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(
|
||||
@@ -111,7 +130,7 @@ export default function AdminLogs() {
|
||||
|
||||
const toggleLive = () => {
|
||||
if (!live) {
|
||||
// Watching the top: drop the older pages, or every tick would re-read all of them.
|
||||
// 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,
|
||||
);
|
||||
@@ -249,7 +268,8 @@ export default function AdminLogs() {
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
disabled={rows.length === 0}
|
||||
// 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")}
|
||||
@@ -276,7 +296,22 @@ export default function AdminLogs() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="font-mono text-2xs" data-testid="admin-logs-lines">
|
||||
{stale && (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
compact
|
||||
onRetry={retryStale}
|
||||
className="border-b border-border"
|
||||
>
|
||||
{t("adminLogs.stale")}
|
||||
</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")}
|
||||
@@ -297,7 +332,7 @@ export default function AdminLogs() {
|
||||
level={row.level}
|
||||
event={row.event ?? "—"}
|
||||
msg={row.msg}
|
||||
title={new Date(row.ts).toLocaleString()}
|
||||
title={formatDateTime(row.ts)}
|
||||
open={open === row.id}
|
||||
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
||||
>
|
||||
@@ -315,7 +350,7 @@ export default function AdminLogs() {
|
||||
|
||||
<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>
|
||||
{logs.hasNextPage && (
|
||||
{olderCursor !== null && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -342,7 +377,7 @@ function PivotChip({
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-accent/40 bg-accent/10 py-0.5 pl-2.5 pr-1 text-accent">
|
||||
<span className="chip py-0.5 pl-2.5 pr-1">
|
||||
<span className="font-mono">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -375,7 +410,7 @@ function RowDetail({
|
||||
<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">{new Date(row.ts).toLocaleString()}</dd>
|
||||
<dd className="tabular-nums">{formatDateTime(row.ts)}</dd>
|
||||
<dt>{t("adminLogs.fields.source")}</dt>
|
||||
<dd className="font-mono">
|
||||
{row.source} · {row.logger}
|
||||
|
||||
Reference in New Issue
Block a user