forked from baron/baron-sso
adminFront에 Audit Log 기능 추가
This commit is contained in:
@@ -1,21 +1,138 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Filter, ListChecks, Search, Terminal } from "lucide-react";
|
||||
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";
|
||||
|
||||
const auditFilters = [
|
||||
"Actor role = admin",
|
||||
"Action = client.rotate_secret",
|
||||
"Tenant = selected header",
|
||||
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 { data, isLoading, error } = useQuery({
|
||||
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: () => fetchAuditLogs(),
|
||||
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
|
||||
const logs = data?.items || [];
|
||||
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">Loading audit logs...</div>;
|
||||
@@ -34,121 +151,307 @@ function AuditLogsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Audit stream
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Observe admin actions per tenant
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>Audit</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Logs</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">감사 로그</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
ClickHouse-backed feed. Filter by tenant, actor, action, and
|
||||
rate-limit status. Enforce admin-only access under /admin.
|
||||
Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는
|
||||
추후 세션 연동 시 자동 채워집니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]"
|
||||
>
|
||||
<Filter size={14} />
|
||||
Saved filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
||||
>
|
||||
<ListChecks size={14} />
|
||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={16} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button>
|
||||
<ListChecks size={16} />
|
||||
Export CSV
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1.1fr,0.9fr]">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-[var(--color-muted)]">
|
||||
<Search size={14} />
|
||||
<span className="text-sm">
|
||||
Try: tenant:TENANT-12 action:client.*
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{auditFilters.map((filter) => (
|
||||
<span
|
||||
key={filter}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
{filter}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 divide-y divide-[var(--color-border)]">
|
||||
{logs.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-[var(--color-muted)]">
|
||||
No audit logs found.
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Audit registry</CardTitle>
|
||||
<CardDescription>로드된 로그 {logs.length}건</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">Command only</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<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="필터 추가 (예: status:failure)"
|
||||
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddFilter}>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
logs.map((row) => (
|
||||
<div
|
||||
key={`${row.event_type}-${row.user_id}-${row.timestamp}`}
|
||||
className="grid grid-cols-[1.2fr,1fr,1fr,1fr] items-center gap-2 py-3 text-sm"
|
||||
>
|
||||
<div className="font-semibold">{row.event_type}</div>
|
||||
<div className="text-[var(--color-muted)]">
|
||||
{/* Tenant info not yet in basic schema, show generic or details snippet */}
|
||||
Tenant-?
|
||||
</div>
|
||||
<div className="text-[var(--color-muted)] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{row.user_id}
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-xs ${
|
||||
row.status === "success" || row.status === "ok"
|
||||
? "bg-[rgba(54,211,153,0.16)] text-[var(--color-accent)]"
|
||||
: "bg-[rgba(249,168,38,0.16)] text-[var(--color-accent-strong)]"
|
||||
}`}
|
||||
{filters.length === 0 ? (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
필터 없음
|
||||
</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={`${filter} 필터 제거`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
<span className="text-[var(--color-muted)] text-xs">
|
||||
{new Date(row.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[140px]">TIME</TableHead>
|
||||
<TableHead className="w-[160px]">ACTOR (ID)</TableHead>
|
||||
<TableHead>REQUEST</TableHead>
|
||||
<TableHead>PATH</TableHead>
|
||||
<TableHead className="w-[120px]">STATUS</TableHead>
|
||||
<TableHead>Action / Target</TableHead>
|
||||
<TableHead className="w-[80px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>로딩 중...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && logs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
아직 수집된 감사 로그가 없습니다.
|
||||
</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="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="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">
|
||||
Target · {details.target}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label="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]">
|
||||
Request
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Request ID · {formatCellValue(details.request_id)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Event ID · {formatCellValue(row.event_id)}
|
||||
</div>
|
||||
<div>IP · {formatCellValue(row.ip_address)}</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 · {row.user_id || details.actor_id || "-"}
|
||||
</div>
|
||||
<div>Tenant · {formatCellValue(details.tenant_id)}</div>
|
||||
<div>Device · {formatCellValue(row.device_id)}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
Result
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Error · {formatCellValue(details.error)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Before · {formatCellValue(details.before)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
After · {formatCellValue(details.after)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="pt-4 text-center">
|
||||
{hasNextPage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage ? "Loading..." : "Load more"}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
End of audit feed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Guard rails
|
||||
</p>
|
||||
<h3 className="mt-1 text-lg font-semibold">Tenant admin only</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Enforce Tenant Admin middleware and admin session TTL before
|
||||
surfacing any audit feed. Super Admin role can bypass tenant
|
||||
filter when needed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Export rules
|
||||
</p>
|
||||
<h3 className="mt-1 text-lg font-semibold">
|
||||
Rate-limit sensitive exports
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Keep export endpoints behind admin-only routes with ClickHouse
|
||||
query limits. Log download attempts with IP, role, and tenant
|
||||
scope.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import apiClient from "./apiClient";
|
||||
|
||||
export type AuditLog = {
|
||||
event_id: string;
|
||||
timestamp: string;
|
||||
user_id: string;
|
||||
event_type: string;
|
||||
@@ -14,7 +15,8 @@ export type AuditLog = {
|
||||
export type AuditLogListResponse = {
|
||||
items: AuditLog[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
cursor?: string;
|
||||
next_cursor?: string;
|
||||
};
|
||||
|
||||
export type TenantSummary = {
|
||||
@@ -48,9 +50,9 @@ export type TenantUpdateRequest = {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export async function fetchAuditLogs(limit = 50, offset = 0) {
|
||||
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||
params: { limit, offset },
|
||||
params: { limit, cursor },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user