333 lines
8.6 KiB
TypeScript
333 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
DataGrid,
|
|
GridColDef,
|
|
GridPaginationModel,
|
|
GridSortModel,
|
|
} from "@mui/x-data-grid";
|
|
import { getAudits } from "@/app/services/audits";
|
|
import "./page.scss";
|
|
import TextField from "@mui/material/TextField";
|
|
import { Box, debounce } from "@mui/material";
|
|
|
|
type AuditRow = Record<string, unknown> & { id: string | number };
|
|
|
|
interface AuditApiResponse {
|
|
total?: number;
|
|
limit?: number;
|
|
page?: number;
|
|
data?: unknown;
|
|
items?: unknown[];
|
|
audits?: unknown[];
|
|
logs?: unknown[];
|
|
results?: unknown[];
|
|
records?: unknown[];
|
|
meta?: { total?: number };
|
|
pagination?: { total?: number };
|
|
}
|
|
|
|
const DEFAULT_PAGE_SIZE = 25;
|
|
|
|
const FALLBACK_COLUMNS: GridColDef[] = [
|
|
{
|
|
field: "placeholder",
|
|
headerName: "Audit Data",
|
|
flex: 1,
|
|
sortable: false,
|
|
filterable: false,
|
|
},
|
|
];
|
|
|
|
const CANDIDATE_ARRAY_KEYS: (keyof AuditApiResponse)[] = [
|
|
"items",
|
|
"audits",
|
|
"logs",
|
|
"results",
|
|
"records",
|
|
];
|
|
|
|
const normalizeValue = (value: unknown): string | number => {
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof value === "string" || typeof value === "number") {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "boolean") {
|
|
return value ? "true" : "false";
|
|
}
|
|
|
|
return JSON.stringify(value);
|
|
};
|
|
|
|
const toTitle = (field: string) =>
|
|
field
|
|
.replace(/_/g, " ")
|
|
.replace(/-/g, " ")
|
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.replace(/^\w/g, char => char.toUpperCase());
|
|
|
|
const deriveColumns = (rows: AuditRow[]): GridColDef[] => {
|
|
if (!rows.length) return [];
|
|
|
|
return Object.keys(rows[0]).map(field => ({
|
|
field,
|
|
headerName: toTitle(field),
|
|
flex: field === "id" ? 0 : 1,
|
|
minWidth: field === "id" ? 140 : 200,
|
|
sortable: true,
|
|
}));
|
|
};
|
|
|
|
const extractArray = (payload: AuditApiResponse): unknown[] => {
|
|
if (Array.isArray(payload)) {
|
|
return payload;
|
|
}
|
|
|
|
for (const key of CANDIDATE_ARRAY_KEYS) {
|
|
const candidate = payload[key];
|
|
if (Array.isArray(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
const dataRecord =
|
|
payload.data &&
|
|
typeof payload.data === "object" &&
|
|
!Array.isArray(payload.data)
|
|
? (payload.data as Record<string, unknown>)
|
|
: null;
|
|
|
|
if (dataRecord) {
|
|
for (const key of CANDIDATE_ARRAY_KEYS) {
|
|
const candidate = dataRecord[key];
|
|
if (Array.isArray(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(payload.data)) {
|
|
return payload.data;
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
const resolveTotal = (payload: AuditApiResponse, fallback: number): number => {
|
|
const fromPayload = payload.total;
|
|
const fromMeta = payload.meta?.total;
|
|
const fromPagination = payload.pagination?.total;
|
|
const fromData =
|
|
payload.data &&
|
|
typeof payload.data === "object" &&
|
|
!Array.isArray(payload.data)
|
|
? (payload.data as { total?: number }).total
|
|
: undefined;
|
|
|
|
return (
|
|
(typeof fromPayload === "number" && fromPayload) ||
|
|
(typeof fromMeta === "number" && fromMeta) ||
|
|
(typeof fromPagination === "number" && fromPagination) ||
|
|
(typeof fromData === "number" && fromData) ||
|
|
fallback
|
|
);
|
|
};
|
|
|
|
const normalizeRows = (entries: unknown[], page: number): AuditRow[] =>
|
|
entries.map((entry, index) => {
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
return {
|
|
id: `${page}-${index}`,
|
|
value: normalizeValue(entry),
|
|
};
|
|
}
|
|
|
|
const record = entry as Record<string, unknown>;
|
|
|
|
const normalized: Record<string, unknown> = {};
|
|
Object.entries(record).forEach(([key, value]) => {
|
|
normalized[key] = normalizeValue(value);
|
|
});
|
|
|
|
const identifier =
|
|
record.id ??
|
|
record.audit_id ??
|
|
record.log_id ??
|
|
record._id ??
|
|
`${page}-${index}`;
|
|
|
|
return {
|
|
id: (identifier as string | number) ?? `${page}-${index}`,
|
|
...normalized,
|
|
};
|
|
});
|
|
|
|
export default function AuditPage() {
|
|
const [rows, setRows] = useState<AuditRow[]>([]);
|
|
const [columns, setColumns] = useState<GridColDef[]>([]);
|
|
const [rowCount, setRowCount] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
|
|
page: 0,
|
|
pageSize: DEFAULT_PAGE_SIZE,
|
|
});
|
|
const [sortModel, setSortModel] = useState<GridSortModel>([]);
|
|
const [entitySearch, setEntitySearch] = useState<string>("");
|
|
const [entitySearchInput, setEntitySearchInput] = useState<string>("");
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
|
|
const fetchAudits = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const sortParam =
|
|
sortModel.length && sortModel[0].field && sortModel[0].sort
|
|
? `${sortModel[0].field}:${sortModel[0].sort}`
|
|
: undefined;
|
|
|
|
const entityParam = entitySearch.trim()
|
|
? `LIKE/${entitySearch.trim()}`
|
|
: undefined;
|
|
|
|
try {
|
|
const payload = (await getAudits({
|
|
limit: paginationModel.pageSize,
|
|
page: paginationModel.page + 1,
|
|
sort: sortParam,
|
|
entity: entityParam,
|
|
signal: controller.signal,
|
|
})) as AuditApiResponse;
|
|
|
|
const auditEntries = extractArray(payload);
|
|
const normalized = normalizeRows(auditEntries, paginationModel.page);
|
|
|
|
setColumns(prev =>
|
|
normalized.length
|
|
? deriveColumns(normalized)
|
|
: prev.length
|
|
? prev
|
|
: FALLBACK_COLUMNS
|
|
);
|
|
|
|
setRows(normalized);
|
|
setRowCount(resolveTotal(payload, normalized.length));
|
|
} catch (err) {
|
|
if (controller.signal.aborted) return;
|
|
const message =
|
|
err instanceof Error ? err.message : "Failed to load audits";
|
|
setError(message);
|
|
setRows([]);
|
|
setColumns(prev => (prev.length ? prev : FALLBACK_COLUMNS));
|
|
} finally {
|
|
if (!controller.signal.aborted) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchAudits();
|
|
|
|
return () => controller.abort();
|
|
}, [paginationModel, sortModel, entitySearch]);
|
|
|
|
const handlePaginationChange = (model: GridPaginationModel) => {
|
|
setPaginationModel(model);
|
|
};
|
|
|
|
const handleSortModelChange = (model: GridSortModel) => {
|
|
setSortModel(model);
|
|
setPaginationModel(prev => ({ ...prev, page: 0 }));
|
|
};
|
|
|
|
const debouncedSetEntitySearch = useMemo(
|
|
() =>
|
|
debounce((value: string) => {
|
|
setEntitySearch(value);
|
|
setPaginationModel(prev => ({ ...prev, page: 0 }));
|
|
}, 500),
|
|
[]
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
debouncedSetEntitySearch.clear();
|
|
};
|
|
}, [debouncedSetEntitySearch]);
|
|
|
|
const handleEntitySearchChange = (value: string) => {
|
|
setEntitySearchInput(value);
|
|
debouncedSetEntitySearch(value);
|
|
};
|
|
|
|
const pageTitle = useMemo(
|
|
() =>
|
|
sortModel.length && sortModel[0].field
|
|
? `Audit Logs · sorted by ${toTitle(sortModel[0].field)}`
|
|
: "Audit Logs",
|
|
[sortModel]
|
|
);
|
|
|
|
return (
|
|
<div className="audits-page">
|
|
<Box sx={{ display: "flex", gap: 2, mt: 5 }}>
|
|
<TextField
|
|
label="Search by Entity"
|
|
variant="outlined"
|
|
size="small"
|
|
value={entitySearchInput}
|
|
onChange={e => handleEntitySearchChange(e.target.value)}
|
|
sx={{ width: 300, backgroundColor: "#f0f0f0" }}
|
|
/>
|
|
</Box>
|
|
<h1 className="page-title">{pageTitle}</h1>
|
|
{error && (
|
|
<div className="error-alert" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<div className="table-container">
|
|
<div className="scroll-wrapper">
|
|
<div
|
|
className="table-inner"
|
|
style={{ minWidth: `${columns.length * 200}px` }}
|
|
>
|
|
<DataGrid
|
|
rows={rows}
|
|
columns={columns.length ? columns : FALLBACK_COLUMNS}
|
|
loading={loading}
|
|
paginationMode="server"
|
|
sortingMode="server"
|
|
paginationModel={paginationModel}
|
|
onPaginationModelChange={handlePaginationChange}
|
|
rowCount={rowCount}
|
|
sortModel={sortModel}
|
|
onSortModelChange={handleSortModelChange}
|
|
pageSizeOptions={[10, 25, 50, 100]}
|
|
disableRowSelectionOnClick
|
|
sx={{
|
|
border: 0,
|
|
minHeight: 500,
|
|
"& .MuiDataGrid-cell": {
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|