forked from baron/baron-sso
- Fix missing position and jobTitle properties in BulkUserItem interface - Fix incorrect reference to undefined total property in AuditLogsPage (reverted to logs.length)
607 lines
24 KiB
TypeScript
607 lines
24 KiB
TypeScript
import { useInfiniteQuery } from "@tanstack/react-query";
|
||
import type { AxiosError } from "axios";
|
||
import {
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Copy,
|
||
ListChecks,
|
||
RefreshCw,
|
||
Search,
|
||
Terminal,
|
||
} from "lucide-react";
|
||
import * as React from "react";
|
||
import { Badge } from "../../components/ui/badge";
|
||
import { Button } from "../../components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "../../components/ui/card";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "../../components/ui/table";
|
||
import type { AuditLog } from "../../lib/adminApi";
|
||
import { fetchAuditLogs } from "../../lib/adminApi";
|
||
import { t } from "../../lib/i18n";
|
||
|
||
const defaultAuditFilters = [
|
||
"method:POST path:/api/v1/*",
|
||
"status:failure",
|
||
"latency_ms:>1000",
|
||
];
|
||
|
||
type AuditDetails = {
|
||
request_id?: string;
|
||
method?: string;
|
||
path?: string;
|
||
status?: number;
|
||
latency_ms?: number;
|
||
error?: string;
|
||
tenant_id?: string;
|
||
actor_id?: string;
|
||
action?: string;
|
||
target?: string;
|
||
before?: unknown;
|
||
after?: unknown;
|
||
};
|
||
|
||
function parseDetails(details?: string): AuditDetails {
|
||
if (!details) {
|
||
return {};
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(details);
|
||
if (parsed && typeof parsed === "object") {
|
||
return parsed as AuditDetails;
|
||
}
|
||
} catch {}
|
||
return {};
|
||
}
|
||
|
||
function formatCellValue(value: unknown) {
|
||
if (value === null || value === undefined || value === "") {
|
||
return "-";
|
||
}
|
||
if (typeof value === "string") {
|
||
return value;
|
||
}
|
||
try {
|
||
return JSON.stringify(value);
|
||
} catch {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
function formatIsoDateTime(value: string) {
|
||
if (!value) {
|
||
return { date: "-", time: "-" };
|
||
}
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) {
|
||
return { date: value, time: "-" };
|
||
}
|
||
const date = parsed.toISOString().slice(0, 10);
|
||
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
|
||
return { date, time };
|
||
}
|
||
|
||
function AuditLogsPage() {
|
||
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
||
const [filterDraft, setFilterDraft] = React.useState("");
|
||
const [expandedRows, setExpandedRows] = React.useState<
|
||
Record<string, boolean>
|
||
>({});
|
||
|
||
const handleCopy = (value: string) => {
|
||
if (!value) {
|
||
return;
|
||
}
|
||
navigator.clipboard.writeText(value);
|
||
};
|
||
const {
|
||
data,
|
||
isLoading,
|
||
error,
|
||
fetchNextPage,
|
||
hasNextPage,
|
||
isFetchingNextPage,
|
||
isFetching,
|
||
refetch,
|
||
} = useInfiniteQuery({
|
||
queryKey: ["audit-logs"],
|
||
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
|
||
initialPageParam: undefined as string | undefined,
|
||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||
});
|
||
|
||
const logs =
|
||
data?.pages?.flatMap(
|
||
(page) =>
|
||
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
||
) ?? [];
|
||
|
||
const handleAddFilter = () => {
|
||
const trimmed = filterDraft.trim();
|
||
if (!trimmed) {
|
||
return;
|
||
}
|
||
setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed]));
|
||
setFilterDraft("");
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="p-8 text-center">
|
||
{t("msg.admin.audit.loading", "Loading audit logs...")}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
const errMsg =
|
||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||
(error as Error).message;
|
||
return (
|
||
<div className="p-8 text-center text-red-500">
|
||
{t("msg.admin.audit.load_error", "Error loading logs: {{error}}", {
|
||
error: errMsg,
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||
<div>
|
||
<h2 className="text-3xl font-semibold">
|
||
{t("ui.admin.audit.title", "감사 로그")}
|
||
</h2>
|
||
<p className="text-sm text-[var(--color-muted)]">
|
||
{t(
|
||
"msg.admin.audit.subtitle",
|
||
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => refetch()}
|
||
disabled={isFetching}
|
||
>
|
||
<RefreshCw size={16} />
|
||
{t("ui.common.refresh", "새로고침")}
|
||
</Button>
|
||
<Button>
|
||
<ListChecks size={16} />
|
||
{t("ui.admin.audit.export_csv", "Export CSV")}
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
|
||
<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">
|
||
<div>
|
||
<CardTitle>
|
||
{t("ui.admin.audit.registry.title", "Log 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">
|
||
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||
<Search size={14} />
|
||
<input
|
||
value={filterDraft}
|
||
onChange={(event) => setFilterDraft(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") {
|
||
handleAddFilter();
|
||
}
|
||
}}
|
||
placeholder={t(
|
||
"ui.admin.audit.filters.placeholder",
|
||
"필터 추가 (예: status:failure)",
|
||
)}
|
||
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||
/>
|
||
<Button size="sm" variant="outline" onClick={handleAddFilter}>
|
||
{t("ui.common.add", "추가")}
|
||
</Button>
|
||
</div>
|
||
{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>
|
||
))
|
||
)}
|
||
</div>
|
||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||
<Table className="table-fixed">
|
||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||
<TableRow>
|
||
<TableHead className="w-[140px]">
|
||
{t("ui.admin.audit.table.time", "TIME")}
|
||
</TableHead>
|
||
<TableHead className="w-[160px]">
|
||
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
|
||
</TableHead>
|
||
<TableHead>
|
||
{t("ui.admin.audit.table.request", "REQUEST")}
|
||
</TableHead>
|
||
<TableHead>
|
||
{t("ui.admin.audit.table.path", "PATH")}
|
||
</TableHead>
|
||
<TableHead className="w-[120px]">
|
||
{t("ui.admin.audit.table.status", "STATUS")}
|
||
</TableHead>
|
||
<TableHead>
|
||
{t(
|
||
"ui.admin.audit.table.action_target",
|
||
"Action / Target",
|
||
)}
|
||
</TableHead>
|
||
<TableHead className="w-[80px]" />
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{isLoading && (
|
||
<TableRow>
|
||
<TableCell colSpan={7}>
|
||
{t("msg.common.loading", "로딩 중...")}
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
{!isLoading && logs.length === 0 && (
|
||
<TableRow>
|
||
<TableCell colSpan={7}>
|
||
{t(
|
||
"msg.admin.audit.empty",
|
||
"아직 수집된 감사 로그가 없습니다.",
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
{logs.map((row, index) => {
|
||
const details = parseDetails(row.details);
|
||
const actionLabel =
|
||
details.action ||
|
||
(details.method && details.path
|
||
? `${details.method} ${details.path}`
|
||
: row.event_type);
|
||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||
const isExpanded = Boolean(expandedRows[rowKey]);
|
||
return (
|
||
<React.Fragment key={rowKey}>
|
||
<TableRow className="bg-card/40">
|
||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||
{(() => {
|
||
const { date, time } = formatIsoDateTime(
|
||
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">
|
||
{row.user_id || details.actor_id || "-"}
|
||
</code>
|
||
{(row.user_id || details.actor_id) && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||
aria-label={t(
|
||
"ui.admin.audit.copy.actor_id",
|
||
"Copy actor id",
|
||
)}
|
||
onClick={() =>
|
||
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" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||
<div className="font-semibold text-foreground">
|
||
{formatCellValue(details.method)}
|
||
</div>
|
||
<div className="break-all">
|
||
{formatCellValue(details.path)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge
|
||
variant={
|
||
row.status === "success" || row.status === "ok"
|
||
? "success"
|
||
: "warning"
|
||
}
|
||
>
|
||
{row.status}
|
||
</Badge>
|
||
</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">
|
||
<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={7} 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.admin.audit.details.request",
|
||
"Request",
|
||
)}
|
||
</div>
|
||
<div className="break-all">
|
||
{t(
|
||
"ui.admin.audit.details.request_id",
|
||
"Request ID · {{value}}",
|
||
{
|
||
value: formatCellValue(
|
||
details.request_id,
|
||
),
|
||
},
|
||
)}
|
||
</div>
|
||
<div className="break-all">
|
||
{t(
|
||
"ui.admin.audit.details.event_id",
|
||
"Event ID · {{value}}",
|
||
{
|
||
value: formatCellValue(row.event_id),
|
||
},
|
||
)}
|
||
</div>
|
||
<div>
|
||
{t(
|
||
"ui.admin.audit.details.ip",
|
||
"IP · {{value}}",
|
||
{
|
||
value: formatCellValue(row.ip_address),
|
||
},
|
||
)}
|
||
</div>
|
||
<div>
|
||
{t(
|
||
"ui.admin.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.admin.audit.details.actor", "Actor")}
|
||
</div>
|
||
<div>
|
||
{t(
|
||
"ui.admin.audit.details.actor_id",
|
||
"Actor ID · {{value}}",
|
||
{
|
||
value:
|
||
row.user_id ||
|
||
details.actor_id ||
|
||
"-",
|
||
},
|
||
)}
|
||
</div>
|
||
<div>
|
||
{t(
|
||
"ui.admin.audit.details.tenant",
|
||
"Tenant · {{value}}",
|
||
{
|
||
value: formatCellValue(
|
||
details.tenant_id,
|
||
),
|
||
},
|
||
)}
|
||
</div>
|
||
<div>
|
||
{t(
|
||
"ui.admin.audit.details.device",
|
||
"Device · {{value}}",
|
||
{
|
||
value: formatCellValue(row.device_id),
|
||
},
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<div className="uppercase tracking-[0.16em]">
|
||
{t(
|
||
"ui.admin.audit.details.result",
|
||
"Result",
|
||
)}
|
||
</div>
|
||
<div className="break-all">
|
||
{t(
|
||
"ui.admin.audit.details.error",
|
||
"Error · {{value}}",
|
||
{
|
||
value: formatCellValue(details.error),
|
||
},
|
||
)}
|
||
</div>
|
||
<div className="break-all">
|
||
{t(
|
||
"ui.admin.audit.details.before",
|
||
"Before · {{value}}",
|
||
{
|
||
value: formatCellValue(details.before),
|
||
},
|
||
)}
|
||
</div>
|
||
<div className="break-all">
|
||
{t(
|
||
"ui.admin.audit.details.after",
|
||
"After · {{value}}",
|
||
{
|
||
value: formatCellValue(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.admin.audit.load_more", "Load more")}
|
||
</Button>
|
||
) : (
|
||
<span className="text-xs text-[var(--color-muted)]">
|
||
{t("msg.admin.audit.end", "End of audit feed")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default AuditLogsPage;
|