1
0
forked from baron/baron-sso

감사 로그 테이블 헤더 및 검색창 문구 수정

This commit is contained in:
2026-05-15 14:46:58 +09:00
parent 974af01d34
commit 9df69f22e8
7 changed files with 563 additions and 698 deletions

View File

@@ -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<string, boolean>
>({});
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 (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="space-y-6">
<PageHeader
sticky
titleAs="h2"
title={t("ui.common.audit.title", "감사 로그")}
description={t(
"msg.admin.audit.subtitle",
@@ -129,6 +96,11 @@ function AuditLogsPage() {
)}
actions={
<>
<Badge variant="muted">
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
count: filteredLogs.length,
})}
</Badge>
<Button
variant="outline"
onClick={() => refetch()}
@@ -145,376 +117,72 @@ function AuditLogsPage() {
}
/>
<Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>
{t("ui.common.audit.registry.title", "Audit registry")}
</CardTitle>
<CardDescription>
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", {
count: logs.length,
})}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<CardContent className="space-y-4 pt-0">
<SearchFilterBar
className="mb-4"
primary={
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1">
<form
onSubmit={(e) => {
e.preventDefault();
refetch();
}}
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={filterDraft}
onChange={(event) => setFilterDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleAddFilter();
}
}}
<Input
className="pl-10"
value={searchActorId}
onChange={(event) => setSearchActorId(event.target.value)}
placeholder={t(
"ui.admin.audit.filters.placeholder",
"필터 추가 (예: status:failure)",
"ui.common.audit.filters.user_id",
"Filter by User ID",
)}
className="h-10 w-full rounded-md border border-input bg-background pl-10 pr-3 text-sm outline-none"
/>
</div>
<Button size="sm" variant="outline" onClick={handleAddFilter}>
{t("ui.common.add", "추가")}
</Button>
</div>
}
actions={
<>
{filters.length === 0 ? (
<span className="text-xs text-[var(--color-muted)]">
{t("msg.admin.audit.filters.empty", "필터 없음")}
</span>
) : (
filters.map((filter) => (
<span
key={filter}
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
>
<Terminal size={12} />
{filter}
<button
type="button"
onClick={() =>
setFilters((prev) =>
prev.filter((item) => item !== filter),
)
}
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
aria-label={t(
"ui.admin.audit.filters.remove",
"{{filter}} 필터 제거",
{ filter },
)}
>
×
</button>
</span>
))
)}
</>
<Input
value={searchAction}
onChange={(event) =>
setSearchAction(event.target.value.toUpperCase())
}
placeholder={t(
"ui.common.audit.filters.action",
"Filter by Action (e.g. ROTATE_SECRET)",
)}
/>
<select
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
<option value="all">
{t("ui.common.audit.filters.status_all", "All Status")}
</option>
<option value="success">
{t("ui.common.status.success", "Success")}
</option>
<option value="failure">
{t("ui.common.status.failure", "Failure")}
</option>
</select>
</form>
}
/>
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table className="table-fixed">
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead className="w-[140px]">
{t("ui.common.audit.table.time", "시간")}
</TableHead>
<TableHead className="w-[160px]">
{t("ui.common.audit.table.actor", "수행자")}
</TableHead>
<TableHead>{t("ui.common.audit.table.action", "액션")}</TableHead>
<TableHead>{t("ui.common.audit.table.target", "대상")}</TableHead>
<TableHead className="w-[120px]">
{t("ui.common.audit.table.status", "상태")}
</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={6}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!isLoading && logs.length === 0 && (
<TableRow>
<TableCell colSpan={6}>
{t(
"msg.common.audit.empty",
"아직 수집된 감사 로그가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{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 (
<React.Fragment key={rowKey}>
<TableRow className="bg-card/40">
<TableCell className="text-xs text-[var(--color-muted)]">
{(() => {
const { date, time } = formatAuditDateParts(
row.timestamp,
);
return (
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
);
})()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{actorLabel}
</code>
{actorLabel !== "-" && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.common.audit.copy.actor_id",
"Copy actor id",
)}
onClick={() => handleCopy(actorLabel)}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="font-semibold text-foreground">
{actionLabel}
</div>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="flex items-center gap-2">
<span className="break-all">{targetLabel}</span>
{targetLabel !== "-" && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.common.audit.copy.target",
"Copy target",
)}
onClick={() => handleCopy(targetLabel)}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
<TableCell>
<Badge
variant={
row.status === "success" || row.status === "ok"
? "success"
: "warning"
}
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !isExpanded,
}))
}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="bg-card/20">
<TableCell colSpan={6} className="text-xs">
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t(
"ui.common.audit.details.request",
"Request",
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.request_id",
"Request ID · {{value}}",
{
value: formatAuditValue(
details.request_id,
),
},
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.event_id",
"Event ID · {{value}}",
{
value: formatAuditValue(row.event_id),
},
)}
</div>
<div>
{t(
"ui.common.audit.details.ip",
"IP · {{value}}",
{
value: formatAuditValue(row.ip_address),
},
)}
</div>
<div className="break-all">
Method · {formatAuditValue(details.method)}
</div>
<div className="break-all">
Path · {formatAuditValue(details.path)}
</div>
<div>
{t(
"ui.common.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.common.audit.details.actor_id",
"Actor ID · {{value}}",
{
value: actorLabel,
},
)}
</div>
<div>
{t(
"ui.common.audit.details.tenant",
"Tenant · {{value}}",
{
value: formatAuditValue(
details.tenant_id,
),
},
)}
</div>
<div>
{t(
"ui.common.audit.details.device",
"Device · {{value}}",
{
value: formatAuditValue(row.device_id),
},
)}
</div>
<div className="break-all">
Target · {targetLabel}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t(
"ui.common.audit.details.result",
"Result",
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.error",
"Error · {{value}}",
{
value: formatAuditValue(details.error),
},
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.before",
"Before · {{value}}",
{
value: formatAuditValue(details.before),
},
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.after",
"After · {{value}}",
{
value: formatAuditValue(details.after),
},
)}
</div>
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
</div>
<div className="pt-4 text-center flex-shrink-0">
{hasNextPage ? (
<Button
variant="outline"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.common.audit.load_more", "더 보기")}
</Button>
) : (
<span className="text-xs text-[var(--color-muted)]">
{t("msg.admin.audit.end", "End of audit feed")}
</span>
)}
</div>
<AuditLogTable
logs={filteredLogs}
t={t}
loading={isLoading}
hasNextPage={Boolean(hasNextPage)}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
/>
</CardContent>
</Card>
</div>

View File

@@ -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, string | number>,
) => string;
type AuditLogTableProps = {
logs: CommonAuditLog[];
t: AuditTranslate;
loading: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
className?: string;
};
function cx(...classNames: Array<string | false | null | undefined>) {
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<string, boolean>
>({});
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
return (
<div className={cx(commonTableShellClass, className)}>
<div className={commonTableViewportClass}>
<div className={commonTableWrapperClass}>
<table className={cx(commonTableClass, "table-fixed")}>
<thead
className={cx(commonTableHeaderClass, commonStickyTableHeaderClass)}
>
<tr className={commonTableRowClass}>
<th className={cx(commonTableHeadClass, "w-[190px]")}>
{t("ui.common.audit.table.time", "Time")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.user_id", "User ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.action", "Action")}
</th>
<th className={cx(commonTableHeadClass, "w-[260px]")}>
{t("ui.common.audit.table.client_id", "Client ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[120px]")}>
{t("ui.common.audit.table.status", "Status")}
</th>
<th className={cx(commonTableHeadClass, "w-[80px]")} />
</tr>
</thead>
<tbody className={commonTableBodyClass}>
{loading && logs.length === 0 ? (
<tr className={commonTableRowClass}>
<td
colSpan={6}
className={cx(
commonTableCellClass,
"py-8 text-center text-muted-foreground",
)}
>
{t("msg.common.audit.loading", "Loading audit logs...")}
</td>
</tr>
) : logs.length === 0 ? (
<tr className={commonTableRowClass}>
<td
colSpan={6}
className={cx(commonTableCellClass, "text-center text-muted-foreground")}
>
{t("msg.common.audit.empty", "No audit logs found.")}
</td>
</tr>
) : (
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 (
<React.Fragment key={rowKey}>
<tr className={cx(commonTableRowClass, "bg-card/40")}>
<td className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
</td>
<td className={commonTableCellClass}>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{actorLabel}
</code>
{actorLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.actor_id",
"Copy User ID",
)}
onClick={() => handleCopy(actorLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</td>
<td
className={cx(commonTableCellClass, "text-xs text-muted-foreground")}
>
<div className="font-semibold text-foreground">
{actionLabel}
</div>
</td>
<td
className={cx(commonTableCellClass, "text-xs text-muted-foreground")}
>
<div className="flex items-center gap-2">
<span className="break-all">{targetLabel}</span>
{targetLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.target",
"Copy Client ID",
)}
onClick={() => handleCopy(targetLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</td>
<td className={commonTableCellClass}>
<span
className={getCommonBadgeClasses({
variant: statusVariant(row.status),
})}
>
{row.status}
</span>
</td>
<td className={cx(commonTableCellClass, "text-right")}>
<button
type="button"
className={getCommonButtonClasses({
variant: "ghost",
size: "sm",
})}
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}))
}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</td>
</tr>
{expanded ? (
<tr className={cx(commonTableRowClass, "bg-card/20")}>
<td colSpan={6} className={cx(commonTableCellClass, "text-xs")}>
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.request", "Request")}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.request_id",
"Request ID · {{value}}",
{
value: formatAuditValue(details.request_id),
},
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.event_id",
"Event ID · {{value}}",
{
value: formatAuditValue(row.event_id),
},
)}
</div>
<div>
{t("ui.common.audit.details.ip", "IP · {{value}}", {
value: formatAuditValue(row.ip_address),
})}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.method",
"Method · {{value}}",
{
value: formatAuditValue(details.method),
},
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.path",
"Path · {{value}}",
{
value: formatAuditValue(details.path),
},
)}
</div>
<div>
{t(
"ui.common.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.common.audit.details.actor_id",
"User ID · {{value}}",
{ value: actorLabel },
)}
</div>
<div>
{t(
"ui.common.audit.details.tenant",
"Tenant · {{value}}",
{
value: formatAuditValue(details.tenant_id),
},
)}
</div>
<div>
{t(
"ui.common.audit.details.device",
"Device · {{value}}",
{
value: formatAuditValue(row.device_id),
},
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.target",
"Client ID · {{value}}",
{ value: targetLabel },
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.result", "Result")}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.error",
"Error · {{value}}",
{
value: formatAuditValue(details.error),
},
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.before",
"Before · {{value}}",
{
value: formatAuditValue(details.before),
},
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.after",
"After · {{value}}",
{
value: formatAuditValue(details.after),
},
)}
</div>
</div>
</div>
</td>
</tr>
) : null}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
</div>
<div className="pt-6 text-center flex-shrink-0">
{hasNextPage ? (
<button
type="button"
className={getCommonButtonClasses({ variant: "outline" })}
onClick={onLoadMore}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.common.audit.load_more", "Load more")}
</button>
) : (
<span className="text-xs text-muted-foreground">
{t("msg.common.audit.end", "End of audit feed")}
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "./AuditLogTable";

View File

@@ -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]

View File

@@ -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]

View File

@@ -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 = ""

View File

@@ -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<string, boolean>
>({});
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 (
<div className="space-y-6">
<PageHeader
eyebrow={t("ui.common.audit.registry.title", "Audit registry")}
title={t("ui.common.audit.title", "Audit Logs")}
description={t(
"msg.dev.audit.subtitle",
@@ -164,7 +129,7 @@ function AuditLogsPage() {
actions={
<>
<Badge variant="muted">
{t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", {
{t("msg.common.audit.registry.count", " {{count}}개 로그", {
count: logs.length,
})}
</Badge>
@@ -188,7 +153,14 @@ function AuditLogsPage() {
/>
<Card className="glass-panel">
<CardContent className="space-y-4 pt-6">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>
{t("ui.common.audit.registry.title", "Audit registry")}
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<SearchFilterBar
primary={
<form
@@ -205,7 +177,7 @@ function AuditLogsPage() {
value={searchClientId}
onChange={(e) => 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)}
>
<option value="all">
{t("ui.dev.audit.filter.status_all", "All Status")}
{t("ui.common.audit.filters.status_all", "All Status")}
</option>
<option value="success">
{t("ui.common.status.success", "Success")}
@@ -246,230 +218,15 @@ function AuditLogsPage() {
: ""
}
>
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table className="table-fixed">
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead className="w-[190px]">
{t("ui.common.audit.table.time", "Time")}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.common.audit.table.actor", "Actor")}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.common.audit.table.action", "Action")}
</TableHead>
<TableHead className="w-[260px]">
{t("ui.common.audit.table.target", "Target")}
</TableHead>
<TableHead className="w-[120px]">
{t("ui.common.audit.table.status", "Status")}
</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && logs.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="py-8 text-center text-muted-foreground"
>
{t("msg.common.audit.loading", "Loading audit logs...")}
</TableCell>
</TableRow>
) : logs.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("msg.common.audit.empty", "No audit logs found.")}
</TableCell>
</TableRow>
) : (
logs.map((row, index) => {
const details = parseAuditDetails(row.details);
const actionLabel = resolveAuditAction(row, details);
const actorLabel = resolveAuditActor(row, details);
const targetValue = resolveAuditTarget(details);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
return (
<React.Fragment key={rowKey}>
<TableRow>
<TableCell className="text-xs text-muted-foreground">
{(() => {
const { date, time } = formatAuditDateParts(
row.timestamp,
);
return (
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
);
})()}
</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
<span>{actorLabel}</span>
{actorLabel !== "-" ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(actorLabel)}
>
<Copy className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
<TableCell className="text-xs">
{actionLabel}
</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
<span className="break-all">
{targetValue}
</span>
{targetValue !== "-" ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(targetValue)}
>
<Copy className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
<TableCell>
<Badge
variant={
row.status === "success"
? "success"
: "warning"
}
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}))
}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</TableCell>
</TableRow>
{expanded ? (
<TableRow className="bg-card/20">
<TableCell
colSpan={6}
className="text-xs text-muted-foreground"
>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
Request
</div>
<div className="break-all">
Request ID:{" "}
{formatAuditValue(details.request_id)}
</div>
<div className="break-all">
Event ID:{" "}
{formatAuditValue(row.event_id)}
</div>
<div>IP: {formatAuditValue(row.ip_address)}</div>
<div className="break-all">
Method: {formatAuditValue(details.method)}
</div>
<div className="break-all">
Path: {formatAuditValue(details.path)}
</div>
<div>
Latency:{" "}
{details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-"}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
Actor
</div>
<div>Actor ID: {actorLabel}</div>
<div>
Tenant:{" "}
{formatAuditValue(details.tenant_id)}
</div>
<div>
Device:{" "}
{formatAuditValue(row.device_id)}
</div>
<div className="break-all">
Target: {targetValue}
</div>
</div>
<div className="space-y-1 break-all">
<div className="uppercase tracking-[0.16em]">
Result
</div>
<div>
Error:{" "}
{formatAuditValue(details.error)}
</div>
<div>
Before:{" "}
{formatAuditValue(details.before)}
</div>
<div>
After: {formatAuditValue(details.after)}
</div>
</div>
</div>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div>
</div>
<AuditLogTable
logs={logs}
t={t}
loading={query.isLoading}
hasNextPage={Boolean(query.hasNextPage)}
isFetchingNextPage={query.isFetchingNextPage}
onLoadMore={() => query.fetchNextPage()}
/>
</div>
{query.hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
>
{query.isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.common.audit.load_more", "Load more")}
</Button>
</div>
) : null}
</CardContent>
</Card>
</div>