Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx
marshalyordanos 5da36eb128 feat: add wagon usage computation and maintenance logging features
- Implemented  utility to calculate wagon usage metrics for train schedules.
- Created  for sending wagons to maintenance with optional notes.
- Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes.
- Developed  component for merging train schedules with detailed previews and reasons for merging.
- Introduced  component for selecting wagons with search functionality and selection limits.
- Created  for displaying and filtering audit logs, including detailed views of individual log entries.
- Added  for handling API interactions related to audit logs, including fetching logs and entity types.
2026-08-12 09:36:50 +03:00

341 lines
12 KiB
TypeScript

import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Card,
Code,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import {
usePagination,
type OnChangeFn,
type PaginationState,
} from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import {
AUDIT_METHODS,
auditLogsService,
type AuditLog,
type AuditMethod,
} from "@/services/auditLogs.service";
/** Method → badge colour. Destructive actions read as the loudest. */
const METHOD_COLORS: Record<AuditMethod, string> = {
POST: "green",
PUT: "blue",
PATCH: "yellow",
DELETE: "red",
};
const OUTCOME_OPTIONS = [
{ value: "true", label: "Succeeded" },
{ value: "false", label: "Failed" },
];
/** `YYYY-MM-DD` → inclusive ISO bounds, so a single day covers its full range. */
const startOfDay = (date: string) => `${date}T00:00:00.000Z`;
const endOfDay = (date: string) => `${date}T23:59:59.999Z`;
const formatTimestamp = (value: string) => new Date(value).toLocaleString();
const AuditLogsPage = () => {
// Server-side filters. Unlike most freight lists (which filter an
// already-fetched array via useListControls), audit_logs is append-only and
// grows without bound, so filtering and paging both happen in the API.
const [search, setSearch] = useState("");
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [type, setType] = useState<string | null>(null);
const [method, setMethod] = useState<string | null>(null);
const [outcome, setOutcome] = useState<string | null>(null);
const [selected, setSelected] = useState<AuditLog | null>(null);
const { pagination, setPagination } = usePagination({ pageIndex: 0, pageSize: 25 });
const query = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
type: type ?? undefined,
method: (method as AuditMethod | null) ?? undefined,
isSuccess: outcome === null ? undefined : outcome === "true",
// The API filters by record id; the search box is the natural place to
// paste one when tracing what happened to a specific contract/booking.
resourceId: search.trim() || undefined,
from: dateFrom ? startOfDay(dateFrom) : undefined,
to: dateTo ? endOfDay(dateTo) : undefined,
}),
[pagination, type, method, outcome, search, dateFrom, dateTo],
);
const logsQuery = useQuery({
queryKey: ["audit-logs", query],
queryFn: () => auditLogsService.list(query),
});
const typesQuery = useQuery({
queryKey: ["audit-logs", "types"],
queryFn: () => auditLogsService.types(),
});
const rows = logsQuery.data?.items ?? [];
const totalCount = logsQuery.data?.meta.total ?? 0;
const pageCount = logsQuery.data?.meta.totalPages ?? 0;
const hasFilters = Boolean(
search || dateFrom || dateTo || type || method || outcome,
);
const resetFilters = () => {
setSearch("");
setDateFrom(null);
setDateTo(null);
setType(null);
setMethod(null);
setOutcome(null);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
/** Any filter change must return to page 1, or the view can land out of range. */
const onFilterChange = <T,>(setter: (value: T) => void) => (value: T) => {
setter(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
// `OnChangeFn` may hand back either a new value or an updater, so both forms
// are resolved before storing.
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
setPagination((prev) =>
typeof updater === "function" ? updater(prev) : updater,
);
};
// `PageContainer` gives the page the same horizontal inset and vertical
// rhythm as every other dashboard screen; `fluid` lifts the max-width cap
// because the log table is wide.
return (
<PageContainer fluid>
<PageHeader
title="Audit logs"
subtitle="Every state-changing action taken by backoffice staff. Read-only — entries cannot be edited or removed."
/>
<Card withBorder padding="md">
<Stack gap="md">
<ListControls
search={search}
onSearchChange={onFilterChange(setSearch)}
searchPlaceholder="Filter by record id…"
dateFrom={dateFrom}
onDateFromChange={onFilterChange(setDateFrom)}
dateTo={dateTo}
onDateToChange={onFilterChange(setDateTo)}
dateLabel="Action date"
hasFilters={hasFilters}
onReset={resetFilters}
>
<Select
label="Entity"
placeholder="All entities"
data={typesQuery.data ?? []}
value={type}
onChange={onFilterChange(setType)}
clearable
searchable
w={200}
/>
<Select
label="Method"
placeholder="All methods"
data={[...AUDIT_METHODS]}
value={method}
onChange={onFilterChange(setMethod)}
clearable
w={150}
/>
<Select
label="Outcome"
placeholder="Any outcome"
data={OUTCOME_OPTIONS}
value={outcome}
onChange={onFilterChange(setOutcome)}
clearable
w={160}
/>
</ListControls>
{logsQuery.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : logsQuery.isError ? (
<Text c="red" ta="center" py="xl">
Could not load audit logs.
</Text>
) : rows.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No audit entries match these filters.
</Text>
) : (
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Action</Table.Th>
<Table.Th>Entity</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>User</Table.Th>
<Table.Th>Outcome</Table.Th>
<Table.Th>When</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((log) => (
<Table.Tr
key={log.id}
onClick={() => setSelected(log)}
style={{ cursor: "pointer" }}
>
<Table.Td maw={340}>
<Text size="sm" lineClamp={2}>
{log.title}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light">{log.type}</Badge>
</Table.Td>
<Table.Td>
<Badge color={METHOD_COLORS[log.method]} variant="light">
{log.method}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{log.userName ?? "—"}</Text>
{log.userRole ? (
<Text size="xs" c="dimmed">
{log.userRole}
</Text>
) : null}
</Table.Td>
<Table.Td>
{log.isSuccess ? (
<Badge color="green" variant="light">
Success
</Badge>
) : (
// The status code separates "denied" (403) from
// "broke" (500) — both are simply a failure here.
<Tooltip
label={log.errorMessage ?? "Failed"}
multiline
w={280}
disabled={!log.errorMessage}
>
<Badge color="red" variant="light">
Failed{log.statusCode ? ` · ${log.statusCode}` : ""}
</Badge>
</Tooltip>
)}
</Table.Td>
<Table.Td>
<Text size="sm">{formatTimestamp(log.createdAt)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={totalCount}
itemLabel="entries"
onPaginationChange={handlePaginationChange}
/>
</Stack>
</Card>
<Modal
opened={selected !== null}
onClose={() => setSelected(null)}
title="Audit entry"
size="lg"
>
{selected ? (
<Stack gap="sm">
<DetailRow label="Action" value={selected.title} />
<DetailRow label="Entity" value={selected.type} />
<DetailRow label="Record id" value={selected.resourceId} />
<DetailRow label="Method" value={selected.method} />
<DetailRow label="URL" value={selected.url} />
<DetailRow label="Route" value={selected.routePath} />
<DetailRow
label="Outcome"
value={
selected.isSuccess
? `Success${selected.statusCode ? ` (${selected.statusCode})` : ""}`
: `Failed${selected.statusCode ? ` (${selected.statusCode})` : ""}`
}
/>
{selected.errorMessage ? (
<DetailRow label="Error" value={selected.errorMessage} />
) : null}
<DetailRow label="User" value={selected.userName} />
<DetailRow label="Role" value={selected.userRole} />
<DetailRow label="User id" value={selected.userId} />
<DetailRow label="IP address" value={selected.ipAddress} />
<DetailRow label="Request id" value={selected.requestId} />
<DetailRow
label="Duration"
value={selected.durationMs === null ? null : `${selected.durationMs} ms`}
/>
<DetailRow label="When" value={formatTimestamp(selected.createdAt)} />
<div>
<Text size="sm" fw={600} mb={4}>
Request payload
</Text>
{selected.request ? (
// Secrets are already redacted and uploads reduced to
// descriptors by the API before storage.
<Code block style={{ maxHeight: 320, overflow: "auto" }}>
{JSON.stringify(selected.request, null, 2)}
</Code>
) : (
<Text size="sm" c="dimmed">
No payload recorded.
</Text>
)}
</div>
</Stack>
) : null}
</Modal>
</PageContainer>
);
};
const DetailRow = ({ label, value }: { label: string; value: string | null }) => (
<Group gap="xs" wrap="nowrap" align="flex-start">
<Text size="sm" fw={600} w={120} style={{ flexShrink: 0 }}>
{label}
</Text>
<Text size="sm" style={{ wordBreak: "break-all" }}>
{value ?? "—"}
</Text>
</Group>
);
export default AuditLogsPage;