Files
HealthLog/src/components/admin/app-log-preview-section.tsx
T
Marc-André Bombeck 47e50ece39 Wait for content the page owns before scanning it
Several accessibility scans ran against a page that had not finished
arriving, so axe reported violations the app does not have. The worst of
them was the medications case, which waited for a level-one heading and
then checked that a level-one heading exists. A page that really lost its
heading would have waited out the timeout under a misleading name instead
of failing the rule, which makes it a check that cannot fail rather than a
flaky one.

The filter rail was the other bad sentinel. It renders beside the loading
silhouettes rather than after them, so it was on screen while the rows were
still grey rectangles. Under an 8x CPU throttle the rail and the loading
marker resolved in the same 40ms poll and the rows a poll later.

Every case now gates on a marker the real component carries in its
resolved, non-empty branch, and on a component other than the one the scan
asserts. Both the desktop table and the mobile card list carry the same
marker so the wait holds at any viewport. No sentinel names a heading, the
app frame, a busy marker or the filter rail any more.
2026-08-02 18:50:31 +02:00

333 lines
12 KiB
TypeScript

"use client";
/**
* `<AppLogPreviewSection>` — surfaces the in-process wide-event ring buffer
* (`src/lib/logging/in-memory-buffer.ts`) so admins can drill into the most
* recent ~500 structured events without standing up a Loki stack just to
* read the last hour.
*
* Limitations (covered by the section header):
* - Per-process. Under split web/worker deployments, only the web buffer
* is visible here. Worker logs ship to Loki when configured.
* - Volatile. Restart drops the buffer.
*
* Privacy: every event passes through `redactSecrets()` server-side before
* reaching this component; storage stays raw so the diagnostic value
* survives shipping to Loki.
*/
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
FileText,
Info,
Loader2,
RefreshCw,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { WideEvent, LogLevel } from "@/lib/logging/types";
import { apiGet } from "@/lib/api/api-fetch";
type RangePreset = "15m" | "1h" | "6h" | "all";
interface AppLogsResponse {
events: WideEvent[];
meta: {
total: number;
bufferMax: number;
};
}
function rangeToSince(range: RangePreset): string | undefined {
if (range === "all") return undefined;
const now = Date.now();
const minutes = range === "15m" ? 15 : range === "1h" ? 60 : 6 * 60;
return new Date(now - minutes * 60 * 1000).toISOString();
}
function levelIcon(level: LogLevel) {
switch (level) {
case "error":
return <AlertCircle className="text-destructive h-4 w-4" />;
case "warn":
return <AlertTriangle className="text-warning h-4 w-4" />;
case "debug":
return <Info className="text-muted-foreground h-4 w-4" />;
case "info":
default:
return <CheckCircle2 className="text-success h-4 w-4" />;
}
}
export function AppLogPreviewSection() {
const { t } = useTranslations();
const [traceId, setTraceId] = useState("");
const [actionFilter, setActionFilter] = useState("");
const [level, setLevel] = useState<LogLevel | "__all__">("__all__");
const [range, setRange] = useState<RangePreset>("1h");
const [selected, setSelected] = useState<WideEvent | null>(null);
const params = useMemo(() => {
const p = new URLSearchParams();
if (traceId.trim()) p.set("traceId", traceId.trim());
if (actionFilter.trim()) p.set("action", actionFilter.trim());
if (level !== "__all__") p.set("level", level);
const since = rangeToSince(range);
if (since) p.set("since", since);
return p;
}, [traceId, actionFilter, level, range]);
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range),
queryFn: async () => {
return apiGet<AppLogsResponse>(
`/api/admin/app-logs?${params.toString()}`,
);
},
refetchInterval: 30_000,
});
const events = data?.events ?? [];
return (
<SettingsCard className="space-y-4">
<SettingsCardHeader
icon={FileText}
title={t("admin.section.app-logs.title")}
description={t("admin.section.app-logs.processNote")}
status={
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
aria-label={t("admin.section.app-logs.refresh")}
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
/>
</Button>
}
/>
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
<Input
type="search"
placeholder={t("admin.section.app-logs.filterTraceId")}
value={traceId}
onChange={(e) => setTraceId(e.target.value)}
aria-label={t("admin.section.app-logs.filterTraceId")}
/>
<Input
type="search"
placeholder={t("admin.section.app-logs.filterAction")}
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
aria-label={t("admin.section.app-logs.filterAction")}
/>
<Select
value={level}
onValueChange={(v) => setLevel(v as LogLevel | "__all__")}
>
<SelectTrigger aria-label={t("admin.section.app-logs.filterLevel")}>
<SelectValue
placeholder={t("admin.section.app-logs.filterLevel")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
{t("admin.section.app-logs.levelAll")}
</SelectItem>
<SelectItem value="debug">debug</SelectItem>
<SelectItem value="info">info</SelectItem>
<SelectItem value="warn">warn</SelectItem>
<SelectItem value="error">error</SelectItem>
</SelectContent>
</Select>
<Select value={range} onValueChange={(v) => setRange(v as RangePreset)}>
<SelectTrigger aria-label={t("admin.section.app-logs.filterRange")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15m">
{t("admin.section.app-logs.range15m")}
</SelectItem>
<SelectItem value="1h">
{t("admin.section.app-logs.range1h")}
</SelectItem>
<SelectItem value="6h">
{t("admin.section.app-logs.range6h")}
</SelectItem>
<SelectItem value="all">
{t("admin.section.app-logs.rangeAll")}
</SelectItem>
</SelectContent>
</Select>
</div>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
</div>
) : events.length === 0 ? (
<EmptyState
icon={<FileText className="size-6" />}
title={t("admin.section.app-logs.empty")}
description={t("admin.section.app-logs.emptyDescription")}
/>
) : (
// v1.4.27 MB5 — keep the table scroll container, but lift the
// "showing X of Y" summary line out into a sibling `<div>` so
// the meta info stays reachable on narrow viewports even when
// the table is scrolled horizontally. The refresh control was
// already in the header above the table wrapper.
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colLevel")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colTimestamp")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colAction")}
</th>
<th className="px-2 py-2 text-right font-medium">
{t("admin.section.app-logs.colDuration")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colTraceId")}
</th>
</tr>
</thead>
{/* `app-log-rows` names the read events. The branch above it
is a spinner and the one below an empty state, so the
marker exists only once the buffer has actually answered. */}
<tbody
data-slot="app-log-rows"
className="divide-border divide-y"
>
{events.map((event, i) => {
const actionLabel =
event.action?.name ??
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
event.kind);
return (
<tr
key={`${event.request_id}-${i}`}
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
// Pointer convenience only — the row itself is not
// keyboard-reachable; the real interactive control is
// the button below (2026-07-17 a11y audit H1).
onClick={() => setSelected(event)}
>
<td className="px-2 py-2">{levelIcon(event.level)}</td>
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
{formatDateTime(event.timestamp)}
</td>
<td className="px-2 py-2 text-xs">
<button
type="button"
onClick={() => setSelected(event)}
className="focus-visible:ring-ring w-full rounded text-left underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
aria-label={t("admin.section.app-logs.viewDetails", {
action: actionLabel,
timestamp: formatDateTime(event.timestamp),
})}
>
{actionLabel}
</button>
</td>
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
{event.duration_ms} ms
</td>
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
{event.trace_id.slice(0, 8)}…
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div
className="text-muted-foreground mt-3 flex items-center justify-between text-xs"
data-testid="app-log-preview-summary"
>
<span>
{t("admin.section.app-logs.showing", {
count: events.length,
bufferMax: data?.meta.bufferMax ?? 500,
})}
</span>
</div>
</>
)}
<Dialog
open={selected !== null}
onOpenChange={(open) => !open && setSelected(null)}
>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selected && levelIcon(selected.level)}
<span className="font-mono text-sm">
{selected?.action?.name ??
selected?.http?.path ??
t("admin.section.app-logs.eventDetails")}
</span>
</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto">
{selected && (
<pre className="bg-muted text-foreground rounded-md p-3 font-mono text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selected, null, 2)}
</pre>
)}
</div>
<DialogClose asChild>
<Button
variant="outline"
size="sm"
className="absolute top-3 right-3"
aria-label={t("admin.section.app-logs.closeDetails")}
>
<X className="h-4 w-4" />
</Button>
</DialogClose>
</DialogContent>
</Dialog>
</SettingsCard>
);
}