613 lines
28 KiB
TypeScript
613 lines
28 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
Activity,
|
|
RefreshCw,
|
|
Search,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Eye,
|
|
User as UserIcon,
|
|
Filter
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { API } from '@/api/API';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle
|
|
} from '@/components/ui/dialog';
|
|
|
|
type SystemAuditLog = {
|
|
id: string;
|
|
module: string;
|
|
moduleLabel: string;
|
|
action: string;
|
|
actionLabel: string;
|
|
entityType: string;
|
|
entityId: string | null;
|
|
entityLabel: string | null;
|
|
description: string;
|
|
actor: {
|
|
id: string | null;
|
|
name: string;
|
|
role: string | null;
|
|
email: string | null;
|
|
};
|
|
oldData: any;
|
|
newData: any;
|
|
metadata: any;
|
|
ipAddress: string | null;
|
|
userAgent: string | null;
|
|
timestamp: string;
|
|
};
|
|
|
|
type Summary = {
|
|
totalEntries: number;
|
|
byModule: { module: string; moduleLabel: string; total: number }[];
|
|
lastActivity: SystemAuditLog | null;
|
|
};
|
|
|
|
const MODULE_OPTIONS = [
|
|
{ value: '__all__', label: 'All Modules' },
|
|
{ value: 'QUESTIONNAIRE', label: 'Questionnaire' },
|
|
{ value: 'INTERVIEW_CONFIG', label: 'Interview Configuration' },
|
|
{ value: 'SYSTEM_CONFIG', label: 'System Configuration' },
|
|
{ value: 'SLA_CONFIG', label: 'SLA Configuration' },
|
|
{ value: 'EMAIL_TEMPLATE', label: 'Email Template' },
|
|
{ value: 'MASTER_HIERARCHY', label: 'Master Hierarchy' },
|
|
{ value: 'ROLE_ASSIGNMENT', label: 'Role Assignment' },
|
|
{ value: 'USER_ADMIN', label: 'User Administration' },
|
|
{ value: 'DEALER_MAPPING', label: 'Dealer Mapping' }
|
|
];
|
|
|
|
const ACTION_OPTIONS = [
|
|
{ value: '__all__', label: 'All Actions' },
|
|
{ value: 'CREATED', label: 'Created' },
|
|
{ value: 'UPDATED', label: 'Updated' },
|
|
{ value: 'DELETED', label: 'Deleted' },
|
|
{ value: 'ACTIVATED', label: 'Activated' },
|
|
{ value: 'DEACTIVATED', label: 'Deactivated' },
|
|
{ value: 'INITIALIZED', label: 'Initialized' },
|
|
{ value: 'SUBMITTED', label: 'Submitted' },
|
|
{ value: 'ASSIGNED', label: 'Assigned' },
|
|
{ value: 'UNASSIGNED', label: 'Unassigned' },
|
|
{ value: 'REORDERED', label: 'Reordered' }
|
|
];
|
|
|
|
const ACTION_BADGE_CLASS: Record<string, string> = {
|
|
CREATED: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
|
UPDATED: 'bg-blue-100 text-blue-700 border-blue-200',
|
|
DELETED: 'bg-rose-100 text-rose-700 border-rose-200',
|
|
ACTIVATED: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
|
DEACTIVATED: 'bg-slate-200 text-slate-700 border-slate-300',
|
|
INITIALIZED: 'bg-amber-100 text-amber-700 border-amber-200',
|
|
SUBMITTED: 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
|
ASSIGNED: 'bg-violet-100 text-violet-700 border-violet-200',
|
|
UNASSIGNED: 'bg-slate-200 text-slate-700 border-slate-300',
|
|
REORDERED: 'bg-sky-100 text-sky-700 border-sky-200'
|
|
};
|
|
|
|
const formatTimestamp = (ts: string) => {
|
|
try {
|
|
const d = new Date(ts);
|
|
return d.toLocaleString('en-IN', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
});
|
|
} catch {
|
|
return ts;
|
|
}
|
|
};
|
|
|
|
const JsonBlock = ({ value }: { value: any }) => {
|
|
if (value === null || value === undefined) {
|
|
return <p className="text-xs italic text-slate-400">—</p>;
|
|
}
|
|
return (
|
|
<pre className="text-xs bg-slate-900 text-slate-100 rounded-lg p-3 overflow-auto max-h-72 whitespace-pre-wrap break-words">
|
|
{JSON.stringify(value, null, 2)}
|
|
</pre>
|
|
);
|
|
};
|
|
|
|
export const SystemLogsPage: React.FC = () => {
|
|
const [logs, setLogs] = useState<SystemAuditLog[]>([]);
|
|
const [summary, setSummary] = useState<Summary | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [limit] = useState(25);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [totalEntries, setTotalEntries] = useState(0);
|
|
|
|
const [moduleFilter, setModuleFilter] = useState<string>('__all__');
|
|
const [actionFilter, setActionFilter] = useState<string>('__all__');
|
|
const [search, setSearch] = useState('');
|
|
const [dateFrom, setDateFrom] = useState('');
|
|
const [dateTo, setDateTo] = useState('');
|
|
const [appliedSearch, setAppliedSearch] = useState('');
|
|
|
|
const [selected, setSelected] = useState<SystemAuditLog | null>(null);
|
|
|
|
const queryParams = useMemo(
|
|
() => ({
|
|
module: moduleFilter !== '__all__' ? moduleFilter : undefined,
|
|
action: actionFilter !== '__all__' ? actionFilter : undefined,
|
|
search: appliedSearch || undefined,
|
|
dateFrom: dateFrom || undefined,
|
|
dateTo: dateTo || undefined,
|
|
page,
|
|
limit
|
|
}),
|
|
[moduleFilter, actionFilter, appliedSearch, dateFrom, dateTo, page, limit]
|
|
);
|
|
|
|
const fetchLogs = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = (await API.getSystemAuditLogs(queryParams)) as any;
|
|
const body = res?.data;
|
|
if (res?.ok && body?.success) {
|
|
setLogs(body.data || []);
|
|
setTotalPages(body.pagination?.totalPages || 1);
|
|
setTotalEntries(body.pagination?.total || 0);
|
|
} else {
|
|
toast.error(body?.message || 'Unable to load system logs');
|
|
setLogs([]);
|
|
}
|
|
} catch (err) {
|
|
console.error('[SystemLogsPage] fetchLogs error:', err);
|
|
toast.error('Failed to load system logs');
|
|
setLogs([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [queryParams]);
|
|
|
|
const fetchSummary = useCallback(async () => {
|
|
try {
|
|
const res = (await API.getSystemAuditSummary()) as any;
|
|
const body = res?.data;
|
|
if (res?.ok && body?.success) {
|
|
setSummary(body.data || null);
|
|
}
|
|
} catch (err) {
|
|
console.error('[SystemLogsPage] fetchSummary error:', err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchLogs();
|
|
}, [fetchLogs]);
|
|
|
|
useEffect(() => {
|
|
fetchSummary();
|
|
}, [fetchSummary]);
|
|
|
|
const applySearch = () => {
|
|
setPage(1);
|
|
setAppliedSearch(search.trim());
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setModuleFilter('__all__');
|
|
setActionFilter('__all__');
|
|
setSearch('');
|
|
setAppliedSearch('');
|
|
setDateFrom('');
|
|
setDateTo('');
|
|
setPage(1);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 max-w-7xl mx-auto">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
|
<Activity className="w-6 h-6 text-re-red" />
|
|
System Activity Logs
|
|
</h1>
|
|
<p className="text-slate-500">
|
|
Configuration-level changes across questionnaires, interview configs, master hierarchy, system / SLA configs, and role assignments.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
fetchLogs();
|
|
fetchSummary();
|
|
}}
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Summary */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
|
<Card className="border-slate-200">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
|
Total Events
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-bold text-slate-900">{summary?.totalEntries ?? '—'}</p>
|
|
<p className="text-[11px] text-slate-400 mt-1">Lifetime</p>
|
|
</CardContent>
|
|
</Card>
|
|
{(summary?.byModule || []).slice(0, 4).map((row) => (
|
|
<Card key={row.module} className="border-slate-200">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
|
{row.moduleLabel}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-bold text-slate-900">{row.total}</p>
|
|
<button
|
|
onClick={() => {
|
|
setModuleFilter(row.module);
|
|
setPage(1);
|
|
}}
|
|
className="text-[11px] text-re-red hover:underline mt-1"
|
|
>
|
|
View module →
|
|
</button>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card className="border-slate-200">
|
|
<CardContent className="pt-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3 items-end">
|
|
<div className="lg:col-span-2">
|
|
<label className="text-xs font-semibold text-slate-600 mb-1 block flex items-center gap-1">
|
|
<Search className="w-3 h-3" /> Search
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="Entity name, description, or actor…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') applySearch();
|
|
}}
|
|
/>
|
|
<Button variant="outline" onClick={applySearch}>
|
|
Go
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-semibold text-slate-600 mb-1 block">Module</label>
|
|
<Select
|
|
value={moduleFilter}
|
|
onValueChange={(v) => {
|
|
setModuleFilter(v);
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="All Modules" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{MODULE_OPTIONS.map((m) => (
|
|
<SelectItem key={m.value} value={m.value}>
|
|
{m.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-semibold text-slate-600 mb-1 block">Action</label>
|
|
<Select
|
|
value={actionFilter}
|
|
onValueChange={(v) => {
|
|
setActionFilter(v);
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="All Actions" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ACTION_OPTIONS.map((a) => (
|
|
<SelectItem key={a.value} value={a.value}>
|
|
{a.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-semibold text-slate-600 mb-1 block">From</label>
|
|
<Input
|
|
type="date"
|
|
value={dateFrom}
|
|
onChange={(e) => {
|
|
setDateFrom(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-semibold text-slate-600 mb-1 block">To</label>
|
|
<Input
|
|
type="date"
|
|
value={dateTo}
|
|
onChange={(e) => {
|
|
setDateTo(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mt-4">
|
|
<div className="text-xs text-slate-500 flex items-center gap-2">
|
|
<Filter className="w-3 h-3" />
|
|
Showing {logs.length} of {totalEntries} matching event(s)
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
|
Reset filters
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Logs table */}
|
|
<Card className="border-slate-200 overflow-hidden">
|
|
<div className="overflow-x-auto custom-scrollbar-x-slim">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-50 text-slate-600">
|
|
<tr className="text-left">
|
|
<th className="px-4 py-3 font-semibold">When</th>
|
|
<th className="px-4 py-3 font-semibold">Module</th>
|
|
<th className="px-4 py-3 font-semibold">Action</th>
|
|
<th className="px-4 py-3 font-semibold">Entity</th>
|
|
<th className="px-4 py-3 font-semibold">Actor</th>
|
|
<th className="px-4 py-3 font-semibold">Description</th>
|
|
<th className="px-4 py-3 font-semibold w-10"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{loading && (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-10 text-center text-slate-400">
|
|
<RefreshCw className="w-5 h-5 inline-block mr-2 animate-spin" />
|
|
Loading system logs…
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{!loading && logs.length === 0 && (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-10 text-center text-slate-400">
|
|
No events match the current filters.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{!loading &&
|
|
logs.map((log) => (
|
|
<tr
|
|
key={log.id}
|
|
className="hover:bg-slate-50/70 cursor-pointer"
|
|
onClick={() => setSelected(log)}
|
|
>
|
|
<td className="px-4 py-3 whitespace-nowrap text-slate-700">
|
|
{formatTimestamp(log.timestamp)}
|
|
</td>
|
|
<td className="px-4 py-3 whitespace-nowrap">
|
|
<Badge variant="outline" className="border-slate-300 text-slate-700">
|
|
{log.moduleLabel}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-4 py-3 whitespace-nowrap">
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
ACTION_BADGE_CLASS[log.action] ||
|
|
'bg-slate-100 text-slate-700 border-slate-200'
|
|
}
|
|
>
|
|
{log.actionLabel}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-800">
|
|
<div className="font-medium truncate max-w-[260px]" title={log.entityLabel || ''}>
|
|
{log.entityLabel || `${log.entityType}`}
|
|
</div>
|
|
{log.entityId && (
|
|
<div className="text-[10px] font-mono text-slate-400 truncate max-w-[260px]">
|
|
{log.entityId}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 whitespace-nowrap">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-7 h-7 rounded-full bg-slate-100 text-slate-600 flex items-center justify-center text-xs font-semibold">
|
|
{(log.actor?.name || 'S').charAt(0).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<div className="text-slate-800 leading-tight">{log.actor?.name || 'System'}</div>
|
|
{log.actor?.role && (
|
|
<div className="text-[10px] text-slate-500 uppercase tracking-wider">
|
|
{log.actor.role}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-600">
|
|
<div className="truncate max-w-[360px]" title={log.description}>
|
|
{log.description}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelected(log);
|
|
}}
|
|
title="View details"
|
|
>
|
|
<Eye className="w-4 h-4 text-slate-500" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100 bg-slate-50/50">
|
|
<div className="text-xs text-slate-500">
|
|
Page {page} of {totalPages}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={loading || page <= 1}
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
>
|
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
|
Prev
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={loading || page >= totalPages}
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
>
|
|
Next
|
|
<ChevronRight className="w-4 h-4 ml-1" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Detail dialog */}
|
|
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
|
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
|
{selected && (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Activity className="w-5 h-5 text-re-red" />
|
|
{selected.moduleLabel} · {selected.actionLabel}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-slate-600">
|
|
{selected.description}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
|
|
<div>
|
|
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
|
|
Entity
|
|
</p>
|
|
<p className="text-slate-800">{selected.entityLabel || selected.entityType}</p>
|
|
{selected.entityId && (
|
|
<p className="text-[11px] font-mono text-slate-400 mt-0.5">
|
|
{selected.entityId}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
|
|
Actor
|
|
</p>
|
|
<p className="text-slate-800 flex items-center gap-2">
|
|
<UserIcon className="w-4 h-4 text-slate-400" />
|
|
{selected.actor?.name || 'System'}
|
|
</p>
|
|
{selected.actor?.role && (
|
|
<p className="text-[11px] text-slate-500 uppercase tracking-wider mt-0.5">
|
|
{selected.actor.role}
|
|
</p>
|
|
)}
|
|
{selected.actor?.email && (
|
|
<p className="text-[11px] text-slate-500 mt-0.5">
|
|
{selected.actor.email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
|
|
When
|
|
</p>
|
|
<p className="text-slate-800">{formatTimestamp(selected.timestamp)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
|
|
Source
|
|
</p>
|
|
<p className="text-slate-800">{selected.ipAddress || '—'}</p>
|
|
<p className="text-[11px] text-slate-500 truncate" title={selected.userAgent || ''}>
|
|
{selected.userAgent || ''}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
|
<div>
|
|
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
|
|
Previous values
|
|
</p>
|
|
<JsonBlock value={selected.oldData} />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
|
|
New values
|
|
</p>
|
|
<JsonBlock value={selected.newData} />
|
|
</div>
|
|
</div>
|
|
|
|
{selected.metadata && (
|
|
<div className="mt-4">
|
|
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
|
|
Metadata
|
|
</p>
|
|
<JsonBlock value={selected.metadata} />
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SystemLogsPage;
|