feat : copy buzzy audit logs
CI / Verify (push) Successful in 2m2s
CI / Build immutable images and deploy (push) Successful in 2m47s

This commit is contained in:
2026-09-27 17:00:25 +07:00 Unverified
parent 8ddb5896b6
commit 188cec9bda
10 changed files with 152 additions and 353 deletions
+37 -145
View File
@@ -1,65 +1,20 @@
import { ClipboardListIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { ClipboardListIcon } from "lucide-react";
import { AdminHeader } from "@/components/admin/admin-header";
import { AuditLogFilters } from "@/components/admin/audit-log-filters";
import { Badge } from "@/components/ui/badge";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AUDIT_ACTION_LABELS,
AUDIT_TARGET_LABELS,
AUDIT_TARGET_TYPES,
isAuditTargetType,
listAuditActors,
listAuditLogs,
type AuditAction,
} from "@/lib/audit-log";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { listAuditActors, listAuditLogs } from "@/lib/audit-log";
import { getAdminSession } from "@/lib/auth/server";
function pageHref(
page: number,
actorId?: string,
targetType?: string,
) {
function pageHref(page: number, actorId?: string) {
const query = new URLSearchParams({ page: String(page) });
if (actorId) query.set("actor", actorId);
if (targetType) query.set("type", targetType);
return `/admin/activity?${query}`;
}
function eventSummary(metadata: Record<string, string | number | boolean | null>) {
const label = metadata.sectionTitle
?? metadata.guideName
?? metadata.alias
?? metadata.fileName
?? metadata.accountLabel;
if (typeof label === "string") return label;
if (typeof metadata.error === "string") return metadata.error;
return null;
}
export default async function AuditLogPage({
searchParams,
}: {
@@ -67,22 +22,15 @@ export default async function AuditLogPage({
}) {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const query = await searchParams;
const rawPage = Array.isArray(query.page) ? query.page[0] : query.page;
const parsedPage = Number(rawPage);
const page = Number.isSafeInteger(parsedPage) && parsedPage > 0
? parsedPage
: 1;
const page = Number.isSafeInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1;
const rawActorId = Array.isArray(query.actor) ? query.actor[0] : query.actor;
const actorId = rawActorId && rawActorId.length <= 128
? rawActorId
: undefined;
const rawTargetType = Array.isArray(query.type) ? query.type[0] : query.type;
const targetType = isAuditTargetType(rawTargetType)
? rawTargetType
: undefined;
const actorId = rawActorId && rawActorId.length <= 128 ? rawActorId : undefined;
const [{ events, pageCount, total }, actors] = await Promise.all([
listAuditLogs({ page, pageSize: 50, actorId, targetType }),
listAuditLogs({ page, pageSize: 100, actorId }),
listAuditActors(),
]);
const dateTime = new Intl.DateTimeFormat("th-TH", {
@@ -94,99 +42,47 @@ export default async function AuditLogPage({
return (
<div className="min-h-svh">
<AdminHeader />
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
<main className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
<header>
<p className="text-sm font-medium text-primary">Admin audit trail</p>
<h1 className="font-heading text-3xl font-semibold">Activity</h1>
<p className="text-sm font-medium text-primary">Admin audit log</p>
<h1 className="font-heading text-3xl font-semibold">Logs</h1>
<p className="mt-2 text-muted-foreground">
ประวัติการเปลี่ยนแปลงที่บันทึกสำเร็จทั้งหมด {total.toLocaleString("th-TH")} รายการ
บันทึกการทำงานของผู้ดูแล {total.toLocaleString("th-TH")} รายการ
</p>
</header>
<AuditLogFilters
key={`${actorId ?? "all"}:${targetType ?? "all"}`}
actors={actors}
targetTypes={AUDIT_TARGET_TYPES.map((value) => ({
value,
label: AUDIT_TARGET_LABELS[value],
}))}
initialActorId={actorId}
initialTargetType={targetType}
/>
<AuditLogFilters key={actorId ?? "all"} actors={actors} initialActorId={actorId} />
{events.length ? (
<>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>เวลา</TableHead>
<TableHead>ผู้ดูแล</TableHead>
<TableHead>การทำงาน</TableHead>
<TableHead>เป้าหมาย</TableHead>
<TableHead>รายละเอียด</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => {
const action = event.action as AuditAction;
const summary = eventSummary(event.metadata);
return (
<TableRow key={event.id}>
<TableCell>{dateTime.format(event.createdAt)}</TableCell>
<TableCell>{event.actorLabel}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span>{AUDIT_ACTION_LABELS[action] ?? event.action}</span>
{event.metadata.overwrite === true ? (
<Badge variant="outline">Overwrite</Badge>
) : null}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<Badge variant="secondary">
{isAuditTargetType(event.targetType)
? AUDIT_TARGET_LABELS[event.targetType]
: event.targetType}
</Badge>
<span className="max-w-48 truncate font-mono text-xs text-muted-foreground">
{event.targetId}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex max-w-80 flex-col gap-1">
{summary ? <span className="truncate">{summary}</span> : null}
<span className="text-xs text-muted-foreground">
{event.scope ? `${event.scope} · ` : ""}
{event.resultingVersion
? `v${event.resultingVersion}`
: ""}
</span>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="overflow-hidden rounded-xl border">
{events.map((event) => (
<article className="border-b px-4 py-3 last:border-b-0 hover:bg-muted/40" key={event.id}>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="font-semibold">{event.author ?? "System"}</span>
<span>{event.text}</span>
<time className="text-xs text-muted-foreground">{dateTime.format(event.time)}</time>
</div>
{event.details && Object.keys(event.details).length ? (
<details className="mt-2">
<summary className="cursor-pointer text-sm text-muted-foreground">รายละเอียด</summary>
<pre className="mt-2 max-h-96 overflow-auto rounded-lg bg-muted p-3 text-xs">
{JSON.stringify(event.details, null, 2)}
</pre>
</details>
) : null}
</article>
))}
</div>
{pageCount > 1 ? (
<Pagination>
<PaginationContent>
{page > 1 ? (
<PaginationItem>
<PaginationPrevious
href={pageHref(page - 1, actorId, targetType)}
text="ก่อนหน้า"
/>
<PaginationPrevious href={pageHref(page - 1, actorId)} text="ก่อนหน้า" />
</PaginationItem>
) : null}
{page < pageCount ? (
<PaginationItem>
<PaginationNext
href={pageHref(page + 1, actorId, targetType)}
text="ถัดไป"
/>
<PaginationNext href={pageHref(page + 1, actorId)} text="ถัดไป" />
</PaginationItem>
) : null}
</PaginationContent>
@@ -196,13 +92,9 @@ export default async function AuditLogPage({
) : (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<ClipboardListIcon />
</EmptyMedia>
<EmptyTitle>ยังไม่มีกิจกรรม</EmptyTitle>
<EmptyDescription>
ลองเปลี่ยนตัวกรอง หรือกลับมาหลังจากมีการแก้ไขข้อมูล
</EmptyDescription>
<EmptyMedia variant="icon"><ClipboardListIcon /></EmptyMedia>
<EmptyTitle>ยังไม่มี Log</EmptyTitle>
<EmptyDescription>Log จะแสดงหลังจากผู้ดูแลแก้ไขข้อมูล</EmptyDescription>
</EmptyHeader>
</Empty>
)}