mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
feat: implement audit logs
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { Badge, Box, Card, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
AuditLogRow,
|
||||
AuditQueryMethod,
|
||||
AuditUser,
|
||||
} from "@/services/audit.service";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const ACTION_LABELS: Record<AuditQueryMethod, string> = {
|
||||
INSERT: "Created",
|
||||
UPDATE: "Updated",
|
||||
DELETE: "Deleted",
|
||||
INSERT_CHILD: "Linked child",
|
||||
DELETE_CHILD: "Unlinked child",
|
||||
};
|
||||
|
||||
const ACTION_COLORS: Record<AuditQueryMethod, string> = {
|
||||
INSERT: "edr-green",
|
||||
UPDATE: "yellow",
|
||||
DELETE: "red",
|
||||
INSERT_CHILD: "indigo",
|
||||
DELETE_CHILD: "gray",
|
||||
};
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// The producer (@tria-plc/auditlog's ClientLoggerInterceptor) builds
|
||||
// `name` from `${auditUser?.firstName} ${auditUser?.lastName}` — this app's
|
||||
// user model only has a single `name` field, and unauthenticated/customer
|
||||
// flows (e.g. Fayda verification) have no auditUser at all, so this literal
|
||||
// "undefined undefined" ends up stored as-is. Filter it back out on render
|
||||
// rather than showing raw garbage.
|
||||
function formatUser(user: AuditUser | null | undefined): string {
|
||||
const name = user?.name;
|
||||
if (typeof name === "string" && /^undefined(\s+undefined)?$/.test(name.trim())) {
|
||||
return "—";
|
||||
}
|
||||
return name ?? user?.id ?? "—";
|
||||
}
|
||||
|
||||
function summarize(row: AuditLogRow): string {
|
||||
if (row.changes?.length) {
|
||||
return row.changes
|
||||
.slice(0, 2)
|
||||
.map((c) => c.field)
|
||||
.join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : "");
|
||||
}
|
||||
if (row.payload) {
|
||||
return row.payload.name ?? row.payload.title ?? row.payload.id ?? "—";
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
const tableHeader =
|
||||
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 20 });
|
||||
|
||||
const filter = {
|
||||
skip: pagination.pageIndex * pagination.pageSize,
|
||||
take: pagination.pageSize,
|
||||
};
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.audit.list.queryOptions({ input: { filter } }),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.count ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const columns: ColumnDef<AuditLogRow>[] = [
|
||||
{
|
||||
id: "time",
|
||||
header: () => <span className={tableHeader}>Time</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDateTime(row.original.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: () => <span className={tableHeader}>Action</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
color={ACTION_COLORS[row.original.queryMethod] ?? "gray"}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
{ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "entity",
|
||||
header: () => <span className={tableHeader}>Entity</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm text-foreground">
|
||||
{row.original.entityName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "user",
|
||||
header: () => <span className={tableHeader}>User</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-foreground">
|
||||
{formatUser(row.original.auditLog?.user)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "summary",
|
||||
header: () => <span className={tableHeader}>Summary</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{summarize(row.original)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Audit Logs"
|
||||
subtitle="Request and entity-level activity recorded across the freight API."
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user