1
0
forked from baron/baron-sso

feat: apply sticky header and inner scroll to audit logs page

This commit is contained in:
2026-03-19 13:15:50 +09:00
parent f072d37362
commit 926c26b1ad

View File

@@ -158,8 +158,8 @@ function AuditLogsPage() {
} }
return ( return (
<div className="space-y-8"> <div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4"> <header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0">
<div> <div>
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]"> <div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>{t("ui.admin.audit.breadcrumb.section", "Audit")}</span> <span>{t("ui.admin.audit.breadcrumb.section", "Audit")}</span>
@@ -194,409 +194,421 @@ function AuditLogsPage() {
</div> </div>
</header> </header>
<div className="space-y-4"> <Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden">
<Card className="glass-panel"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<CardHeader className="flex flex-row items-center justify-between"> <div>
<div> <CardTitle>
<CardTitle> {t("ui.admin.audit.registry.title", "Audit registry")}
{t("ui.admin.audit.registry.title", "Audit registry")} </CardTitle>
</CardTitle> <CardDescription>
<CardDescription> {t("msg.admin.audit.registry.count", "로드된 로그 {{count}}건", {
{t( count: logs.length,
"msg.admin.audit.registry.count", })}
"로드된 로그 {{count}}건", </CardDescription>
{ count: logs.length }, </div>
<Badge variant="muted">
{t("ui.common.badge.command_only", "Command only")}
</Badge>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
<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={t(
"ui.admin.audit.filters.placeholder",
"필터 추가 (예: status:failure)",
)} )}
</CardDescription> className="w-full bg-transparent text-sm text-foreground outline-none"
/>
<Button size="sm" variant="outline" onClick={handleAddFilter}>
{t("ui.common.add", "추가")}
</Button>
</div> </div>
<Badge variant="muted"> {filters.length === 0 ? (
{t("ui.common.badge.command_only", "Command only")} <span className="text-xs text-[var(--color-muted)]">
</Badge> {t("msg.admin.audit.filters.empty", "필터 없음")}
</CardHeader> </span>
<CardContent> ) : (
<div className="mb-4 flex flex-wrap items-center gap-2"> filters.map((filter) => (
<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)]"> <span
<Search size={14} /> key={filter}
<input 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)]"
value={filterDraft} >
onChange={(event) => setFilterDraft(event.target.value)} <Terminal size={12} />
onKeyDown={(event) => { {filter}
if (event.key === "Enter") { <button
handleAddFilter(); 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)]"
placeholder={t( aria-label={t(
"ui.admin.audit.filters.placeholder", "ui.admin.audit.filters.remove",
"필터 추가 (예: status:failure)", "{{filter}} 필터 제거",
)} { filter },
className="w-full bg-transparent text-sm text-foreground outline-none" )}
/>
<Button size="sm" variant="outline" onClick={handleAddFilter}>
{t("ui.common.add", "추가")}
</Button>
</div>
{filters.length === 0 ? (
<span className="text-xs text-[var(--color-muted)]">
{t("msg.admin.audit.filters.empty", "필터 없음")}
</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>
<button </span>
type="button" ))
onClick={() => )}
setFilters((prev) => </div>
prev.filter((item) => item !== filter), <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
) <div className="flex-1 overflow-auto relative custom-scrollbar">
} <Table className="table-fixed">
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]" <TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
aria-label={t(
"ui.admin.audit.filters.remove",
"{{filter}} 필터 제거",
{ filter },
)}
>
×
</button>
</span>
))
)}
</div>
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">
{t("ui.admin.audit.table.time", "TIME")}
</TableHead>
<TableHead className="w-[160px]">
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.request", "REQUEST")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.path", "PATH")}
</TableHead>
<TableHead className="w-[120px]">
{t("ui.admin.audit.table.status", "STATUS")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.action_target", "Action / Target")}
</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow> <TableRow>
<TableCell colSpan={7}> <TableHead className="w-[140px]">
{t("msg.common.loading", "로딩 중...")} {t("ui.admin.audit.table.time", "TIME")}
</TableCell> </TableHead>
</TableRow> <TableHead className="w-[160px]">
)} {t("ui.admin.audit.table.actor", "ACTOR (ID)")}
{!isLoading && logs.length === 0 && ( </TableHead>
<TableRow> <TableHead>
<TableCell colSpan={7}> {t("ui.admin.audit.table.request", "REQUEST")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.path", "PATH")}
</TableHead>
<TableHead className="w-[120px]">
{t("ui.admin.audit.table.status", "STATUS")}
</TableHead>
<TableHead>
{t( {t(
"msg.admin.audit.empty", "ui.admin.audit.table.action_target",
"아직 수집된 감사 로그가 없습니다.", "Action / Target",
)} )}
</TableCell> </TableHead>
<TableHead className="w-[80px]" />
</TableRow> </TableRow>
)} </TableHeader>
{logs.map((row, index) => { <TableBody>
const details = parseDetails(row.details); {isLoading && (
const actionLabel = <TableRow>
details.action || <TableCell colSpan={7}>
(details.method && details.path {t("msg.common.loading", "로딩 중...")}
? `${details.method} ${details.path}` </TableCell>
: row.event_type); </TableRow>
const rowKey = `${row.event_id}-${row.timestamp}-${index}`; )}
const isExpanded = Boolean(expandedRows[rowKey]); {!isLoading && logs.length === 0 && (
return ( <TableRow>
<React.Fragment key={rowKey}> <TableCell colSpan={7}>
<TableRow className="bg-card/40"> {t(
<TableCell className="text-xs text-[var(--color-muted)]"> "msg.admin.audit.empty",
{(() => { "아직 수집된 감사 로그가 없습니다.",
const { date, time } = formatIsoDateTime( )}
row.timestamp, </TableCell>
); </TableRow>
return ( )}
<div className="space-y-1"> {logs.map((row, index) => {
<div>{date}</div> const details = parseDetails(row.details);
<div>{time}</div> const actionLabel =
</div> details.action ||
); (details.method && details.path
})()} ? `${details.method} ${details.path}`
</TableCell> : row.event_type);
<TableCell> const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
<div className="flex items-center gap-2"> const isExpanded = Boolean(expandedRows[rowKey]);
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground"> return (
{row.user_id || details.actor_id || "-"} <React.Fragment key={rowKey}>
</code> <TableRow className="bg-card/40">
{(row.user_id || details.actor_id) && ( <TableCell className="text-xs text-[var(--color-muted)]">
<Button {(() => {
variant="ghost" const { date, time } = formatIsoDateTime(
size="icon" row.timestamp,
className="h-7 w-7 text-muted-foreground hover:text-primary" );
aria-label={t( return (
"ui.admin.audit.copy.actor_id", <div className="space-y-1">
"Copy actor id", <div>{date}</div>
)} <div>{time}</div>
onClick={() => </div>
handleCopy( );
row.user_id || details.actor_id || "", })()}
) </TableCell>
} <TableCell>
>
<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={t(
"ui.admin.audit.copy.request_id",
"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"> <div className="flex items-center gap-2">
<span className="break-all"> <code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{t( {row.user_id || details.actor_id || "-"}
"ui.admin.audit.target", </code>
"Target · {{target}}", {(row.user_id || details.actor_id) && (
{ <Button
target: details.target, variant="ghost"
}, size="icon"
)} className="h-7 w-7 text-muted-foreground hover:text-primary"
</span> aria-label={t(
<Button "ui.admin.audit.copy.actor_id",
variant="ghost" "Copy actor id",
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.target",
"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]">
{t(
"ui.admin.audit.details.request",
"Request",
)} )}
</div> onClick={() =>
<div className="break-all"> handleCopy(
{t( row.user_id || details.actor_id || "",
"ui.admin.audit.details.request_id", )
"Request ID · {{value}}", }
{ >
value: formatCellValue( <Copy className="h-3 w-3" />
details.request_id, </Button>
), )}
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.event_id",
"Event ID · {{value}}",
{
value: formatCellValue(row.event_id),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.ip",
"IP · {{value}}",
{
value: formatCellValue(row.ip_address),
},
)}
</div>
<div>
{t(
"ui.admin.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.admin.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.admin.audit.details.actor_id",
"Actor ID · {{value}}",
{
value:
row.user_id || details.actor_id || "-",
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.tenant",
"Tenant · {{value}}",
{
value: formatCellValue(details.tenant_id),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.device",
"Device · {{value}}",
{
value: formatCellValue(row.device_id),
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.admin.audit.details.result", "Result")}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.error",
"Error · {{value}}",
{
value: formatCellValue(details.error),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.before",
"Before · {{value}}",
{
value: formatCellValue(details.before),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.after",
"After · {{value}}",
{
value: formatCellValue(details.after),
},
)}
</div>
</div>
</div> </div>
</TableCell> </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={t(
"ui.admin.audit.copy.request_id",
"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">
{t(
"ui.admin.audit.target",
"Target · {{target}}",
{
target: details.target,
},
)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.target",
"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> </TableRow>
)} {isExpanded && (
</React.Fragment> <TableRow className="bg-card/20">
); <TableCell colSpan={7} className="text-xs">
})} <div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
</TableBody> <div className="space-y-1">
</Table> <div className="uppercase tracking-[0.16em]">
<div className="pt-4 text-center"> {t(
{hasNextPage ? ( "ui.admin.audit.details.request",
<Button "Request",
variant="outline" )}
onClick={() => fetchNextPage()} </div>
disabled={isFetchingNextPage} <div className="break-all">
> {t(
{isFetchingNextPage "ui.admin.audit.details.request_id",
? t("msg.common.loading", "Loading...") "Request ID · {{value}}",
: t("ui.admin.audit.load_more", "Load more")} {
</Button> value: formatCellValue(
) : ( details.request_id,
<span className="text-xs text-[var(--color-muted)]"> ),
{t("msg.admin.audit.end", "End of audit feed")} },
</span> )}
)} </div>
<div className="break-all">
{t(
"ui.admin.audit.details.event_id",
"Event ID · {{value}}",
{
value: formatCellValue(row.event_id),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.ip",
"IP · {{value}}",
{
value: formatCellValue(row.ip_address),
},
)}
</div>
<div>
{t(
"ui.admin.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.admin.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.admin.audit.details.actor_id",
"Actor ID · {{value}}",
{
value:
row.user_id ||
details.actor_id ||
"-",
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.tenant",
"Tenant · {{value}}",
{
value: formatCellValue(
details.tenant_id,
),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.device",
"Device · {{value}}",
{
value: formatCellValue(row.device_id),
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t(
"ui.admin.audit.details.result",
"Result",
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.error",
"Error · {{value}}",
{
value: formatCellValue(details.error),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.before",
"Before · {{value}}",
{
value: formatCellValue(details.before),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.after",
"After · {{value}}",
{
value: formatCellValue(details.after),
},
)}
</div>
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div> </div>
</CardContent> </div>
</Card> <div className="pt-4 text-center flex-shrink-0">
</div> {hasNextPage ? (
<Button
variant="outline"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.admin.audit.load_more", "Load more")}
</Button>
) : (
<span className="text-xs text-[var(--color-muted)]">
{t("msg.admin.audit.end", "End of audit feed")}
</span>
)}
</div>
</CardContent>
</Card>
</div> </div>
); );
} }