Implemented audit logging
This commit is contained in:
@@ -20,6 +20,8 @@ import {
|
||||
Award as AwardIcon,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
AlertOctagon,
|
||||
Layers,
|
||||
Activity,
|
||||
Calendar,
|
||||
@@ -370,8 +372,14 @@ export const SettingsPage: React.FC = () => {
|
||||
const [auditSearchTerm, setAuditSearchTerm] = useState('');
|
||||
const [auditEntityFilter, setAuditEntityFilter] = useState<string>('all');
|
||||
const [auditActionFilter, setAuditActionFilter] = useState<string>('all');
|
||||
const [auditTypeFilter, setAuditTypeFilter] = useState<'all' | 'audits' | 'exceptions'>('all');
|
||||
const [auditPage, setAuditPage] = useState(1);
|
||||
const [auditPageSize, setAuditPageSize] = useState(10);
|
||||
const [selectedAuditLog, setSelectedAuditLog] = useState<AuditLog | null>(null);
|
||||
const [isAuditDetailModalOpen, setIsAuditDetailModalOpen] = useState(false);
|
||||
const [selectedExceptionLog, setSelectedExceptionLog] = useState<AuditLog | null>(null);
|
||||
const [isExceptionDetailModalOpen, setIsExceptionDetailModalOpen] = useState(false);
|
||||
const [copiedExceptionTrace, setCopiedExceptionTrace] = useState(false);
|
||||
const [copiedAuditPayload, setCopiedAuditPayload] = useState(false);
|
||||
const [expandedAuditLogIds, setExpandedAuditLogIds] = useState<Record<string | number, boolean>>({});
|
||||
const [copiedUserId, setCopiedUserId] = useState<string | number | null>(null);
|
||||
@@ -402,11 +410,14 @@ export const SettingsPage: React.FC = () => {
|
||||
isFetching: isFetchingAuditLogs,
|
||||
} = useQuery({
|
||||
queryKey: ['audit-logs'],
|
||||
queryFn: () => getAuditLogs(1, 100),
|
||||
queryFn: () => getAuditLogs(1, 500),
|
||||
});
|
||||
|
||||
const isLogException = (log: AuditLog) => Boolean(log.error || log.isError);
|
||||
|
||||
const auditExportColumns: ExportColumn<AuditLog>[] = [
|
||||
{ header: 'Event ID', key: 'id' },
|
||||
{ header: 'Event Type', key: 'error', format: (val, row) => (val || row?.isError ? 'EXCEPTION' : 'AUDIT') },
|
||||
{ header: 'Timestamp', key: 'timestamp', format: (val) => formatDate(val) },
|
||||
{ header: 'Action', key: 'action', format: (val) => val || '—' },
|
||||
{ header: 'Entity Name', key: 'entityName', format: (val) => val || '—' },
|
||||
@@ -421,10 +432,11 @@ export const SettingsPage: React.FC = () => {
|
||||
},
|
||||
{ header: 'Tenant ID', key: 'tenantId', format: (val) => (val ? `Tenant #${val}` : '—') },
|
||||
{
|
||||
header: 'Changes',
|
||||
header: 'Changes / Arguments',
|
||||
key: 'changes',
|
||||
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
|
||||
},
|
||||
{ header: 'Error Details', key: 'error', format: (val) => val || '—' },
|
||||
];
|
||||
|
||||
const uniqueAuditEntities = Array.from(
|
||||
@@ -435,6 +447,13 @@ export const SettingsPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const filteredAuditLogs = auditLogs.filter((log) => {
|
||||
const isEx = isLogException(log);
|
||||
if (auditTypeFilter === 'audits' && isEx) {
|
||||
return false;
|
||||
}
|
||||
if (auditTypeFilter === 'exceptions' && !isEx) {
|
||||
return false;
|
||||
}
|
||||
if (auditEntityFilter !== 'all' && (log.entityName || '').toLowerCase() !== auditEntityFilter.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
@@ -449,6 +468,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const entityId = String(log.entityId || '').toLowerCase();
|
||||
const performedBy = (log.performedBy || '').toLowerCase();
|
||||
const tenant = String(log.tenantId || '').toLowerCase();
|
||||
const err = (log.error || '').toLowerCase();
|
||||
const changes = Array.isArray(log.changes) ? log.changes.join(' ').toLowerCase() : '';
|
||||
return (
|
||||
id.includes(q) ||
|
||||
@@ -457,6 +477,7 @@ export const SettingsPage: React.FC = () => {
|
||||
entityId.includes(q) ||
|
||||
performedBy.includes(q) ||
|
||||
tenant.includes(q) ||
|
||||
err.includes(q) ||
|
||||
changes.includes(q)
|
||||
);
|
||||
});
|
||||
@@ -468,6 +489,13 @@ export const SettingsPage: React.FC = () => {
|
||||
handleSort: handleAuditSort,
|
||||
} = useTableSort(filteredAuditLogs, 'timestamp', 'desc');
|
||||
|
||||
const totalAuditRecords = sortedAuditLogs.length;
|
||||
const totalAuditPages = Math.max(1, Math.ceil(totalAuditRecords / auditPageSize));
|
||||
const paginatedAuditLogs = React.useMemo(() => {
|
||||
const start = (auditPage - 1) * auditPageSize;
|
||||
return sortedAuditLogs.slice(start, start + auditPageSize);
|
||||
}, [sortedAuditLogs, auditPage, auditPageSize]);
|
||||
|
||||
const userExportColumns: ExportColumn<User>[] = [
|
||||
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
|
||||
{ header: 'User Identifier', key: 'id' },
|
||||
@@ -1532,7 +1560,10 @@ export const SettingsPage: React.FC = () => {
|
||||
<input
|
||||
type="text"
|
||||
value={auditSearchTerm}
|
||||
onChange={(e) => setAuditSearchTerm(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAuditSearchTerm(e.target.value);
|
||||
setAuditPage(1);
|
||||
}}
|
||||
placeholder="Search audit trail by action, entity name, ID, performed by UUID..."
|
||||
className="w-full pl-9 pr-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
@@ -1543,7 +1574,10 @@ export const SettingsPage: React.FC = () => {
|
||||
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Entity:</span>
|
||||
<select
|
||||
value={auditEntityFilter}
|
||||
onChange={(e) => setAuditEntityFilter(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAuditEntityFilter(e.target.value);
|
||||
setAuditPage(1);
|
||||
}}
|
||||
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
||||
>
|
||||
<option value="all">All Entities</option>
|
||||
@@ -1568,7 +1602,10 @@ export const SettingsPage: React.FC = () => {
|
||||
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Action:</span>
|
||||
<select
|
||||
value={auditActionFilter}
|
||||
onChange={(e) => setAuditActionFilter(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAuditActionFilter(e.target.value);
|
||||
setAuditPage(1);
|
||||
}}
|
||||
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
||||
>
|
||||
<option value="all">All Actions</option>
|
||||
@@ -1580,13 +1617,31 @@ export const SettingsPage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all') && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Type:</span>
|
||||
<select
|
||||
value={auditTypeFilter}
|
||||
onChange={(e) => {
|
||||
setAuditTypeFilter(e.target.value as 'all' | 'audits' | 'exceptions');
|
||||
setAuditPage(1);
|
||||
}}
|
||||
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
||||
>
|
||||
<option value="all">All Types ({auditLogs.length})</option>
|
||||
<option value="audits">Audits Only ({auditLogs.filter((l) => !l.error && !l.isError).length})</option>
|
||||
<option value="exceptions">Exceptions Only ({auditLogs.filter((l) => Boolean(l.error || l.isError)).length})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all' || auditTypeFilter !== 'all') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuditSearchTerm('');
|
||||
setAuditEntityFilter('all');
|
||||
setAuditActionFilter('all');
|
||||
setAuditTypeFilter('all');
|
||||
setAuditPage(1);
|
||||
}}
|
||||
className="px-2.5 py-1.5 text-xs text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
@@ -1624,12 +1679,18 @@ export const SettingsPage: React.FC = () => {
|
||||
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-4 h-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Audit Log Ledger ({filteredAuditLogs.length} of {auditLogs.length})
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<span>Audit Log Ledger ({filteredAuditLogs.length} of {auditLogs.length})</span>
|
||||
{auditLogs.some((l) => Boolean(l.error || l.isError)) && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
{auditLogs.filter((l) => Boolean(l.error || l.isError)).length} Exceptions
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<HelpTooltip
|
||||
title="Audit Trail"
|
||||
content="Authoritative record of state changes and system activities."
|
||||
content="Authoritative record of state changes, exceptions, and system activities."
|
||||
size="sm"
|
||||
placement="right"
|
||||
/>
|
||||
@@ -1671,13 +1732,14 @@ export const SettingsPage: React.FC = () => {
|
||||
<tr>
|
||||
<td colSpan={6} className="py-12 text-center text-slate-500">
|
||||
<History className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all'
|
||||
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all' || auditTypeFilter !== 'all'
|
||||
? 'No audit log entries match your filter criteria.'
|
||||
: 'No audit records found.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedAuditLogs.map((log) => {
|
||||
paginatedAuditLogs.map((log) => {
|
||||
const isException = Boolean(log.error || log.isError);
|
||||
const badge = getActionBadge(log.action);
|
||||
const isExpanded = !!expandedAuditLogIds[log.id];
|
||||
const changesCount = Array.isArray(log.changes) ? log.changes.length : 0;
|
||||
@@ -1689,8 +1751,14 @@ export const SettingsPage: React.FC = () => {
|
||||
<React.Fragment key={log.id}>
|
||||
<tr
|
||||
onClick={() => toggleExpandAuditLog(log.id)}
|
||||
className={`hover:bg-slate-800/40 transition-colors cursor-pointer ${
|
||||
isExpanded ? 'bg-slate-800/30' : ''
|
||||
className={`transition-colors cursor-pointer border-l-4 ${
|
||||
isException
|
||||
? isExpanded
|
||||
? 'border-l-rose-500 bg-rose-950/25 hover:bg-rose-950/35'
|
||||
: 'border-l-rose-500 bg-rose-950/15 hover:bg-rose-950/25'
|
||||
: isExpanded
|
||||
? 'border-l-blue-500 bg-slate-800/40 hover:bg-slate-800/50'
|
||||
: 'border-l-transparent hover:bg-slate-800/40'
|
||||
}`}
|
||||
>
|
||||
<td className="py-3.5 px-3 text-center">
|
||||
@@ -1705,7 +1773,13 @@ export const SettingsPage: React.FC = () => {
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90 text-blue-400' : 'text-slate-500'
|
||||
isExpanded
|
||||
? isException
|
||||
? 'rotate-90 text-rose-400'
|
||||
: 'rotate-90 text-blue-400'
|
||||
: isException
|
||||
? 'text-rose-400/80'
|
||||
: 'text-slate-500'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
@@ -1714,12 +1788,33 @@ export const SettingsPage: React.FC = () => {
|
||||
{formatDate(log.timestamp)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 font-mono font-bold text-white whitespace-nowrap">
|
||||
#{log.id}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>#{log.id}</span>
|
||||
{isException && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-rose-950 text-rose-300 border border-rose-700/60 shadow-xs"
|
||||
title="Exception captured"
|
||||
>
|
||||
<AlertTriangle className="w-2.5 h-2.5 text-rose-400 shrink-0" />
|
||||
Exception
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
{isException ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border bg-rose-500/10 text-rose-400 border-rose-500/30">
|
||||
<AlertCircle className="w-3 h-3 text-rose-400 shrink-0" />
|
||||
<span>{log.action || 'EXCEPTION'}</span>
|
||||
<span className="ml-0.5 px-1 py-0.2 rounded bg-rose-500/20 text-[9px] font-bold text-rose-300 uppercase">
|
||||
FAIL
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className="py-3.5 px-4"
|
||||
@@ -1749,18 +1844,34 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedAuditLog(log);
|
||||
setIsAuditDetailModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
|
||||
>
|
||||
<Eye className="w-3 h-3 text-blue-400" />
|
||||
Inspect
|
||||
</button>
|
||||
{isException ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedExceptionLog(log);
|
||||
setIsExceptionDetailModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-rose-300 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/30 rounded-lg transition cursor-pointer"
|
||||
title="Inspect exception details"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
Inspect Error
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedAuditLog(log);
|
||||
setIsAuditDetailModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
|
||||
>
|
||||
<Eye className="w-3 h-3 text-blue-400" />
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1771,11 +1882,21 @@ export const SettingsPage: React.FC = () => {
|
||||
<div className="rounded-xl border border-slate-800/90 bg-[#070a12]/90 p-4 space-y-3 shadow-inner">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80 pb-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
{isException ? (
|
||||
<AlertOctagon className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
) : (
|
||||
<Layers className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
)}
|
||||
<h4 className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
Recorded Changes Content (Event #{log.id})
|
||||
{isException
|
||||
? `Captured Exception & Invocation Payload (Event #${log.id})`
|
||||
: `Recorded Changes Content (Event #${log.id})`}
|
||||
</h4>
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-semibold shrink-0">
|
||||
<span className={`text-[10px] font-mono px-2 py-0.5 rounded-full font-semibold shrink-0 ${
|
||||
isException
|
||||
? 'bg-rose-500/10 text-rose-400 border border-rose-500/20'
|
||||
: 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
|
||||
}`}>
|
||||
{changesCount} {changesCount === 1 ? 'Entry' : 'Entries'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1804,6 +1925,32 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exception Diagnostic Banner inside expanded shelf */}
|
||||
{log.error && (
|
||||
<div className="p-3 bg-rose-950/40 border border-rose-800/60 rounded-xl space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold text-rose-300 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-rose-400" />
|
||||
Captured Exception Diagnostic Trace
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(log.error || '');
|
||||
success('Copied', 'Exception details copied to clipboard.');
|
||||
}}
|
||||
className="text-[10px] text-rose-300 hover:text-white bg-rose-900/50 hover:bg-rose-900 px-2 py-0.5 rounded border border-rose-700/60 transition cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Copy className="w-3 h-3" /> Copy Details
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs font-mono text-rose-200 whitespace-pre-wrap break-all bg-black/40 p-2.5 rounded-lg border border-rose-900/40 max-h-40 overflow-y-auto leading-relaxed">
|
||||
{log.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changes List */}
|
||||
{Array.isArray(log.changes) && log.changes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
@@ -1823,7 +1970,7 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-xl bg-slate-900/50 border border-slate-800/80 text-center text-slate-400 text-xs font-mono">
|
||||
No itemized field changes content recorded for this event.
|
||||
No itemized field changes or invocation arguments recorded for this event.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1837,6 +1984,64 @@ export const SettingsPage: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalAuditRecords > 0 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3 border-t border-slate-800/80 bg-slate-900/40 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
Showing <strong className="text-white font-mono">{Math.min((auditPage - 1) * auditPageSize + 1, totalAuditRecords)}</strong> to{' '}
|
||||
<strong className="text-white font-mono">{Math.min(auditPage * auditPageSize, totalAuditRecords)}</strong> of{' '}
|
||||
<strong className="text-white font-mono">{totalAuditRecords}</strong> records
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-400">Per page:</span>
|
||||
<select
|
||||
value={auditPageSize}
|
||||
onChange={(e) => {
|
||||
setAuditPageSize(Number(e.target.value));
|
||||
setAuditPage(1);
|
||||
}}
|
||||
className="bg-slate-900 border border-slate-700/80 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuditPage((p) => Math.max(1, p - 1))}
|
||||
disabled={auditPage <= 1}
|
||||
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
title="Previous page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span className="px-2 font-mono text-slate-300">
|
||||
{auditPage} / {totalAuditPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuditPage((p) => Math.min(totalAuditPages, p + 1))}
|
||||
disabled={auditPage >= totalAuditPages}
|
||||
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
title="Next page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1990,7 +2195,195 @@ export const SettingsPage: React.FC = () => {
|
||||
</Modal>
|
||||
|
||||
{/* ============================================================== */}
|
||||
{/* Modal: Dispatch Invitation */}
|
||||
{/* Modal: Exception Detail Inspection Modal */}
|
||||
{/* ============================================================== */}
|
||||
<Modal
|
||||
isOpen={isExceptionDetailModalOpen}
|
||||
onClose={() => {
|
||||
setIsExceptionDetailModalOpen(false);
|
||||
setSelectedExceptionLog(null);
|
||||
setCopiedExceptionTrace(false);
|
||||
}}
|
||||
title={`Captured Exception: Event #${selectedExceptionLog?.id || ''}`}
|
||||
subtitle="Runtime exception diagnostics and invocation telemetry"
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{selectedExceptionLog && (
|
||||
<div className="space-y-4 text-xs min-w-0">
|
||||
{/* Header Alert Box */}
|
||||
<div className="p-4 bg-rose-950/30 border border-rose-500/30 rounded-xl space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-rose-900/50 pb-2.5 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-rose-500/20 border border-rose-500/30 flex items-center justify-center text-rose-400 shrink-0">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white font-bold text-sm block">System Exception Captured</span>
|
||||
<span className="text-[11px] text-rose-300/80 font-mono">
|
||||
Action: <strong className="text-white">{selectedExceptionLog.action || 'Unknown'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/40">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-rose-400" />
|
||||
FAILED EXECUTION
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Entity and Context Details */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity / Target:</span>
|
||||
<span className="text-white font-semibold">
|
||||
{selectedExceptionLog.entityName || 'General'}{' '}
|
||||
{selectedExceptionLog.entityId ? `(#${selectedExceptionLog.entityId})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Logged Timestamp:</span>
|
||||
<span className="font-mono text-slate-300">{formatDate(selectedExceptionLog.timestamp)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Triggered By:</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="font-mono text-slate-200 text-[11px] truncate" title={selectedExceptionLog.performedBy || 'System'}>
|
||||
{getUserEmail(selectedExceptionLog.performedBy) !== '—'
|
||||
? getUserEmail(selectedExceptionLog.performedBy)
|
||||
: (selectedExceptionLog.performedBy || 'System')}
|
||||
</span>
|
||||
{selectedExceptionLog.performedBy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleCopyUserId(selectedExceptionLog.performedBy, e)}
|
||||
className="p-1 rounded hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer"
|
||||
title={`Copy User ID: ${selectedExceptionLog.performedBy}`}
|
||||
>
|
||||
{copiedUserId === selectedExceptionLog.performedBy ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5 hover:text-blue-400" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Tenant Scope:</span>
|
||||
<span className="font-mono text-slate-300">
|
||||
{selectedExceptionLog.tenantId ? `Tenant #${selectedExceptionLog.tenantId}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message & Stack Trace Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-300 font-bold uppercase text-[11px] flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-rose-400" />
|
||||
Exception Diagnostic Message & Trace
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const textToCopy = selectedExceptionLog.error || 'No error details recorded.';
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setCopiedExceptionTrace(true);
|
||||
success('Exception Copied', 'Exception trace copied to clipboard.');
|
||||
setTimeout(() => setCopiedExceptionTrace(false), 2000);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white border border-slate-700 text-[11px] transition cursor-pointer"
|
||||
>
|
||||
{copiedExceptionTrace ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 text-blue-400" />
|
||||
<span>Copy Trace</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#050810] border border-rose-900/50 rounded-xl p-4 overflow-hidden shadow-inner">
|
||||
<pre className="font-mono text-rose-400 text-xs whitespace-pre-wrap break-all max-h-72 overflow-y-auto leading-relaxed selection:bg-rose-900 selection:text-white">
|
||||
{selectedExceptionLog.error || 'No stack trace or error message captured.'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invocation Arguments / Parameters */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-slate-300 font-bold uppercase text-[11px] flex items-center gap-1.5">
|
||||
<Layers className="w-3.5 h-3.5 text-blue-400" />
|
||||
Invocation Arguments & Parameters
|
||||
</span>
|
||||
|
||||
{Array.isArray(selectedExceptionLog.changes) && selectedExceptionLog.changes.length > 0 ? (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{selectedExceptionLog.changes.map((param, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2.5 p-2.5 rounded-lg bg-slate-900/90 border border-slate-800 text-xs font-mono"
|
||||
>
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 border border-blue-500/20 text-[10px] font-bold shrink-0 mt-0.5">
|
||||
#{idx + 1}
|
||||
</span>
|
||||
<span className="break-all text-slate-300 flex-1 leading-relaxed">
|
||||
{param}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-center text-slate-500 font-mono text-[11px]">
|
||||
No parameters or arguments recorded for this invocation.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Raw JSON Data Viewer */}
|
||||
<div className="border-t border-slate-800/80 pt-2.5 space-y-1.5 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 font-semibold text-[11px] uppercase">
|
||||
Raw Exception Record (JSON):
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(JSON.stringify(selectedExceptionLog, null, 2));
|
||||
success('Copied', 'JSON payload copied to clipboard.');
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-medium text-slate-400 hover:text-white bg-slate-800 px-2 py-1 rounded transition cursor-pointer"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>Copy JSON</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 rounded-lg bg-[#070a12] border border-slate-800/80 text-rose-300/90 font-mono text-[10.5px] overflow-x-auto whitespace-pre-wrap break-all max-h-40 min-w-0">
|
||||
{JSON.stringify(selectedExceptionLog, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-end pt-3 border-t border-slate-800 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsExceptionDetailModalOpen(false);
|
||||
setSelectedExceptionLog(null);
|
||||
}}
|
||||
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{/* ============================================================== */}
|
||||
<Modal
|
||||
isOpen={isSendInviteModalOpen}
|
||||
|
||||
Reference in New Issue
Block a user