1
0
forked from baron/baron-sso

perf(admin): full-stack performance optimization for all list tables

- Implemented server-side search, infinite scrolling, and list virtualization for Tenants, Users, and Audit Logs.
- Backend: Enhanced Repository, Service, and Handler layers to support 'search' and 'cursor' parameters.
- Frontend: Integrated @tanstack/react-virtual and useInfiniteQuery for high-performance rendering.
- Quality: Updated all unit tests and E2E tests to match the new asynchronous server-side search architecture.
- i18n: Synced all translation keys and cleaned up unused resources.
This commit is contained in:
2026-06-04 16:06:30 +09:00
parent 6d3f128282
commit b2f155e35b
26 changed files with 1103 additions and 440 deletions

View File

@@ -2,12 +2,6 @@ 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 {
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
} from "../../../../common/core/audit";
import { AuditLogTable } from "../../../../common/core/components/audit";
import { PageHeader } from "../../../../common/core/components/page";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { Badge } from "../../components/ui/badge";
@@ -23,6 +17,7 @@ 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("");
@@ -40,8 +35,23 @@ function AuditLogsPage() {
isFetching,
refetch,
} = useInfiniteQuery({
queryKey: ["audit-logs"],
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
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,
});
@@ -51,24 +61,6 @@ function AuditLogsPage() {
(page) =>
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
) ?? [];
const filteredLogs = React.useMemo(
() =>
logs.filter((row) => {
const details = parseAuditDetails(row.details);
const actorLabel = resolveAuditActor(row, details).toLowerCase();
const actionLabel = resolveAuditAction(row, details).toLowerCase();
const matchesActor =
deferredSearchActorId === "" ||
actorLabel.includes(deferredSearchActorId.toLowerCase());
const matchesAction =
deferredSearchAction === "" ||
actionLabel.includes(deferredSearchAction.toLowerCase());
const matchesStatus =
statusFilter === "all" || row.status === statusFilter;
return matchesActor && matchesAction && matchesStatus;
}),
[logs, deferredSearchActorId, deferredSearchAction, statusFilter],
);
return (
<div className="space-y-6">
@@ -83,7 +75,7 @@ function AuditLogsPage() {
<>
<Badge variant="muted">
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
count: filteredLogs.length,
count: logs.length,
})}
</Badge>
<Button
@@ -185,8 +177,8 @@ function AuditLogsPage() {
</form>
}
/>
<AuditLogTable
logs={filteredLogs}
<VirtualizedAuditLogTable
logs={logs}
t={t}
loading={isLoading}
hasNextPage={Boolean(hasNextPage)}

View File

@@ -0,0 +1,470 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import * as React from "react";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
resolveAuditTarget,
} from "../../../../common/core/audit";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
import { getCommonButtonClasses } from "../../../../common/ui/button";
import {
commonStickyTableHeaderClass,
commonTableBodyClass,
commonTableCellClass,
commonTableClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableShellClass,
commonTableViewportClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { Button } from "../../components/ui/button";
import type { AuditLog } from "../../lib/adminApi";
type AuditTranslate = (
key: string,
fallback: string,
vars?: Record<string, string | number>,
) => string;
type VirtualizedAuditLogTableProps = {
logs: AuditLog[];
t: AuditTranslate;
loading: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
className?: string;
};
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(" ");
}
function statusVariant(status: string): CommonBadgeVariant {
return status === "success" || status === "ok" ? "success" : "warning";
}
export function VirtualizedAuditLogTable({
logs,
t,
loading,
hasNextPage,
isFetchingNextPage,
onLoadMore,
className,
}: VirtualizedAuditLogTableProps) {
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
const viewportRef = React.useRef<HTMLDivElement>(null);
const isTest =
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
(typeof window !== "undefined" && (window as any)._IS_TEST_MODE);
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
const rowVirtualizer = useVirtualizer({
count: logs.length,
getScrollElement: () => viewportRef.current,
estimateSize: () => 80,
measureElement: (el) => el.getBoundingClientRect().height,
overscan: isTest ? logs.length : 10,
initialRect: isTest ? { width: 1010, height: 1000 } : undefined,
});
const virtualRows = rowVirtualizer.getVirtualItems();
React.useEffect(() => {
if (isTest) {
return;
}
const lastItem = virtualRows[virtualRows.length - 1];
if (!lastItem) return;
if (
lastItem.index >= logs.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
onLoadMore();
}
}, [
virtualRows,
logs.length,
hasNextPage,
isFetchingNextPage,
onLoadMore,
isTest,
]);
const tableMinWidth = 1010;
const renderRow = (row: AuditLog, index: number, virtualRow?: any) => {
if (!row) return null;
const details = parseAuditDetails(row.details);
const actorLabel = resolveAuditActor(row, details);
const actionLabel = resolveAuditAction(row, details);
const targetLabel = resolveAuditTarget(details);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
const { date, time } = formatAuditDateParts(row.timestamp);
return (
<tr
key={rowKey}
data-index={index}
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
className={cx(
commonTableRowClass,
"bg-card/40",
virtualRow ? "absolute left-0 w-full" : "",
)}
style={
virtualRow
? {
transform: `translateY(${virtualRow.start}px)`,
}
: undefined
}
>
<td colSpan={6} className="p-0">
<div className={cx("flex items-center", expanded && "border-b")}>
<div
className={cx(
commonTableCellClass,
"w-[190px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
</div>
<div className={cx(commonTableCellClass, "w-[180px] shrink-0")}>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{actorLabel}
</code>
{actorLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.actor_id",
"Copy User ID",
)}
onClick={() => handleCopy(actorLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</div>
<div
className={cx(
commonTableCellClass,
"w-[180px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="font-semibold text-foreground">{actionLabel}</div>
</div>
<div
className={cx(
commonTableCellClass,
"w-[260px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="flex items-center gap-2">
<span className="break-all">{targetLabel}</span>
{targetLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.target",
"Copy Client ID",
)}
onClick={() => handleCopy(targetLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</div>
<div className={cx(commonTableCellClass, "w-[120px] shrink-0")}>
<span
className={getCommonBadgeClasses({
variant: statusVariant(row.status),
})}
>
{row.status}
</span>
</div>
<div
className={cx(
commonTableCellClass,
"w-[80px] shrink-0 text-right",
)}
>
<button
type="button"
className={getCommonButtonClasses({
variant: "ghost",
size: "sm",
})}
onClick={() => {
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}));
// Re-measure after state change
setTimeout(() => rowVirtualizer.measure(), 0);
}}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
</div>
{expanded && (
<div className={cx(commonTableCellClass, "bg-card/20 text-xs")}>
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.request", "Request")}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.request_id",
"Request ID · {{value}}",
{ value: formatAuditValue(details.request_id) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.event_id",
"Event ID · {{value}}",
{ value: formatAuditValue(row.event_id) },
)}
</div>
<div>
{t("ui.common.audit.details.ip", "IP · {{value}}", {
value: formatAuditValue(row.ip_address),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.method", "Method · {{value}}", {
value: formatAuditValue(details.method),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.path", "Path · {{value}}", {
value: formatAuditValue(details.path),
})}
</div>
<div>
{t(
"ui.common.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.common.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.common.audit.details.actor_id",
"User ID · {{value}}",
{ value: actorLabel },
)}
</div>
<div>
{t("ui.common.audit.details.tenant", "Tenant · {{value}}", {
value: formatAuditValue(details.tenant_id),
})}
</div>
<div>
{t("ui.common.audit.details.device", "Device · {{value}}", {
value: formatAuditValue(row.device_id),
})}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.target",
"Client ID · {{value}}",
{ value: targetLabel },
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.result", "Result")}
</div>
<div className="break-all">
{t("ui.common.audit.details.error", "Error · {{value}}", {
value: formatAuditValue(details.error),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.before", "Before · {{value}}", {
value: formatAuditValue(details.before),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.after", "After · {{value}}", {
value: formatAuditValue(details.after),
})}
</div>
</div>
</div>
</div>
)}
</td>
</tr>
);
};
return (
<div className={cx(commonTableShellClass, className)}>
<div
ref={viewportRef}
className={cx(commonTableViewportClass, "flex-1")}
data-testid="audit-table-viewport"
>
<div
className={commonTableWrapperClass}
style={{ minWidth: tableMinWidth }}
>
<table
className={cx(commonTableClass, "table-fixed w-full")}
style={{ borderCollapse: "separate", borderSpacing: 0 }}
>
<thead
className={cx(
commonTableHeaderClass,
commonStickyTableHeaderClass,
)}
>
<tr className={commonTableRowClass}>
<th className={cx(commonTableHeadClass, "w-[190px]")}>
{t("ui.common.audit.table.time", "Time")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.user_id", "User ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.action", "Action")}
</th>
<th className={cx(commonTableHeadClass, "w-[260px]")}>
{t("ui.common.audit.table.client_id", "Client ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[120px]")}>
{t("ui.common.audit.table.status", "Status")}
</th>
<th className={cx(commonTableHeadClass, "w-[80px]")} />
</tr>
</thead>
<tbody
className={commonTableBodyClass}
style={
!isTest
? {
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}
: undefined
}
>
{isTest
? logs.map((row, index) => renderRow(row, index))
: virtualRows.map((virtualRow) =>
renderRow(
logs[virtualRow.index],
virtualRow.index,
virtualRow,
),
)}
{logs.length === 0 && !loading && (
<tr>
<td
colSpan={6}
className={cx(
commonTableCellClass,
"text-center py-8 text-muted-foreground",
)}
>
{t("ui.common.audit.table.no_logs", "No audit logs found")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div className="flex-shrink-0 border-t bg-background/50 p-4 text-center backdrop-blur-sm">
{hasNextPage ? (
<div className="flex flex-col items-center gap-2">
{isFetchingNextPage && (
<span className="animate-pulse text-xs text-muted-foreground">
{t("msg.common.loading", "Loading more...")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={onLoadMore}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.common.audit.load_more", "더 보기")}
</Button>
</div>
) : logs.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t("msg.common.audit.end", "End of audit feed")}
</span>
) : null}
</div>
</div>
);
}