1
0
forked from baron/baron-sso

Resolve merge conflicts with main

This commit is contained in:
2026-01-29 16:45:40 +09:00
69 changed files with 6049 additions and 1379 deletions

View File

@@ -1,476 +1,562 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronDown,
ChevronUp,
Copy,
ListChecks,
RefreshCw,
Search,
Terminal,
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,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
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",
"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;
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;
if (!details) {
return {};
}
} catch {}
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);
}
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 };
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 [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;
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>;
}
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;
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>
);
}
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="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>
{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} 필터 제거`}
<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}
>
×
</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]" />
</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>
<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>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !isExpanded,
}))
}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
{filters.length === 0 ? (
<span className="text-xs text-[var(--color-muted)]">
</span>
) : (
<ChevronDown className="h-4 w-4" />
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>
))
)}
</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>
<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>
</CardContent>
</Card>
</div>
</div>
);
</div>
);
}
export default AuditLogsPage;
export default AuditLogsPage;