import { useInfiniteQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ChevronDown, ChevronUp, Copy, ListChecks, RefreshCw, Search, Terminal, } from "lucide-react"; import * as React from "react"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../../components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../components/ui/table"; import type { AuditLog } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; const defaultAuditFilters = [ "method:POST path:/api/v1/*", "status:failure", "latency_ms:>1000", ]; type AuditDetails = { request_id?: string; method?: string; path?: string; status?: number; latency_ms?: number; error?: string; tenant_id?: string; actor_id?: string; action?: string; target?: string; before?: unknown; after?: unknown; }; function parseDetails(details?: string): AuditDetails { if (!details) { return {}; } try { const parsed = JSON.parse(details); if (parsed && typeof parsed === "object") { return parsed as AuditDetails; } } catch {} return {}; } function formatCellValue(value: unknown) { if (value === null || value === undefined || value === "") { return "-"; } if (typeof value === "string") { return value; } try { return JSON.stringify(value); } catch { return String(value); } } function formatIsoDateTime(value: string) { if (!value) { return { date: "-", time: "-" }; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return { date: value, time: "-" }; } const date = parsed.toISOString().slice(0, 10); const time = parsed.toLocaleTimeString("ko-KR", { hour12: false }); return { date, time }; } function AuditLogsPage() { const [filters, setFilters] = React.useState(defaultAuditFilters); const [filterDraft, setFilterDraft] = React.useState(""); const [expandedRows, setExpandedRows] = React.useState< Record >({}); const handleCopy = (value: string) => { if (!value) { return; } navigator.clipboard.writeText(value); }; const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching, refetch, } = useInfiniteQuery({ queryKey: ["audit-logs"], queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, }); const logs = data?.pages?.flatMap( (page) => page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [], ) ?? []; const handleAddFilter = () => { const trimmed = filterDraft.trim(); if (!trimmed) { return; } setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed])); setFilterDraft(""); }; if (isLoading) { return (
{t("msg.admin.audit.loading", "Loading audit logs...")}
); } if (error) { const errMsg = (error as AxiosError<{ error?: string }>).response?.data?.error ?? (error as Error).message; return (
{t("msg.admin.audit.load_error", "Error loading logs: {{error}}", { error: errMsg, })}
); } return (

{t("ui.admin.audit.title", "감사 로그")}

{t( "msg.admin.audit.subtitle", "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.", )}

{t("ui.admin.audit.registry.title", "Log Registry")} {t("msg.admin.audit.registry.count", "총 {{count}}개 로그", { count: logs.length, })}
setFilterDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { handleAddFilter(); } }} placeholder={t( "ui.admin.audit.filters.placeholder", "필터 추가 (예: status:failure)", )} className="w-full bg-transparent text-sm text-foreground outline-none" />
{filters.length === 0 ? ( {t("msg.admin.audit.filters.empty", "필터 없음")} ) : ( filters.map((filter) => ( {filter} )) )}
{t("ui.admin.audit.table.time", "TIME")} {t("ui.admin.audit.table.actor", "ACTOR (ID)")} {t("ui.admin.audit.table.request", "REQUEST")} {t("ui.admin.audit.table.path", "PATH")} {t("ui.admin.audit.table.status", "STATUS")} {t( "ui.admin.audit.table.action_target", "Action / Target", )} {isLoading && ( {t("msg.common.loading", "로딩 중...")} )} {!isLoading && logs.length === 0 && ( {t( "msg.admin.audit.empty", "아직 수집된 감사 로그가 없습니다.", )} )} {logs.map((row, index) => { const details = parseDetails(row.details); const actionLabel = details.action || (details.method && details.path ? `${details.method} ${details.path}` : row.event_type); const rowKey = `${row.event_id}-${row.timestamp}-${index}`; const isExpanded = Boolean(expandedRows[rowKey]); return ( {(() => { const { date, time } = formatIsoDateTime( row.timestamp, ); return (
{date}
{time}
); })()}
{row.user_id || details.actor_id || "-"} {(row.user_id || details.actor_id) && ( )}
{formatCellValue(details.request_id)} {details.request_id && ( )}
{formatCellValue(details.method)}
{formatCellValue(details.path)}
{row.status}
{actionLabel}
{details.target && (
{t( "ui.admin.audit.target", "Target · {{target}}", { target: details.target, }, )}
)}
{isExpanded && (
{t( "ui.admin.audit.details.request", "Request", )}
{t( "ui.admin.audit.details.request_id", "Request ID · {{value}}", { value: formatCellValue( details.request_id, ), }, )}
{t( "ui.admin.audit.details.event_id", "Event ID · {{value}}", { value: formatCellValue(row.event_id), }, )}
{t( "ui.admin.audit.details.ip", "IP · {{value}}", { value: formatCellValue(row.ip_address), }, )}
{t( "ui.admin.audit.details.latency", "Latency · {{value}}", { value: details.latency_ms !== undefined ? `${details.latency_ms}ms` : "-", }, )}
{t("ui.admin.audit.details.actor", "Actor")}
{t( "ui.admin.audit.details.actor_id", "Actor ID · {{value}}", { value: row.user_id || details.actor_id || "-", }, )}
{t( "ui.admin.audit.details.tenant", "Tenant · {{value}}", { value: formatCellValue( details.tenant_id, ), }, )}
{t( "ui.admin.audit.details.device", "Device · {{value}}", { value: formatCellValue(row.device_id), }, )}
{t( "ui.admin.audit.details.result", "Result", )}
{t( "ui.admin.audit.details.error", "Error · {{value}}", { value: formatCellValue(details.error), }, )}
{t( "ui.admin.audit.details.before", "Before · {{value}}", { value: formatCellValue(details.before), }, )}
{t( "ui.admin.audit.details.after", "After · {{value}}", { value: formatCellValue(details.after), }, )}
)}
); })}
{hasNextPage ? ( ) : ( {t("msg.admin.audit.end", "End of audit feed")} )}
); } export default AuditLogsPage;