313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { CustomColumnFilter, CustomButton } from "../../components/custom";
|
|
import { DataTable } from "../../components/custom/CustomTable";
|
|
import type { ColumnDef } from "../../components/custom/CustomTable";
|
|
import {
|
|
buildColumnFilterOptions,
|
|
resolveColumnSortState,
|
|
} from "../../components/custom/CustomColumnFilter.utils";
|
|
import type { ColumnSortDirection } from "../../components/custom/CustomColumnFilter";
|
|
import { useDebounce } from "../../components/hooks/useDebounce";
|
|
import { apiClient } from "../../lib/apiClient";
|
|
import { buildQueryString } from "../../lib/queryParams";
|
|
import {
|
|
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
|
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
|
resolveStoredTablePageSize,
|
|
} from "../../lib/tablePageSize";
|
|
import { RefreshCcw } from "lucide-react";
|
|
|
|
interface AuditLog extends Record<string, unknown> {
|
|
id: string;
|
|
module_name: string;
|
|
action_type: string;
|
|
entity_name: string;
|
|
performed_by_email: string;
|
|
ip_address: string;
|
|
description: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface AuditLogListResponse {
|
|
items: AuditLog[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
}
|
|
|
|
const LogsPage = () => {
|
|
const { t, i18n } = useTranslation(["logs", "common"]);
|
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
|
const [allLogsForCounts, setAllLogsForCounts] = useState<AuditLog[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(() =>
|
|
resolveStoredTablePageSize({
|
|
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
|
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
|
defaultPageSize: 10,
|
|
})
|
|
);
|
|
const [search, setSearch] = useState("");
|
|
const [moduleFilter, setModuleFilter] = useState<string[]>([]);
|
|
const [actionFilter, setActionFilter] = useState<string[]>([]);
|
|
const [emailFilter, setEmailFilter] = useState<string[]>([]);
|
|
const [totalRows, setTotalRows] = useState(0);
|
|
const [activeSort, setActiveSort] = useState<{
|
|
column: "module_name" | "action_type" | "performed_by_email" | "created_at" | null;
|
|
direction: ColumnSortDirection;
|
|
}>({ column: "created_at", direction: "desc" });
|
|
|
|
const debouncedSearch = useDebounce(search, 500);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const prevLoadingRef = useRef(isLoading);
|
|
const latestLogsRequestRef = useRef(0);
|
|
|
|
const fetchLogs = useCallback(async () => {
|
|
const requestId = ++latestLogsRequestRef.current;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
const queryString = buildQueryString({
|
|
limit: pageSize,
|
|
offset: (page - 1) * pageSize,
|
|
search: debouncedSearch || undefined,
|
|
module_names: moduleFilter,
|
|
action_types: actionFilter,
|
|
performed_by_emails: emailFilter,
|
|
sort_by: activeSort.column ?? undefined,
|
|
sort_order: activeSort.direction ?? undefined,
|
|
});
|
|
|
|
const response = await apiClient.get<AuditLogListResponse>(
|
|
`/api/admin/audit-logs/${queryString}`,
|
|
{ silent: true }
|
|
);
|
|
|
|
if (requestId === latestLogsRequestRef.current) {
|
|
setLogs(response.items);
|
|
setTotalRows(response.total);
|
|
}
|
|
} catch (error) {
|
|
console.error(t("messages.loadError"), error);
|
|
} finally {
|
|
if (requestId === latestLogsRequestRef.current) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
}, [actionFilter, activeSort, debouncedSearch, emailFilter, page, pageSize, moduleFilter, t]);
|
|
|
|
const fetchFilterOptions = useCallback(async () => {
|
|
try {
|
|
const response = await apiClient.get<AuditLogListResponse>(
|
|
"/api/admin/audit-logs/?limit=500&offset=0",
|
|
{ silent: true }
|
|
);
|
|
setAllLogsForCounts(response.items);
|
|
} catch (error) {
|
|
console.error(t("messages.loadError"), error);
|
|
}
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
fetchLogs();
|
|
}, [fetchLogs]);
|
|
|
|
useEffect(() => {
|
|
fetchFilterOptions();
|
|
}, [fetchFilterOptions]);
|
|
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [debouncedSearch, moduleFilter, actionFilter, emailFilter, activeSort]);
|
|
|
|
useEffect(() => {
|
|
if (prevLoadingRef.current && !isLoading && search.trim()) {
|
|
searchInputRef.current?.focus({ preventScroll: true });
|
|
}
|
|
prevLoadingRef.current = isLoading;
|
|
}, [isLoading, search]);
|
|
|
|
const moduleCounts = useMemo(() => {
|
|
const counts = new Map<string, number>();
|
|
allLogsForCounts.forEach((log) => {
|
|
counts.set(log.module_name, (counts.get(log.module_name) ?? 0) + 1);
|
|
});
|
|
return counts;
|
|
}, [allLogsForCounts]);
|
|
|
|
const actionCounts = useMemo(() => {
|
|
const counts = new Map<string, number>();
|
|
allLogsForCounts.forEach((log) => {
|
|
counts.set(log.action_type, (counts.get(log.action_type) ?? 0) + 1);
|
|
});
|
|
return counts;
|
|
}, [allLogsForCounts]);
|
|
|
|
const emailCounts = useMemo(() => {
|
|
const counts = new Map<string, number>();
|
|
allLogsForCounts.forEach((log) => {
|
|
counts.set(log.performed_by_email, (counts.get(log.performed_by_email) ?? 0) + 1);
|
|
});
|
|
return counts;
|
|
}, [allLogsForCounts]);
|
|
|
|
const columns: ColumnDef<AuditLog>[] = useMemo(
|
|
() => [
|
|
{
|
|
key: "module_name",
|
|
visibilityLabel: t("columns.module"),
|
|
header: (
|
|
<div className="flex items-center">
|
|
{t("columns.module")}
|
|
<CustomColumnFilter
|
|
title={t("columns.module")}
|
|
options={buildColumnFilterOptions(moduleCounts.entries())}
|
|
selectedValues={moduleFilter}
|
|
sortDirection={activeSort.column === "module_name" ? activeSort.direction : null}
|
|
onApply={(values, direction) => {
|
|
setPage(1);
|
|
setModuleFilter(values);
|
|
setActiveSort(resolveColumnSortState(activeSort, "module_name", direction));
|
|
}}
|
|
/>
|
|
</div>
|
|
),
|
|
searchable: false,
|
|
},
|
|
{
|
|
key: "action_type",
|
|
visibilityLabel: t("columns.action"),
|
|
header: (
|
|
<div className="flex items-center">
|
|
{t("columns.action")}
|
|
<CustomColumnFilter
|
|
title={t("columns.action")}
|
|
options={buildColumnFilterOptions(actionCounts.entries())}
|
|
selectedValues={actionFilter}
|
|
sortDirection={activeSort.column === "action_type" ? activeSort.direction : null}
|
|
enableSearch={false}
|
|
onApply={(values, direction) => {
|
|
setPage(1);
|
|
setActionFilter(values);
|
|
setActiveSort(resolveColumnSortState(activeSort, "action_type", direction));
|
|
}}
|
|
/>
|
|
</div>
|
|
),
|
|
searchable: false,
|
|
render: (row) => (
|
|
<span
|
|
className={`rounded px-2 py-1 text-xs font-bold ${
|
|
row.action_type === "CREATE"
|
|
? "bg-green-100 text-green-700"
|
|
: row.action_type === "UPDATE"
|
|
? "bg-blue-100 text-blue-700"
|
|
: row.action_type === "DELETE"
|
|
? "bg-red-100 text-red-700"
|
|
: "bg-gray-100"
|
|
}`}
|
|
>
|
|
{row.action_type}
|
|
</span>
|
|
),
|
|
},
|
|
{ key: "description", header: t("columns.description") },
|
|
{
|
|
key: "performed_by_email",
|
|
visibilityLabel: t("columns.performedBy"),
|
|
header: (
|
|
<div className="flex items-center">
|
|
{t("columns.performedBy")}
|
|
<CustomColumnFilter
|
|
title={t("columns.performedBy")}
|
|
options={buildColumnFilterOptions(emailCounts.entries())}
|
|
selectedValues={emailFilter}
|
|
sortDirection={activeSort.column === "performed_by_email" ? activeSort.direction : null}
|
|
onApply={(values, direction) => {
|
|
setPage(1);
|
|
setEmailFilter(values);
|
|
setActiveSort(resolveColumnSortState(activeSort, "performed_by_email", direction));
|
|
}}
|
|
/>
|
|
</div>
|
|
),
|
|
searchable: false,
|
|
},
|
|
{ key: "ip_address", header: t("columns.ipAddress") },
|
|
{
|
|
key: "created_at",
|
|
visibilityLabel: t("columns.timestamp"),
|
|
header: (
|
|
<div className="flex items-center">
|
|
{t("columns.timestamp")}
|
|
<CustomColumnFilter
|
|
title={t("columns.timestamp")}
|
|
options={[]}
|
|
selectedValues={[]}
|
|
sortDirection={activeSort.column === "created_at" ? activeSort.direction : null}
|
|
enableSearch={false}
|
|
enableSelectAll={false}
|
|
onApply={(_, direction) => {
|
|
setPage(1);
|
|
setActiveSort(resolveColumnSortState(activeSort, "created_at", direction));
|
|
}}
|
|
/>
|
|
</div>
|
|
),
|
|
searchable: false,
|
|
render: (row) =>
|
|
new Date(row.created_at).toLocaleString(
|
|
i18n.language === "ar" ? "ar-EG" : "en-GB"
|
|
),
|
|
},
|
|
],
|
|
[actionCounts, actionFilter, activeSort, emailCounts, emailFilter, i18n.language, moduleCounts, moduleFilter, t]
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div className="flex flex-col gap-1">
|
|
<h1 className="text-2xl font-bold text-(--text-primary)">
|
|
{t("title")}
|
|
</h1>
|
|
<p className="text-sm text-[var(--text-secondary)]">Track and audit all system activities and user actions.</p>
|
|
</div>
|
|
<CustomButton
|
|
variant="outlined"
|
|
onClick={fetchLogs}
|
|
loading={isLoading}
|
|
leftIcon={<RefreshCcw size={16} />}
|
|
>
|
|
{t("common:actions.refresh")}
|
|
</CustomButton>
|
|
</div>
|
|
|
|
<DataTable<AuditLog>
|
|
data={logs}
|
|
columns={columns}
|
|
columnVisibilityEnabled
|
|
columnVisibilityStorageKey="logs-table-columns"
|
|
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
|
isLoading={isLoading}
|
|
exportEnabled={false}
|
|
exportFileName={t("exportFileName")}
|
|
manualPagination
|
|
manualFiltering
|
|
totalRows={totalRows}
|
|
page={page}
|
|
pageSize={pageSize}
|
|
search={search}
|
|
onPageChange={setPage}
|
|
onPageSizeChange={setPageSize}
|
|
onSearchChange={setSearch}
|
|
searchInputRef={searchInputRef}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LogsPage;
|