"use client"; /** * `` — 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 ; case "warn": return ; case "debug": return ; case "info": default: return ; } } export function AppLogPreviewSection() { const { t } = useTranslations(); const [requestId, setRequestId] = useState(""); const [traceId, setTraceId] = useState(""); const [actionFilter, setActionFilter] = useState(""); const [level, setLevel] = useState("__all__"); const [range, setRange] = useState("1h"); const [selected, setSelected] = useState(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( `/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 (

{t("admin.section.app-logs.processDetail")}

{/* Filters and the refresh share one toolbar row above the table; the header status slot carries status, not actions (design standards §12). */}
{ setRequestId(e.target.value); setPage(1); }} aria-label={t("admin.section.app-logs.filterErrorId")} data-slot="app-logs-error-id-filter" /> { setTraceId(e.target.value); setPage(1); }} aria-label={t("admin.section.app-logs.filterTraceId")} /> { setActionFilter(e.target.value); setPage(1); }} aria-label={t("admin.section.app-logs.filterAction")} />
{isLoading ? (
) : events.length === 0 ? ( } 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 `
` 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. <>
{/* `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. */} {pageEvents.map((event, i) => { const actionLabel = event.action?.name ?? (`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() || event.kind); return ( setSelected(event)} > ); })}
{t("admin.section.app-logs.colLevel")} {t("admin.section.app-logs.colTimestamp")} {t("admin.section.app-logs.colAction")} {t("admin.section.app-logs.colDuration")} {t("admin.section.app-logs.colTraceId")}
{levelIcon(event.level)} {formatDateTime(event.timestamp)} {event.duration_ms} ms {event.trace_id.slice(0, 8)}…
{t("admin.section.app-logs.showing", { count: events.length, bufferMax: data?.meta.bufferMax ?? 500, })} {/* Same pager as the login overview. */} {lastPage > 1 ? (
{t("admin.section.auditLog.pageOf", { page: currentPage, total: lastPage, })}
) : null}
)} !open && setSelected(null)} > {selected && levelIcon(selected.level)} {selected?.action?.name ?? selected?.http?.path ?? t("admin.section.app-logs.eventDetails")}
{selected && (
                {JSON.stringify(selected, null, 2)}
              
)}
); }