From 9df69f22e8a95777968f4de4db2935e250076083 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 15 May 2026 14:46:58 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=90=EC=82=AC=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=97=A4=EB=8D=94=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=B0=BD=20=EB=AC=B8=EA=B5=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/audit/AuditLogsPage.tsx | 506 +++--------------- .../core/components/audit/AuditLogTable.tsx | 394 ++++++++++++++ common/core/components/audit/index.ts | 1 + common/locales/en.toml | 27 +- common/locales/ko.toml | 29 +- common/locales/template.toml | 15 + devfront/src/features/audit/AuditLogsPage.tsx | 289 +--------- 7 files changed, 563 insertions(+), 698 deletions(-) create mode 100644 common/core/components/audit/AuditLogTable.tsx create mode 100644 common/core/components/audit/index.ts diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index fe7a60e6..a561692d 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -1,70 +1,30 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { - ChevronDown, - ChevronUp, - Copy, - ListChecks, - RefreshCw, - Search, - Terminal, -} from "lucide-react"; +import { ListChecks, RefreshCw, Search } from "lucide-react"; import * as React from "react"; import { - commonStickyTableHeaderClass, - commonTableShellClass, - commonTableViewportClass, -} from "../../../../common/ui/table"; -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 { - formatAuditDateParts, formatAuditValue, parseAuditDetails, resolveAuditAction, resolveAuditActor, - resolveAuditTarget, } from "../../../../common/core/audit"; -import { SearchFilterBar } from "../../../../common/ui/search-filter-bar"; +import { AuditLogTable } from "../../../../common/core/components/audit"; import { PageHeader } from "../../../../common/core/components/page"; +import { SearchFilterBar } from "../../../../common/ui/search-filter-bar"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"; +import { Input } from "../../components/ui/input"; 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", -]; - 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 [searchActorId, setSearchActorId] = React.useState(""); + const [searchAction, setSearchAction] = React.useState(""); + const [statusFilter, setStatusFilter] = React.useState("all"); + const deferredSearchActorId = React.useDeferredValue(searchActorId.trim()); + const deferredSearchAction = React.useDeferredValue(searchAction.trim()); const { data, isLoading, @@ -86,15 +46,24 @@ function AuditLogsPage() { (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(""); - }; + const filteredLogs = React.useMemo( + () => + logs.filter((row) => { + const details = parseAuditDetails(row.details); + const actorLabel = resolveAuditActor(row, details).toLowerCase(); + const actionLabel = resolveAuditAction(row, details).toLowerCase(); + const matchesActor = + deferredSearchActorId === "" || + actorLabel.includes(deferredSearchActorId.toLowerCase()); + const matchesAction = + deferredSearchAction === "" || + actionLabel.includes(deferredSearchAction.toLowerCase()); + const matchesStatus = + statusFilter === "all" || row.status === statusFilter; + return matchesActor && matchesAction && matchesStatus; + }), + [logs, deferredSearchActorId, deferredSearchAction, statusFilter], + ); if (isLoading) { return ( @@ -118,10 +87,8 @@ function AuditLogsPage() { } return ( -
+
+ + {t("msg.common.audit.registry.count", "총 {{count}}개 로그", { + count: filteredLogs.length, + })} + -
- } - actions={ - <> - {filters.length === 0 ? ( - - {t("msg.admin.audit.filters.empty", "필터 없음")} - - ) : ( - filters.map((filter) => ( - - - {filter} - - - )) - )} - + + setSearchAction(event.target.value.toUpperCase()) + } + placeholder={t( + "ui.common.audit.filters.action", + "Filter by Action (e.g. ROTATE_SECRET)", + )} + /> + + } /> -
-
- - - - - {t("ui.common.audit.table.time", "시간")} - - - {t("ui.common.audit.table.actor", "수행자")} - - {t("ui.common.audit.table.action", "액션")} - {t("ui.common.audit.table.target", "대상")} - - {t("ui.common.audit.table.status", "상태")} - - - - - - {isLoading && ( - - - {t("msg.common.loading", "로딩 중...")} - - - )} - {!isLoading && logs.length === 0 && ( - - - {t( - "msg.common.audit.empty", - "아직 수집된 감사 로그가 없습니다.", - )} - - - )} - {logs.map((row, index) => { - const details = parseAuditDetails(row.details); - const actionLabel = resolveAuditAction(row, details); - const actorLabel = resolveAuditActor(row, details); - const targetLabel = resolveAuditTarget(details); - const rowKey = `${row.event_id}-${row.timestamp}-${index}`; - const isExpanded = Boolean(expandedRows[rowKey]); - return ( - - - - {(() => { - const { date, time } = formatAuditDateParts( - row.timestamp, - ); - return ( -
-
{date}
-
{time}
-
- ); - })()} -
- -
- - {actorLabel} - - {actorLabel !== "-" && ( - - )} -
-
- -
- {actionLabel} -
-
- -
- {targetLabel} - {targetLabel !== "-" && ( - - )} -
-
- - - {row.status} - - - - - -
- {isExpanded && ( - - -
-
-
- {t( - "ui.common.audit.details.request", - "Request", - )} -
-
- {t( - "ui.common.audit.details.request_id", - "Request ID · {{value}}", - { - value: formatAuditValue( - details.request_id, - ), - }, - )} -
-
- {t( - "ui.common.audit.details.event_id", - "Event ID · {{value}}", - { - value: formatAuditValue(row.event_id), - }, - )} -
-
- {t( - "ui.common.audit.details.ip", - "IP · {{value}}", - { - value: formatAuditValue(row.ip_address), - }, - )} -
-
- Method · {formatAuditValue(details.method)} -
-
- Path · {formatAuditValue(details.path)} -
-
- {t( - "ui.common.audit.details.latency", - "Latency · {{value}}", - { - value: - details.latency_ms !== undefined - ? `${details.latency_ms}ms` - : "-", - }, - )} -
-
-
-
- {t("ui.common.audit.details.actor", "Actor")} -
-
- {t( - "ui.common.audit.details.actor_id", - "Actor ID · {{value}}", - { - value: actorLabel, - }, - )} -
-
- {t( - "ui.common.audit.details.tenant", - "Tenant · {{value}}", - { - value: formatAuditValue( - details.tenant_id, - ), - }, - )} -
-
- {t( - "ui.common.audit.details.device", - "Device · {{value}}", - { - value: formatAuditValue(row.device_id), - }, - )} -
-
- Target · {targetLabel} -
-
-
-
- {t( - "ui.common.audit.details.result", - "Result", - )} -
-
- {t( - "ui.common.audit.details.error", - "Error · {{value}}", - { - value: formatAuditValue(details.error), - }, - )} -
-
- {t( - "ui.common.audit.details.before", - "Before · {{value}}", - { - value: formatAuditValue(details.before), - }, - )} -
-
- {t( - "ui.common.audit.details.after", - "After · {{value}}", - { - value: formatAuditValue(details.after), - }, - )} -
-
-
-
-
- )} -
- ); - })} -
-
-
-
-
- {hasNextPage ? ( - - ) : ( - - {t("msg.admin.audit.end", "End of audit feed")} - - )} -
+ fetchNextPage()} + />
diff --git a/common/core/components/audit/AuditLogTable.tsx b/common/core/components/audit/AuditLogTable.tsx new file mode 100644 index 00000000..2fe97a2c --- /dev/null +++ b/common/core/components/audit/AuditLogTable.tsx @@ -0,0 +1,394 @@ +import { ChevronDown, ChevronUp, Copy } from "lucide-react"; +import * as React from "react"; +import type { CommonAuditLog } from "../../audit"; +import { + formatAuditDateParts, + formatAuditValue, + parseAuditDetails, + resolveAuditAction, + resolveAuditActor, + resolveAuditTarget, +} from "../../audit"; +import { + getCommonBadgeClasses, + type CommonBadgeVariant, +} from "../../../ui/badge"; +import { getCommonButtonClasses } from "../../../ui/button"; +import { + commonStickyTableHeaderClass, + commonTableBodyClass, + commonTableCellClass, + commonTableClass, + commonTableHeadClass, + commonTableHeaderClass, + commonTableRowClass, + commonTableShellClass, + commonTableViewportClass, + commonTableWrapperClass, +} from "../../../ui/table"; + +type AuditTranslate = ( + key: string, + fallback: string, + vars?: Record, +) => string; + +type AuditLogTableProps = { + logs: CommonAuditLog[]; + t: AuditTranslate; + loading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; + className?: string; +}; + +function cx(...classNames: Array) { + return classNames.filter(Boolean).join(" "); +} + +function statusVariant(status: string): CommonBadgeVariant { + return status === "success" || status === "ok" ? "success" : "warning"; +} + +export function AuditLogTable({ + logs, + t, + loading, + hasNextPage, + isFetchingNextPage, + onLoadMore, + className, +}: AuditLogTableProps) { + const [expandedRows, setExpandedRows] = React.useState< + Record + >({}); + + const handleCopy = (value: string) => { + if (!value) { + return; + } + navigator.clipboard.writeText(value); + }; + + return ( +
+
+
+ + + + + + + + + + + + {loading && logs.length === 0 ? ( + + + + ) : logs.length === 0 ? ( + + + + ) : ( + logs.map((row, index) => { + const details = parseAuditDetails(row.details); + const actorLabel = resolveAuditActor(row, details); + const actionLabel = resolveAuditAction(row, details); + const targetLabel = resolveAuditTarget(details); + const rowKey = `${row.event_id}-${row.timestamp}-${index}`; + const expanded = Boolean(expandedRows[rowKey]); + const { date, time } = formatAuditDateParts(row.timestamp); + return ( + + + + + + + + + + {expanded ? ( + + + + ) : null} + + ); + }) + )} + +
+ {t("ui.common.audit.table.time", "Time")} + + {t("ui.common.audit.table.user_id", "User ID")} + + {t("ui.common.audit.table.action", "Action")} + + {t("ui.common.audit.table.client_id", "Client ID")} + + {t("ui.common.audit.table.status", "Status")} + +
+ {t("msg.common.audit.loading", "Loading audit logs...")} +
+ {t("msg.common.audit.empty", "No audit logs found.")} +
+
+
{date}
+
{time}
+
+
+
+ + {actorLabel} + + {actorLabel !== "-" ? ( + + ) : null} +
+
+
+ {actionLabel} +
+
+
+ {targetLabel} + {targetLabel !== "-" ? ( + + ) : null} +
+
+ + {row.status} + + + +
+
+
+
+ {t("ui.common.audit.details.request", "Request")} +
+
+ {t( + "ui.common.audit.details.request_id", + "Request ID · {{value}}", + { + value: formatAuditValue(details.request_id), + }, + )} +
+
+ {t( + "ui.common.audit.details.event_id", + "Event ID · {{value}}", + { + value: formatAuditValue(row.event_id), + }, + )} +
+
+ {t("ui.common.audit.details.ip", "IP · {{value}}", { + value: formatAuditValue(row.ip_address), + })} +
+
+ {t( + "ui.common.audit.details.method", + "Method · {{value}}", + { + value: formatAuditValue(details.method), + }, + )} +
+
+ {t( + "ui.common.audit.details.path", + "Path · {{value}}", + { + value: formatAuditValue(details.path), + }, + )} +
+
+ {t( + "ui.common.audit.details.latency", + "Latency · {{value}}", + { + value: + details.latency_ms !== undefined + ? `${details.latency_ms}ms` + : "-", + }, + )} +
+
+
+
+ {t("ui.common.audit.details.actor", "Actor")} +
+
+ {t( + "ui.common.audit.details.actor_id", + "User ID · {{value}}", + { value: actorLabel }, + )} +
+
+ {t( + "ui.common.audit.details.tenant", + "Tenant · {{value}}", + { + value: formatAuditValue(details.tenant_id), + }, + )} +
+
+ {t( + "ui.common.audit.details.device", + "Device · {{value}}", + { + value: formatAuditValue(row.device_id), + }, + )} +
+
+ {t( + "ui.common.audit.details.target", + "Client ID · {{value}}", + { value: targetLabel }, + )} +
+
+
+
+ {t("ui.common.audit.details.result", "Result")} +
+
+ {t( + "ui.common.audit.details.error", + "Error · {{value}}", + { + value: formatAuditValue(details.error), + }, + )} +
+
+ {t( + "ui.common.audit.details.before", + "Before · {{value}}", + { + value: formatAuditValue(details.before), + }, + )} +
+
+ {t( + "ui.common.audit.details.after", + "After · {{value}}", + { + value: formatAuditValue(details.after), + }, + )} +
+
+
+
+
+
+
+ {hasNextPage ? ( + + ) : ( + + {t("msg.common.audit.end", "End of audit feed")} + + )} +
+
+ ); +} diff --git a/common/core/components/audit/index.ts b/common/core/components/audit/index.ts new file mode 100644 index 00000000..1c1a821e --- /dev/null +++ b/common/core/components/audit/index.ts @@ -0,0 +1 @@ +export * from "./AuditLogTable"; diff --git a/common/locales/en.toml b/common/locales/en.toml index f9489562..9f24c3df 100644 --- a/common/locales/en.toml +++ b/common/locales/en.toml @@ -12,9 +12,13 @@ unknown_error = "unknown error" [msg.common.audit] empty = "No audit logs found." +end = "End of audit feed" load_error = "Error loading logs: {{error}}" loading = "Loading audit logs..." +[msg.common.audit.registry] +count = "{{count}} logs" + [ui.common] apply = "Apply" actions = "Actions" @@ -96,12 +100,18 @@ load_more = "Load more" title = "Audit Logs" [ui.common.audit.copy] -actor_id = "Copy actor id" -target = "Copy target" +actor_id = "Copy User ID" +target = "Copy Client ID" + +[ui.common.audit.filters] +user_id = "Filter by User ID" +client_id = "Filter by Client ID" +action = "Filter by Action (e.g. ROTATE_SECRET)" +status_all = "All Status" [ui.common.audit.details] -actor = "Actor" -actor_id = "Actor ID · {{value}}" +actor = "User ID" +actor_id = "User ID · {{value}}" after = "After · {{value}}" before = "Before · {{value}}" device = "Device · {{value}}" @@ -109,19 +119,24 @@ error = "Error · {{value}}" event_id = "Event ID · {{value}}" ip = "IP · {{value}}" latency = "Latency · {{value}}" +method = "Method · {{value}}" +path = "Path · {{value}}" request = "Request" request_id = "Request ID · {{value}}" result = "Result" tenant = "Tenant · {{value}}" +target = "Client ID · {{value}}" [ui.common.audit.registry] title = "Audit registry" [ui.common.audit.table] action = "Action" -actor = "Actor" +actor = "User ID" +client_id = "Client ID" +user_id = "User ID" status = "Status" -target = "Target" +target = "Client ID" time = "Time" [ui.common.overview] diff --git a/common/locales/ko.toml b/common/locales/ko.toml index 3eeb255e..08b97c67 100644 --- a/common/locales/ko.toml +++ b/common/locales/ko.toml @@ -12,9 +12,13 @@ unknown_error = "알 수 없는 오류" [msg.common.audit] empty = "아직 수집된 감사 로그가 없습니다." +end = "End of audit feed" load_error = "Error loading logs: {{error}}" loading = "Loading audit logs..." +[msg.common.audit.registry] +count = "총 {{count}}개 로그" + [ui.common] apply = "적용" actions = "액션" @@ -96,12 +100,18 @@ load_more = "더 보기" title = "감사 로그" [ui.common.audit.copy] -actor_id = "Copy actor id" -target = "Copy target" +actor_id = "사용자 ID 복사" +target = "클라이언트 ID 복사" + +[ui.common.audit.filters] +user_id = "사용자 ID로 검색" +client_id = "클라이언트 ID로 검색" +action = "액션으로 검색 (예: ROTATE_SECRET)" +status_all = "전체 상태" [ui.common.audit.details] -actor = "Actor" -actor_id = "Actor ID · {{value}}" +actor = "사용자 ID" +actor_id = "사용자 ID · {{value}}" after = "After · {{value}}" before = "Before · {{value}}" device = "Device · {{value}}" @@ -109,19 +119,24 @@ error = "Error · {{value}}" event_id = "Event ID · {{value}}" ip = "IP · {{value}}" latency = "Latency · {{value}}" +method = "Method · {{value}}" +path = "Path · {{value}}" request = "Request" request_id = "Request ID · {{value}}" result = "Result" tenant = "Tenant · {{value}}" +target = "클라이언트 ID · {{value}}" [ui.common.audit.registry] -title = "Audit registry" +title = "감사 로그 레지스트리" [ui.common.audit.table] action = "액션" -actor = "수행자" +actor = "사용자 ID" +client_id = "클라이언트 ID" +user_id = "사용자 ID" status = "상태" -target = "대상" +target = "클라이언트 ID" time = "시간" [ui.common.overview] diff --git a/common/locales/template.toml b/common/locales/template.toml index 3c8b5452..ae4cc887 100644 --- a/common/locales/template.toml +++ b/common/locales/template.toml @@ -12,9 +12,13 @@ unknown_error = "" [msg.common.audit] empty = "" +end = "" load_error = "" loading = "" +[msg.common.audit.registry] +count = "" + [ui.common] apply = "Apply" actions = "" @@ -99,6 +103,12 @@ title = "" actor_id = "" target = "" +[ui.common.audit.filters] +user_id = "" +client_id = "" +action = "" +status_all = "" + [ui.common.audit.details] actor = "" actor_id = "" @@ -109,10 +119,13 @@ error = "" event_id = "" ip = "" latency = "" +method = "" +path = "" request = "" request_id = "" result = "" tenant = "" +target = "" [ui.common.audit.registry] title = "" @@ -120,6 +133,8 @@ title = "" [ui.common.audit.table] action = "" actor = "" +client_id = "" +user_id = "" status = "" target = "" time = "" diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index 40584bde..88595ddd 100644 --- a/devfront/src/features/audit/AuditLogsPage.tsx +++ b/devfront/src/features/audit/AuditLogsPage.tsx @@ -1,43 +1,20 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { - ChevronDown, - ChevronUp, - Copy, - Download, - RefreshCw, - Search, -} from "lucide-react"; +import { Download, RefreshCw, Search } from "lucide-react"; import * as React from "react"; -import { - commonStickyTableHeaderClass, - commonTableShellClass, - commonTableViewportClass, -} from "../../../../common/ui/table"; import { ForbiddenMessage } from "../../components/common/ForbiddenMessage"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; -import { - Card, - CardContent, -} from "../../components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "../../components/ui/table"; -import { - formatAuditDateParts, formatAuditValue, parseAuditDetails, resolveAuditAction, resolveAuditActor, resolveAuditTarget, } from "../../../../common/core/audit"; +import { AuditLogTable } from "../../../../common/core/components/audit"; import { SearchFilterBar } from "../../../../common/ui/search-filter-bar"; import { PageHeader } from "../../../../common/core/components/page"; import type { DevAuditLog } from "../../lib/devApi"; @@ -96,10 +73,6 @@ function AuditLogsPage() { const deferredSearchClientId = React.useDeferredValue(searchClientId.trim()); const deferredSearchAction = React.useDeferredValue(searchAction.trim()); - const [expandedRows, setExpandedRows] = React.useState< - Record - >({}); - const query = useInfiniteQuery({ queryKey: [ "dev-audit-logs", @@ -122,13 +95,6 @@ function AuditLogsPage() { page.items.filter((item): item is DevAuditLog => Boolean(item)), ) ?? []; - const handleCopy = (value: string) => { - if (!value) { - return; - } - navigator.clipboard.writeText(value); - }; - const handleExportCsv = () => { const csv = toCsv(logs); const stamp = new Date().toISOString().replaceAll(":", "-"); @@ -155,7 +121,6 @@ function AuditLogsPage() { return (
- {t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", { + {t("msg.common.audit.registry.count", "총 {{count}}개 로그", { count: logs.length, })} @@ -188,7 +153,14 @@ function AuditLogsPage() { /> - + +
+ + {t("ui.common.audit.registry.title", "Audit registry")} + +
+
+ setSearchClientId(e.target.value)} placeholder={t( - "ui.dev.audit.filter.client_id", + "ui.common.audit.filters.client_id", "Filter by Client ID", )} /> @@ -216,7 +188,7 @@ function AuditLogsPage() { setSearchAction(e.target.value.toUpperCase()) } placeholder={t( - "ui.dev.audit.filter.action", + "ui.common.audit.filters.action", "Filter by Action (e.g. ROTATE_SECRET)", )} /> @@ -226,7 +198,7 @@ function AuditLogsPage() { onChange={(e) => setStatusFilter(e.target.value)} >
- - {query.hasNextPage ? ( -
- -
- ) : null}