Files
Aegis/frontend/src/pages/AuditLogPage.tsx
kitos d6df7fdc09
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
fix(audit): show UTC suffix on timestamp display
2026-05-19 13:05:08 +02:00

298 lines
10 KiB
TypeScript

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Loader2,
AlertCircle,
FileText,
Filter,
ChevronLeft,
ChevronRight,
X,
} from "lucide-react";
import {
getAuditLogs,
getAuditActions,
getAuditEntityTypes,
type AuditLogFilters,
} from "../api/audit";
const PAGE_SIZE = 25;
export default function AuditLogPage() {
const [filters, setFilters] = useState<AuditLogFilters>({
offset: 0,
limit: PAGE_SIZE,
});
const {
data: logsData,
isLoading: logsLoading,
error: logsError,
} = useQuery({
queryKey: ["audit-logs", filters],
queryFn: () => getAuditLogs(filters),
});
const { data: actions } = useQuery({
queryKey: ["audit-actions"],
queryFn: getAuditActions,
});
const { data: entityTypes } = useQuery({
queryKey: ["audit-entity-types"],
queryFn: getAuditEntityTypes,
});
const handleFilterChange = (key: keyof AuditLogFilters, value: string) => {
setFilters((prev) => ({
...prev,
[key]: value || undefined,
offset: 0, // Reset pagination on filter change
}));
};
const clearFilters = () => {
setFilters({ offset: 0, limit: PAGE_SIZE });
};
const goToPage = (newOffset: number) => {
setFilters((prev) => ({ ...prev, offset: newOffset }));
};
const hasActiveFilters = filters.action || filters.entity_type || filters.start_date || filters.end_date;
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return "—";
const d = new Date(dateStr);
if (isNaN(d.getTime())) return "—";
return d.toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "UTC",
}) + " UTC";
};
const formatDetails = (details: Record<string, unknown> | null) => {
if (!details) return null;
return JSON.stringify(details, null, 2);
};
const totalPages = logsData ? Math.ceil(logsData.total / PAGE_SIZE) : 0;
const currentPage = Math.floor((filters.offset || 0) / PAGE_SIZE) + 1;
if (logsLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
if (logsError) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load audit logs</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">Audit Log</h1>
<p className="mt-1 text-sm text-gray-400">
System activity and change history
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-400">Filters:</span>
</div>
{/* Action filter */}
<select
value={filters.action || ""}
onChange={(e) => handleFilterChange("action", e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="">All Actions</option>
{actions?.map((action) => (
<option key={action} value={action}>
{action.replace(/_/g, " ")}
</option>
))}
</select>
{/* Entity type filter */}
<select
value={filters.entity_type || ""}
onChange={(e) => handleFilterChange("entity_type", e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="">All Entity Types</option>
{entityTypes?.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
{/* Date range */}
<input
type="date"
value={filters.start_date?.split("T")[0] || ""}
onChange={(e) =>
handleFilterChange(
"start_date",
e.target.value ? `${e.target.value}T00:00:00` : ""
)
}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
placeholder="Start date"
/>
<span className="text-gray-500">to</span>
<input
type="date"
value={filters.end_date?.split("T")[0] || ""}
onChange={(e) =>
handleFilterChange(
"end_date",
e.target.value ? `${e.target.value}T23:59:59` : ""
)
}
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
placeholder="End date"
/>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-400 hover:border-red-500/50 hover:text-red-400"
>
<X className="h-3.5 w-3.5" />
Clear
</button>
)}
<div className="ml-auto text-sm text-gray-500">
{logsData?.total || 0} total records
</div>
</div>
{/* Logs Table */}
<div className="rounded-xl border border-gray-800 bg-gray-900">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="px-6 py-4 font-medium text-gray-400">Timestamp</th>
<th className="px-6 py-4 font-medium text-gray-400">User</th>
<th className="px-6 py-4 font-medium text-gray-400">Action</th>
<th className="px-6 py-4 font-medium text-gray-400">Entity</th>
<th className="px-6 py-4 font-medium text-gray-400">Details</th>
</tr>
</thead>
<tbody>
{logsData?.items.map((log) => (
<tr
key={log.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="px-6 py-4">
<span className="font-mono text-xs text-gray-400">
{formatDate(log.timestamp)}
</span>
</td>
<td className="px-6 py-4">
<span className="text-gray-200">
{log.username || "System"}
</span>
</td>
<td className="px-6 py-4">
<span className="inline-flex rounded-full border border-cyan-500/30 bg-cyan-900/30 px-2 py-0.5 text-xs font-medium text-cyan-400">
{log.action.replace(/_/g, " ")}
</span>
</td>
<td className="px-6 py-4">
{log.entity_type && (
<div>
<span className="text-gray-300">{log.entity_type}</span>
{log.entity_id && (
<span className="ml-1 font-mono text-xs text-gray-500">
({log.entity_id.slice(0, 8)}...)
</span>
)}
</div>
)}
{!log.entity_type && <span className="text-gray-600"></span>}
</td>
<td className="px-6 py-4">
{log.details ? (
<details className="cursor-pointer">
<summary className="text-xs text-gray-400 hover:text-gray-200">
View details
</summary>
<pre className="mt-2 max-w-xs overflow-auto rounded bg-gray-800 p-2 text-xs text-gray-300">
{formatDetails(log.details)}
</pre>
</details>
) : (
<span className="text-gray-600"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
{logsData?.items.length === 0 && (
<div className="py-12 text-center text-gray-400">
No audit logs found
</div>
)}
</div>
{/* Pagination */}
{logsData && logsData.total > PAGE_SIZE && (
<div className="flex items-center justify-between border-t border-gray-800 px-6 py-4">
<div className="text-sm text-gray-400">
Showing {(filters.offset || 0) + 1} to{" "}
{Math.min((filters.offset || 0) + PAGE_SIZE, logsData.total)} of{" "}
{logsData.total}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => goToPage((filters.offset || 0) - PAGE_SIZE)}
disabled={(filters.offset || 0) === 0}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-4 w-4" />
Previous
</button>
<span className="text-sm text-gray-400">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => goToPage((filters.offset || 0) + PAGE_SIZE)}
disabled={(filters.offset || 0) + PAGE_SIZE >= logsData.total}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)}
</div>
</div>
);
}