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

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