it0/it0-web-admin/src/app/(admin)/audit/logs/page.tsx

353 lines
14 KiB
TypeScript

'use client';
import { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils';
// ── Types ───────────────────────────────────────────────────────────────────
interface AuditLog {
id: string;
timestamp: string;
actionType: string;
actorType: string;
actorId: string;
resourceType: string;
resourceId: string;
description: string;
detail: Record<string, unknown>;
}
type AuditLogsResponse = AuditLog[];
interface Filters {
dateFrom: string;
dateTo: string;
actionType: string;
actorType: string;
resourceType: string;
}
// ── Constants ───────────────────────────────────────────────────────────────
const ACTION_TYPES = ['', 'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'EXECUTE', 'APPROVE', 'REJECT'];
const ACTOR_TYPES = ['', 'user', 'system', 'agent'];
const RESOURCE_TYPES = ['', 'server', 'task', 'standing_order', 'runbook', 'credential', 'user'];
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// ── Main Component ──────────────────────────────────────────────────────────
export default function AuditLogsPage() {
const { t } = useTranslation('audit');
const { t: tc } = useTranslation('common');
const [filters, setFilters] = useState<Filters>({
dateFrom: '',
dateTo: '',
actionType: '',
actorType: '',
resourceType: '',
});
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [expandedId, setExpandedId] = useState<string | null>(null);
// Build query params from filters
const queryParams = useMemo(() => {
const params: Record<string, string> = {
page: String(page),
pageSize: String(pageSize),
};
if (filters.dateFrom) params.dateFrom = filters.dateFrom;
if (filters.dateTo) params.dateTo = filters.dateTo;
if (filters.actionType) params.actionType = filters.actionType;
if (filters.actorType) params.actorType = filters.actorType;
if (filters.resourceType) params.resourceType = filters.resourceType;
return params;
}, [filters, page, pageSize]);
const queryString = useMemo(() => {
const sp = new URLSearchParams(queryParams);
return sp.toString();
}, [queryParams]);
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.auditLogs.list(queryParams),
queryFn: () => apiClient<AuditLogsResponse>(`/api/v1/audit/logs?${queryString}`),
});
const logs = data ?? [];
const total = logs.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const updateFilter = useCallback((key: keyof Filters, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
setPage(1); // reset to first page on filter change
}, []);
const handlePageSizeChange = useCallback((newSize: number) => {
setPageSize(newSize);
setPage(1);
}, []);
// ── CSV Export ─────────────────────────────────────────────────────────────
const exportCsv = useCallback(() => {
if (logs.length === 0) return;
const headers = [t('logs.table.timestamp'), t('logs.table.action'), t('logs.table.actorType'), t('logs.table.actorId'), t('logs.table.resourceType'), t('logs.table.resourceId'), t('logs.table.description')];
const rows = logs.map((log) => [
log.timestamp,
log.actionType,
log.actorType,
log.actorId,
log.resourceType,
log.resourceId,
`"${(log.description || '').replace(/"/g, '""')}"`,
]);
const csvContent = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [logs, t]);
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold">{t('logs.title')}</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('logs.subtitle')}
</p>
</div>
<button
onClick={exportCsv}
disabled={logs.length === 0}
className="px-4 py-2 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('logs.exportCsv')}
</button>
</div>
{/* Filters */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 mb-4 p-4 bg-card border rounded-lg">
<div>
<label className="block text-xs text-muted-foreground mb-1">{t('logs.filters.dateFrom')}</label>
<input
type="date"
value={filters.dateFrom}
onChange={(e) => updateFilter('dateFrom', e.target.value)}
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">{t('logs.filters.dateTo')}</label>
<input
type="date"
value={filters.dateTo}
onChange={(e) => updateFilter('dateTo', e.target.value)}
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">{t('logs.filters.actionType')}</label>
<select
value={filters.actionType}
onChange={(e) => updateFilter('actionType', e.target.value)}
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
>
{ACTION_TYPES.map((at) => (
<option key={at} value={at}>{at || tc('all')}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">{t('logs.filters.actorType')}</label>
<select
value={filters.actorType}
onChange={(e) => updateFilter('actorType', e.target.value)}
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
>
{ACTOR_TYPES.map((at) => (
<option key={at} value={at}>{at || tc('all')}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">{t('logs.filters.resourceType')}</label>
<select
value={filters.resourceType}
onChange={(e) => updateFilter('resourceType', e.target.value)}
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
>
{RESOURCE_TYPES.map((rt) => (
<option key={rt} value={rt}>{rt || tc('all')}</option>
))}
</select>
</div>
</div>
{/* Loading / Error */}
{isLoading && <p className="text-muted-foreground py-4">{t('logs.loading')}</p>}
{error && <p className="text-red-500 py-4">{t('logs.loadError')} {(error as Error).message}</p>}
{/* Table */}
{!isLoading && !error && (
<>
<div className="overflow-x-auto border rounded-lg">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left">
<th className="py-2 px-3 font-medium w-8"></th>
<th className="py-2 px-3 font-medium">{t('logs.table.timestamp')}</th>
<th className="py-2 px-3 font-medium">{t('logs.table.action')}</th>
<th className="py-2 px-3 font-medium">{t('logs.table.actorType')}</th>
<th className="py-2 px-3 font-medium">{t('logs.table.actorId')}</th>
<th className="py-2 px-3 font-medium">{t('logs.table.resourceType')}</th>
<th className="py-2 px-3 font-medium">{t('logs.table.resourceId')}</th>
<th className="py-2 px-3 font-medium">{t('logs.table.description')}</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<LogRow
key={log.id}
log={log}
isExpanded={expandedId === log.id}
onToggle={() => setExpandedId(expandedId === log.id ? null : log.id)}
/>
))}
{logs.length === 0 && (
<tr>
<td colSpan={8} className="py-8 text-center text-muted-foreground">
{t('logs.empty')}
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{t('logs.pagination.rowsPerPage')}:</span>
<select
value={pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="px-2 py-1 bg-input border rounded text-sm"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="ml-2">
{total > 0
? `${(page - 1) * pageSize + 1}--${Math.min(page * pageSize, total)} of ${total}`
: tc('noResults')}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1.5 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('logs.pagination.previous')}
</button>
<span className="text-sm text-muted-foreground">
{t('logs.pagination.pageOf', { current: page, total: totalPages })}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1.5 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('logs.pagination.next')}
</button>
</div>
</div>
</>
)}
</div>
);
}
// ── Row Component ───────────────────────────────────────────────────────────
const ACTION_COLORS: Record<string, string> = {
CREATE: 'bg-green-100 text-green-700',
UPDATE: 'bg-blue-100 text-blue-700',
DELETE: 'bg-red-100 text-red-700',
LOGIN: 'bg-purple-100 text-purple-700',
EXECUTE: 'bg-orange-100 text-orange-700',
APPROVE: 'bg-emerald-100 text-emerald-700',
REJECT: 'bg-rose-100 text-rose-700',
};
function LogRow({
log,
isExpanded,
onToggle,
}: {
log: AuditLog;
isExpanded: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation('audit');
const formattedTime = useMemo(() => {
try {
return new Date(log.timestamp).toLocaleString();
} catch {
return log.timestamp;
}
}, [log.timestamp]);
return (
<>
<tr
onClick={onToggle}
className={cn('border-b cursor-pointer hover:bg-accent/50 transition-colors', isExpanded && 'bg-accent/30')}
>
<td className="py-2 px-3 text-muted-foreground">
<span className={cn('inline-block transition-transform text-xs', isExpanded && 'rotate-90')}>
&#9654;
</span>
</td>
<td className="py-2 px-3 text-muted-foreground whitespace-nowrap">{formattedTime}</td>
<td className="py-2 px-3">
<span className={cn('text-xs px-2 py-0.5 rounded-full font-medium', ACTION_COLORS[log.actionType] ?? 'bg-muted text-muted-foreground')}>
{log.actionType}
</span>
</td>
<td className="py-2 px-3 text-muted-foreground">{log.actorType}</td>
<td className="py-2 px-3 font-mono text-xs">{log.actorId}</td>
<td className="py-2 px-3 text-muted-foreground">{log.resourceType}</td>
<td className="py-2 px-3 font-mono text-xs">{log.resourceId}</td>
<td className="py-2 px-3 max-w-xs truncate">{log.description}</td>
</tr>
{isExpanded && (
<tr className="border-b">
<td colSpan={8} className="p-4 bg-muted/30">
<p className="text-xs font-medium text-muted-foreground mb-2">{t('logs.fullDetail')}</p>
<pre className="text-xs bg-background border rounded-md p-3 overflow-x-auto max-h-64 overflow-y-auto">
<code>{JSON.stringify(log.detail ?? log, null, 2)}</code>
</pre>
</td>
</tr>
)}
</>
);
}