353 lines
14 KiB
TypeScript
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')}>
|
|
▶
|
|
</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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|