forked from baron/baron-sso
198 lines
6.8 KiB
TypeScript
198 lines
6.8 KiB
TypeScript
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
|
import * as React from "react";
|
|
import { PageHeader } from "../../../../common/core/components/page";
|
|
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
|
import { Badge } from "../../components/ui/badge";
|
|
import { Button } from "../../components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "../../components/ui/card";
|
|
import { Input } from "../../components/ui/input";
|
|
import type { AuditLog } from "../../lib/adminApi";
|
|
import { fetchAuditLogs } from "../../lib/adminApi";
|
|
import { t } from "../../lib/i18n";
|
|
import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable";
|
|
|
|
function AuditLogsPage() {
|
|
const [searchActorId, setSearchActorId] = React.useState("");
|
|
const [searchAction, setSearchAction] = React.useState("");
|
|
const [statusFilter, setStatusFilter] = React.useState("all");
|
|
const deferredSearchActorId = React.useDeferredValue(searchActorId.trim());
|
|
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
|
|
const {
|
|
data,
|
|
isLoading,
|
|
error,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
isFetching,
|
|
refetch,
|
|
} = useInfiniteQuery({
|
|
queryKey: [
|
|
"audit-logs",
|
|
deferredSearchActorId,
|
|
deferredSearchAction,
|
|
statusFilter,
|
|
],
|
|
queryFn: ({ pageParam }) => {
|
|
const search = [deferredSearchActorId, deferredSearchAction]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
return fetchAuditLogs(
|
|
50,
|
|
pageParam,
|
|
search || undefined,
|
|
statusFilter === "all" ? undefined : statusFilter,
|
|
);
|
|
},
|
|
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)) ?? [],
|
|
) ?? [];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title={t("ui.common.audit.title", "감사 로그")}
|
|
description={t(
|
|
"msg.admin.audit.subtitle",
|
|
"관리자 작업 이력을 조회합니다.",
|
|
)}
|
|
icon={<NotebookTabs size={20} />}
|
|
actions={
|
|
<>
|
|
<Badge variant="muted">
|
|
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
|
count: logs.length,
|
|
})}
|
|
</Badge>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
>
|
|
<RefreshCw size={16} />
|
|
{t("ui.common.refresh", "새로고침")}
|
|
</Button>
|
|
<Button>
|
|
<Download size={16} />
|
|
{t("ui.common.export_csv", "CSV 내보내기")}
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<Card className="glass-panel">
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
|
{t("ui.common.audit.registry.title", "Audit registry")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.admin.audit.registry.description",
|
|
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
</CardHeader>
|
|
{isLoading ? (
|
|
<div className="p-8 text-center" data-testid="audit-loading">
|
|
{t("msg.common.audit.loading", "Loading audit logs...")}
|
|
</div>
|
|
) : error ? (
|
|
<div
|
|
className="p-8 text-center text-red-500"
|
|
data-testid="audit-error"
|
|
>
|
|
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
|
|
error:
|
|
(error as AxiosError<{ error?: string }>).response?.data
|
|
?.error ?? (error as Error).message,
|
|
})}
|
|
</div>
|
|
) : (
|
|
<CardContent className="space-y-4 pt-0">
|
|
<SearchFilterBar
|
|
primary={
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
refetch();
|
|
}}
|
|
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
|
>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
className="pl-10"
|
|
data-testid="audit-search-user-id"
|
|
value={searchActorId}
|
|
onChange={(event) => setSearchActorId(event.target.value)}
|
|
placeholder={t(
|
|
"ui.common.audit.filters.user_id",
|
|
"Filter by User ID",
|
|
)}
|
|
/>
|
|
</div>
|
|
<Input
|
|
data-testid="audit-search-action"
|
|
value={searchAction}
|
|
onChange={(event) =>
|
|
setSearchAction(event.target.value.toUpperCase())
|
|
}
|
|
placeholder={t(
|
|
"ui.common.audit.filters.action",
|
|
"Filter by Action (e.g. ROTATE_SECRET)",
|
|
)}
|
|
/>
|
|
<select
|
|
id="audit-filter-status"
|
|
name="audit-filter-status"
|
|
data-testid="audit-filter-status"
|
|
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
|
value={statusFilter}
|
|
onChange={(event) => setStatusFilter(event.target.value)}
|
|
>
|
|
<option value="all">
|
|
{t("ui.common.audit.filters.status_all", "All Status")}
|
|
</option>
|
|
<option value="success">
|
|
{t("ui.common.status.success", "Success")}
|
|
</option>
|
|
<option value="failure">
|
|
{t("ui.common.status.failure", "Failure")}
|
|
</option>
|
|
</select>
|
|
</form>
|
|
}
|
|
/>
|
|
<VirtualizedAuditLogTable
|
|
logs={logs}
|
|
t={t}
|
|
loading={isLoading}
|
|
hasNextPage={Boolean(hasNextPage)}
|
|
isFetchingNextPage={isFetchingNextPage}
|
|
onLoadMore={() => fetchNextPage()}
|
|
/>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AuditLogsPage;
|