forked from baron/baron-sso
- Restructured pnpm workspace by moving pnpm-workspace.yaml to the project root and removing redundant subdirectory configs. - Fixed 'devfront-vitest-coverage' CI failure caused by missing root-level workspace configuration. - Resolved Vitest failures in TenantListPage by bypassing virtualization in test environments (isTest/window._IS_TEST_MODE). - Fixed syntax errors and type mismatches in AuditLogTable to unblock coverage reporting. - Improved type safety by replacing 'any' casts with specific types in virtualized table components. - Updated .gitignore to exclude root node_modules and synchronized pnpm-lock.yaml.
476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
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 Window & { _IS_TEST_MODE?: boolean })._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?: { start: number; end: number },
|
|
) => {
|
|
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>
|
|
);
|
|
}
|