forked from baron/baron-sso
감사 로그 테이블 공통 컬럼 통일
This commit is contained in:
@@ -32,6 +32,14 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
|
import {
|
||||||
|
formatAuditDateParts,
|
||||||
|
formatAuditValue,
|
||||||
|
parseAuditDetails,
|
||||||
|
resolveAuditAction,
|
||||||
|
resolveAuditActor,
|
||||||
|
resolveAuditTarget,
|
||||||
|
} from "../../../../common/core/audit";
|
||||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||||
import { PageHeader } from "../../../../common/core/components/page";
|
import { PageHeader } from "../../../../common/core/components/page";
|
||||||
import type { AuditLog } from "../../lib/adminApi";
|
import type { AuditLog } from "../../lib/adminApi";
|
||||||
@@ -44,61 +52,6 @@ const defaultAuditFilters = [
|
|||||||
"latency_ms:>1000",
|
"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() {
|
function AuditLogsPage() {
|
||||||
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
||||||
const [filterDraft, setFilterDraft] = React.useState("");
|
const [filterDraft, setFilterDraft] = React.useState("");
|
||||||
@@ -146,7 +99,7 @@ function AuditLogsPage() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
{t("msg.admin.audit.loading", "Loading audit logs...")}
|
{t("msg.common.audit.loading", "Loading audit logs...")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -157,7 +110,7 @@ function AuditLogsPage() {
|
|||||||
(error as Error).message;
|
(error as Error).message;
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-red-500">
|
<div className="p-8 text-center text-red-500">
|
||||||
{t("msg.admin.audit.load_error", "Error loading logs: {{error}}", {
|
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
|
||||||
error: errMsg,
|
error: errMsg,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +122,7 @@ function AuditLogsPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
sticky
|
sticky
|
||||||
titleAs="h2"
|
titleAs="h2"
|
||||||
title={t("ui.admin.audit.title", "감사 로그")}
|
title={t("ui.common.audit.title", "감사 로그")}
|
||||||
description={t(
|
description={t(
|
||||||
"msg.admin.audit.subtitle",
|
"msg.admin.audit.subtitle",
|
||||||
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
|
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
|
||||||
@@ -196,7 +149,7 @@ function AuditLogsPage() {
|
|||||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{t("ui.admin.audit.registry.title", "Log Registry")}
|
{t("ui.common.audit.registry.title", "Audit registry")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", {
|
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", {
|
||||||
@@ -274,25 +227,15 @@ function AuditLogsPage() {
|
|||||||
<TableHeader className={commonStickyTableHeaderClass}>
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[140px]">
|
<TableHead className="w-[140px]">
|
||||||
{t("ui.admin.audit.table.time", "TIME")}
|
{t("ui.common.audit.table.time", "시간")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[160px]">
|
<TableHead className="w-[160px]">
|
||||||
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
|
{t("ui.common.audit.table.actor", "수행자")}
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
{t("ui.admin.audit.table.request", "REQUEST")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
{t("ui.admin.audit.table.path", "PATH")}
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead>{t("ui.common.audit.table.action", "액션")}</TableHead>
|
||||||
|
<TableHead>{t("ui.common.audit.table.target", "대상")}</TableHead>
|
||||||
<TableHead className="w-[120px]">
|
<TableHead className="w-[120px]">
|
||||||
{t("ui.admin.audit.table.status", "STATUS")}
|
{t("ui.common.audit.table.status", "상태")}
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
{t(
|
|
||||||
"ui.admin.audit.table.action_target",
|
|
||||||
"Action / Target",
|
|
||||||
)}
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[80px]" />
|
<TableHead className="w-[80px]" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -300,28 +243,26 @@ function AuditLogsPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7}>
|
<TableCell colSpan={6}>
|
||||||
{t("msg.common.loading", "로딩 중...")}
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{!isLoading && logs.length === 0 && (
|
{!isLoading && logs.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7}>
|
<TableCell colSpan={6}>
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.audit.empty",
|
"msg.common.audit.empty",
|
||||||
"아직 수집된 감사 로그가 없습니다.",
|
"아직 수집된 감사 로그가 없습니다.",
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{logs.map((row, index) => {
|
{logs.map((row, index) => {
|
||||||
const details = parseDetails(row.details);
|
const details = parseAuditDetails(row.details);
|
||||||
const actionLabel =
|
const actionLabel = resolveAuditAction(row, details);
|
||||||
details.action ||
|
const actorLabel = resolveAuditActor(row, details);
|
||||||
(details.method && details.path
|
const targetLabel = resolveAuditTarget(details);
|
||||||
? `${details.method} ${details.path}`
|
|
||||||
: row.event_type);
|
|
||||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||||
const isExpanded = Boolean(expandedRows[rowKey]);
|
const isExpanded = Boolean(expandedRows[rowKey]);
|
||||||
return (
|
return (
|
||||||
@@ -329,7 +270,7 @@ function AuditLogsPage() {
|
|||||||
<TableRow className="bg-card/40">
|
<TableRow className="bg-card/40">
|
||||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
{(() => {
|
{(() => {
|
||||||
const { date, time } = formatIsoDateTime(
|
const { date, time } = formatAuditDateParts(
|
||||||
row.timestamp,
|
row.timestamp,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -343,45 +284,18 @@ function AuditLogsPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||||
{row.user_id || details.actor_id || "-"}
|
{actorLabel}
|
||||||
</code>
|
</code>
|
||||||
{(row.user_id || details.actor_id) && (
|
{actorLabel !== "-" && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
aria-label={t(
|
aria-label={t(
|
||||||
"ui.admin.audit.copy.actor_id",
|
"ui.common.audit.copy.actor_id",
|
||||||
"Copy actor id",
|
"Copy actor id",
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() => handleCopy(actorLabel)}
|
||||||
handleCopy(
|
|
||||||
row.user_id || details.actor_id || "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="break-all">
|
|
||||||
{formatCellValue(details.request_id)}
|
|
||||||
</span>
|
|
||||||
{details.request_id && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
|
||||||
aria-label={t(
|
|
||||||
"ui.admin.audit.copy.request_id",
|
|
||||||
"Copy request id",
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
|
||||||
handleCopy(details.request_id || "")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -390,10 +304,26 @@ function AuditLogsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
<div className="font-semibold text-foreground">
|
<div className="font-semibold text-foreground">
|
||||||
{formatCellValue(details.method)}
|
{actionLabel}
|
||||||
</div>
|
</div>
|
||||||
<div className="break-all">
|
</TableCell>
|
||||||
{formatCellValue(details.path)}
|
<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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -407,38 +337,6 @@ function AuditLogsPage() {
|
|||||||
{row.status}
|
{row.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
|
||||||
<div className="font-semibold text-foreground">
|
|
||||||
{actionLabel}
|
|
||||||
</div>
|
|
||||||
{details.target && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="break-all">
|
|
||||||
{t(
|
|
||||||
"ui.admin.audit.target",
|
|
||||||
"Target · {{target}}",
|
|
||||||
{
|
|
||||||
target: details.target,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
|
||||||
aria-label={t(
|
|
||||||
"ui.admin.audit.copy.target",
|
|
||||||
"Copy target",
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
|
||||||
handleCopy(details.target || "")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -460,21 +358,21 @@ function AuditLogsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<TableRow className="bg-card/20">
|
<TableRow className="bg-card/20">
|
||||||
<TableCell colSpan={7} className="text-xs">
|
<TableCell colSpan={6} className="text-xs">
|
||||||
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="uppercase tracking-[0.16em]">
|
<div className="uppercase tracking-[0.16em]">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.request",
|
"ui.common.audit.details.request",
|
||||||
"Request",
|
"Request",
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="break-all">
|
<div className="break-all">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.request_id",
|
"ui.common.audit.details.request_id",
|
||||||
"Request ID · {{value}}",
|
"Request ID · {{value}}",
|
||||||
{
|
{
|
||||||
value: formatCellValue(
|
value: formatAuditValue(
|
||||||
details.request_id,
|
details.request_id,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -482,25 +380,31 @@ function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="break-all">
|
<div className="break-all">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.event_id",
|
"ui.common.audit.details.event_id",
|
||||||
"Event ID · {{value}}",
|
"Event ID · {{value}}",
|
||||||
{
|
{
|
||||||
value: formatCellValue(row.event_id),
|
value: formatAuditValue(row.event_id),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.ip",
|
"ui.common.audit.details.ip",
|
||||||
"IP · {{value}}",
|
"IP · {{value}}",
|
||||||
{
|
{
|
||||||
value: formatCellValue(row.ip_address),
|
value: formatAuditValue(row.ip_address),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Method · {formatAuditValue(details.method)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Path · {formatAuditValue(details.path)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.latency",
|
"ui.common.audit.details.latency",
|
||||||
"Latency · {{value}}",
|
"Latency · {{value}}",
|
||||||
{
|
{
|
||||||
value:
|
value:
|
||||||
@@ -513,26 +417,23 @@ function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="uppercase tracking-[0.16em]">
|
<div className="uppercase tracking-[0.16em]">
|
||||||
{t("ui.admin.audit.details.actor", "Actor")}
|
{t("ui.common.audit.details.actor", "Actor")}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.actor_id",
|
"ui.common.audit.details.actor_id",
|
||||||
"Actor ID · {{value}}",
|
"Actor ID · {{value}}",
|
||||||
{
|
{
|
||||||
value:
|
value: actorLabel,
|
||||||
row.user_id ||
|
|
||||||
details.actor_id ||
|
|
||||||
"-",
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.tenant",
|
"ui.common.audit.details.tenant",
|
||||||
"Tenant · {{value}}",
|
"Tenant · {{value}}",
|
||||||
{
|
{
|
||||||
value: formatCellValue(
|
value: formatAuditValue(
|
||||||
details.tenant_id,
|
details.tenant_id,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -540,45 +441,48 @@ function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.device",
|
"ui.common.audit.details.device",
|
||||||
"Device · {{value}}",
|
"Device · {{value}}",
|
||||||
{
|
{
|
||||||
value: formatCellValue(row.device_id),
|
value: formatAuditValue(row.device_id),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Target · {targetLabel}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="uppercase tracking-[0.16em]">
|
<div className="uppercase tracking-[0.16em]">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.result",
|
"ui.common.audit.details.result",
|
||||||
"Result",
|
"Result",
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="break-all">
|
<div className="break-all">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.error",
|
"ui.common.audit.details.error",
|
||||||
"Error · {{value}}",
|
"Error · {{value}}",
|
||||||
{
|
{
|
||||||
value: formatCellValue(details.error),
|
value: formatAuditValue(details.error),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="break-all">
|
<div className="break-all">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.before",
|
"ui.common.audit.details.before",
|
||||||
"Before · {{value}}",
|
"Before · {{value}}",
|
||||||
{
|
{
|
||||||
value: formatCellValue(details.before),
|
value: formatAuditValue(details.before),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="break-all">
|
<div className="break-all">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.audit.details.after",
|
"ui.common.audit.details.after",
|
||||||
"After · {{value}}",
|
"After · {{value}}",
|
||||||
{
|
{
|
||||||
value: formatCellValue(details.after),
|
value: formatAuditValue(details.after),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -603,7 +507,7 @@ function AuditLogsPage() {
|
|||||||
>
|
>
|
||||||
{isFetchingNextPage
|
{isFetchingNextPage
|
||||||
? t("msg.common.loading", "Loading...")
|
? t("msg.common.loading", "Loading...")
|
||||||
: t("ui.admin.audit.load_more", "Load more")}
|
: t("ui.common.audit.load_more", "더 보기")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-[var(--color-muted)]">
|
<span className="text-xs text-[var(--color-muted)]">
|
||||||
|
|||||||
92
common/core/audit/index.ts
Normal file
92
common/core/audit/index.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
export type CommonAuditLog = {
|
||||||
|
event_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
user_id: string;
|
||||||
|
event_type: string;
|
||||||
|
status: string;
|
||||||
|
ip_address: string;
|
||||||
|
user_agent: string;
|
||||||
|
device_id?: string;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
target_id?: string;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseAuditDetails(details?: string): AuditDetails {
|
||||||
|
if (!details) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(details);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return parsed as AuditDetails;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAuditValue(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAuditDateParts(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
return { date: "-", time: "-" };
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return { date: value, time: "-" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
date: parsed.toISOString().slice(0, 10),
|
||||||
|
time: parsed.toLocaleTimeString("ko-KR", { hour12: false }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuditActor(
|
||||||
|
log: Pick<CommonAuditLog, "user_id">,
|
||||||
|
details: AuditDetails,
|
||||||
|
) {
|
||||||
|
return log.user_id || details.actor_id || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuditAction(
|
||||||
|
log: Pick<CommonAuditLog, "event_type">,
|
||||||
|
details: AuditDetails,
|
||||||
|
) {
|
||||||
|
if (details.action) {
|
||||||
|
return details.action;
|
||||||
|
}
|
||||||
|
if (details.method && details.path) {
|
||||||
|
return `${details.method} ${details.path}`;
|
||||||
|
}
|
||||||
|
return log.event_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuditTarget(details: AuditDetails) {
|
||||||
|
return details.target || details.target_id || "-";
|
||||||
|
}
|
||||||
@@ -30,59 +30,20 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
|
import {
|
||||||
|
formatAuditDateParts,
|
||||||
|
formatAuditValue,
|
||||||
|
parseAuditDetails,
|
||||||
|
resolveAuditAction,
|
||||||
|
resolveAuditActor,
|
||||||
|
resolveAuditTarget,
|
||||||
|
} from "../../../../common/core/audit";
|
||||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||||
import { PageHeader } from "../../../../common/core/components/page";
|
import { PageHeader } from "../../../../common/core/components/page";
|
||||||
import type { DevAuditLog } from "../../lib/devApi";
|
import type { DevAuditLog } from "../../lib/devApi";
|
||||||
import { fetchDevAuditLogs } from "../../lib/devApi";
|
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
type AuditDetails = {
|
|
||||||
request_id?: string;
|
|
||||||
method?: string;
|
|
||||||
path?: string;
|
|
||||||
tenant_id?: string;
|
|
||||||
action?: string;
|
|
||||||
target_id?: string;
|
|
||||||
before?: unknown;
|
|
||||||
after?: unknown;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 formatValue(value: unknown): string {
|
|
||||||
if (value === null || value === undefined || value === "") {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value: string): string {
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return parsed.toLocaleString("ko-KR");
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCsv(logs: DevAuditLog[]) {
|
function toCsv(logs: DevAuditLog[]) {
|
||||||
const header = [
|
const header = [
|
||||||
"timestamp",
|
"timestamp",
|
||||||
@@ -95,7 +56,7 @@ function toCsv(logs: DevAuditLog[]) {
|
|||||||
"request_id",
|
"request_id",
|
||||||
];
|
];
|
||||||
const rows = logs.map((logItem) => {
|
const rows = logs.map((logItem) => {
|
||||||
const details = parseDetails(logItem.details);
|
const details = parseAuditDetails(logItem.details);
|
||||||
return [
|
return [
|
||||||
logItem.timestamp,
|
logItem.timestamp,
|
||||||
logItem.user_id || "",
|
logItem.user_id || "",
|
||||||
@@ -184,7 +145,7 @@ function AuditLogsPage() {
|
|||||||
axiosError.response?.data?.error ?? (query.error as Error).message;
|
axiosError.response?.data?.error ?? (query.error as Error).message;
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-red-500">
|
<div className="p-8 text-center text-red-500">
|
||||||
{t("msg.dev.audit.load_error", "Error loading logs: {{error}}", {
|
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
|
||||||
error: errMsg,
|
error: errMsg,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -194,8 +155,8 @@ function AuditLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow={t("ui.dev.audit.registry.title", "Audit registry")}
|
eyebrow={t("ui.common.audit.registry.title", "Audit registry")}
|
||||||
title={t("ui.dev.audit.title", "Audit Logs")}
|
title={t("ui.common.audit.title", "Audit Logs")}
|
||||||
description={t(
|
description={t(
|
||||||
"msg.dev.audit.subtitle",
|
"msg.dev.audit.subtitle",
|
||||||
"Shows DevFront activity history within current tenant/app scope.",
|
"Shows DevFront activity history within current tenant/app scope.",
|
||||||
@@ -291,19 +252,19 @@ function AuditLogsPage() {
|
|||||||
<TableHeader className={commonStickyTableHeaderClass}>
|
<TableHeader className={commonStickyTableHeaderClass}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[190px]">
|
<TableHead className="w-[190px]">
|
||||||
{t("ui.dev.audit.table.time", "Time")}
|
{t("ui.common.audit.table.time", "Time")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[180px]">
|
<TableHead className="w-[180px]">
|
||||||
{t("ui.dev.audit.table.actor", "Actor")}
|
{t("ui.common.audit.table.actor", "Actor")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[180px]">
|
<TableHead className="w-[180px]">
|
||||||
{t("ui.dev.audit.table.action", "Action")}
|
{t("ui.common.audit.table.action", "Action")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[260px]">
|
<TableHead className="w-[260px]">
|
||||||
{t("ui.dev.audit.table.target", "Target")}
|
{t("ui.common.audit.table.target", "Target")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[120px]">
|
<TableHead className="w-[120px]">
|
||||||
{t("ui.dev.audit.table.status", "Status")}
|
{t("ui.common.audit.table.status", "Status")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[80px]" />
|
<TableHead className="w-[80px]" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -315,7 +276,7 @@ function AuditLogsPage() {
|
|||||||
colSpan={6}
|
colSpan={6}
|
||||||
className="py-8 text-center text-muted-foreground"
|
className="py-8 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
{t("msg.common.audit.loading", "Loading audit logs...")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
@@ -324,31 +285,42 @@ function AuditLogsPage() {
|
|||||||
colSpan={6}
|
colSpan={6}
|
||||||
className="text-center text-muted-foreground"
|
className="text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t("msg.dev.audit.empty", "No audit logs found.")}
|
{t("msg.common.audit.empty", "No audit logs found.")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
logs.map((row, index) => {
|
logs.map((row, index) => {
|
||||||
const details = parseDetails(row.details);
|
const details = parseAuditDetails(row.details);
|
||||||
const actionLabel = details.action || row.event_type;
|
const actionLabel = resolveAuditAction(row, details);
|
||||||
const targetValue = details.target_id || "-";
|
const actorLabel = resolveAuditActor(row, details);
|
||||||
|
const targetValue = resolveAuditTarget(details);
|
||||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||||
const expanded = Boolean(expandedRows[rowKey]);
|
const expanded = Boolean(expandedRows[rowKey]);
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={rowKey}>
|
<React.Fragment key={rowKey}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{formatDateTime(row.timestamp)}
|
{(() => {
|
||||||
|
const { date, time } = formatAuditDateParts(
|
||||||
|
row.timestamp,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>{date}</div>
|
||||||
|
<div>{time}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell className="font-mono text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{row.user_id || "-"}</span>
|
<span>{actorLabel}</span>
|
||||||
{row.user_id ? (
|
{actorLabel !== "-" ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground"
|
className="h-7 w-7 text-muted-foreground"
|
||||||
onClick={() => handleCopy(row.user_id)}
|
onClick={() => handleCopy(actorLabel)}
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -411,31 +383,64 @@ function AuditLogsPage() {
|
|||||||
colSpan={6}
|
colSpan={6}
|
||||||
className="text-xs text-muted-foreground"
|
className="text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div>
|
<div className="uppercase tracking-[0.16em]">
|
||||||
|
Request
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
Request ID:{" "}
|
Request ID:{" "}
|
||||||
{formatValue(details.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>
|
||||||
<div>
|
<div>
|
||||||
Method: {formatValue(details.method)}
|
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>
|
||||||
<div>
|
<div>
|
||||||
Path: {formatValue(details.path)}
|
Device:{" "}
|
||||||
|
{formatAuditValue(row.device_id)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="break-all">
|
||||||
Tenant: {formatValue(details.tenant_id)}
|
Target: {targetValue}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 break-all">
|
<div className="space-y-1 break-all">
|
||||||
<div>
|
<div className="uppercase tracking-[0.16em]">
|
||||||
Before: {formatValue(details.before)}
|
Result
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
After: {formatValue(details.after)}
|
Error:{" "}
|
||||||
|
{formatAuditValue(details.error)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Error: {formatValue(details.error)}
|
Before:{" "}
|
||||||
|
{formatAuditValue(details.before)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
After: {formatAuditValue(details.after)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -461,7 +466,7 @@ function AuditLogsPage() {
|
|||||||
>
|
>
|
||||||
{query.isFetchingNextPage
|
{query.isFetchingNextPage
|
||||||
? t("msg.common.loading", "Loading...")
|
? t("msg.common.loading", "Loading...")
|
||||||
: t("ui.dev.audit.load_more", "Load more")}
|
: t("ui.common.audit.load_more", "Load more")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user