1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/audit/AuditLogsPage.tsx
Lectom C Han 39594f8e21 README update
2026-01-28 17:30:23 +09:00

563 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
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">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">
Error loading logs: {errMsg}
</div>
);
}
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<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)]">
Command ClickHouse .
/ .
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw size={16} />
</Button>
<Button>
<ListChecks size={16} />
Export CSV
</Button>
</div>
</header>
<div className="space-y-4">
<Card className="glass-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>
{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} 필터 제거`}
>
×
</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>
</div>
);
}
export default AuditLogsPage;