mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment lines in 1,308 files lose the work-item identifier at their head. Release versions and issue references stay; nothing outside comment text changes, which the script checks per file against a comment-free printout. healthlog/no-planning-tags is enabled at error level with this commit, so the tree it lands on is clean.
436 lines
16 KiB
TypeScript
436 lines
16 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";
|
|
|
|
/**
|
|
* Rows per page. The buffer answers with up to ~500 events at once; fifty a
|
|
* page matches the login overview's default and keeps the table one screen
|
|
* of scrolling instead of ten.
|
|
*/
|
|
export const APP_LOG_PAGE_SIZE = 50;
|
|
|
|
interface AppLogsResponse {
|
|
events: WideEvent[];
|
|
meta: {
|
|
total: number;
|
|
bufferMax: number;
|
|
problemsBufferMax?: 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 [requestId, setRequestId] = useState("");
|
|
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 [page, setPage] = useState(1);
|
|
|
|
const params = useMemo(() => {
|
|
const p = new URLSearchParams();
|
|
// An error id is the first characters of a request id. The ids people
|
|
// read out come from 5xx answers, which are logged at `error`, so a
|
|
// search by id, like a filter on warn or error, reads the problems ring:
|
|
// it keeps the last 1,000 warnings and errors, where the main ring keeps
|
|
// 500 events of every kind and turns over in minutes on a busy host.
|
|
const id = requestId.trim();
|
|
if (id.length >= 8) p.set("requestId", id);
|
|
if (id.length >= 8 || level === "warn" || level === "error") {
|
|
p.set("ring", "problems");
|
|
}
|
|
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;
|
|
}, [requestId, traceId, actionFilter, level, range]);
|
|
|
|
const { data, isLoading, refetch, isFetching } = useQuery({
|
|
queryKey: queryKeys.adminAppLogs(
|
|
traceId,
|
|
actionFilter,
|
|
level,
|
|
range,
|
|
requestId.trim(),
|
|
),
|
|
queryFn: async () => {
|
|
return apiGet<AppLogsResponse>(
|
|
`/api/admin/app-logs?${params.toString()}`,
|
|
);
|
|
},
|
|
refetchInterval: 30_000,
|
|
});
|
|
|
|
const events = data?.events ?? [];
|
|
// The buffer refetches every 30 s and can shrink under a narrower filter,
|
|
// so the page is clamped on read rather than trusted.
|
|
const lastPage = Math.max(1, Math.ceil(events.length / APP_LOG_PAGE_SIZE));
|
|
const currentPage = Math.min(page, lastPage);
|
|
const pageEvents = events.slice(
|
|
(currentPage - 1) * APP_LOG_PAGE_SIZE,
|
|
currentPage * APP_LOG_PAGE_SIZE,
|
|
);
|
|
|
|
return (
|
|
<SettingsCard>
|
|
<SettingsCardHeader
|
|
icon={FileText}
|
|
title={t("admin.section.app-logs.cardTitle")}
|
|
description={t("admin.section.app-logs.processNote")}
|
|
/>
|
|
|
|
<p className="text-sm">{t("admin.section.app-logs.processDetail")}</p>
|
|
|
|
{/* Filters and the refresh share one toolbar row above the table; the
|
|
header status slot carries status, not actions (design standards
|
|
§12). */}
|
|
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-[repeat(5,minmax(0,1fr))_auto]">
|
|
<Input
|
|
type="search"
|
|
placeholder={t("admin.section.app-logs.filterErrorId")}
|
|
value={requestId}
|
|
maxLength={64}
|
|
onChange={(e) => {
|
|
setRequestId(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
aria-label={t("admin.section.app-logs.filterErrorId")}
|
|
data-slot="app-logs-error-id-filter"
|
|
/>
|
|
<Input
|
|
type="search"
|
|
placeholder={t("admin.section.app-logs.filterTraceId")}
|
|
value={traceId}
|
|
onChange={(e) => {
|
|
setTraceId(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
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);
|
|
setPage(1);
|
|
}}
|
|
aria-label={t("admin.section.app-logs.filterAction")}
|
|
/>
|
|
<Select
|
|
value={level}
|
|
onValueChange={(v) => {
|
|
setLevel(v as LogLevel | "__all__");
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
className="w-full"
|
|
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);
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
className="w-full"
|
|
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>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="min-h-11 min-w-11 justify-self-end sm:min-h-9 sm:min-w-9"
|
|
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>
|
|
|
|
{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 — 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"
|
|
>
|
|
{pageEvents.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 flex flex-wrap items-center justify-between gap-2 text-xs"
|
|
data-testid="app-log-preview-summary"
|
|
>
|
|
<span>
|
|
{t("admin.section.app-logs.showing", {
|
|
count: events.length,
|
|
bufferMax: data?.meta.bufferMax ?? 500,
|
|
})}
|
|
</span>
|
|
{/* Same pager as the login overview. */}
|
|
{lastPage > 1 ? (
|
|
<div
|
|
className="flex items-center gap-2"
|
|
data-testid="app-log-pagination"
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="min-h-11 sm:min-h-9"
|
|
disabled={currentPage <= 1}
|
|
onClick={() => setPage(Math.max(1, currentPage - 1))}
|
|
>
|
|
{t("admin.section.auditLog.prev")}
|
|
</Button>
|
|
<span>
|
|
{t("admin.section.auditLog.pageOf", {
|
|
page: currentPage,
|
|
total: lastPage,
|
|
})}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="min-h-11 sm:min-h-9"
|
|
disabled={currentPage >= lastPage}
|
|
onClick={() => setPage(Math.min(lastPage, currentPage + 1))}
|
|
>
|
|
{t("admin.section.auditLog.next")}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</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>
|
|
);
|
|
}
|