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:
470
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal file
470
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user