Jesús Pérez 09a97ac8f5
chore: update platform submodule to monorepo crates structure
Platform restructured into crates/, added AI service and detector,
       migrated control-center-ui to Leptos 0.8
2026-01-08 21:32:59 +00:00

519 lines
15 KiB
TypeScript

import React, { useMemo, useCallback, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
SortingState,
ColumnDef,
} from '@tanstack/react-table';
import { format } from 'date-fns';
import {
ChevronUp,
ChevronDown,
ExternalLink,
Shield,
AlertTriangle,
CheckCircle,
XCircle,
MoreHorizontal,
Eye,
Link as LinkIcon,
Clock,
User,
Database,
MapPin
} from 'lucide-react';
import { AuditLogEntry, AuditSeverity } from '@/types/audit';
interface VirtualizedLogTableProps {
logs: AuditLogEntry[];
isLoading: boolean;
hasNextPage: boolean;
fetchNextPage: () => void;
onRowClick: (log: AuditLogEntry) => void;
onViewCorrelated?: (requestId: string) => void;
onViewSession?: (sessionId: string) => void;
selectedLogIds?: Set<string>;
onSelectionChange?: (selectedIds: Set<string>) => void;
}
const SeverityBadge: React.FC<{ severity: AuditSeverity }> = ({ severity }) => {
const className = `severity-indicator severity-${severity}`;
const icon = {
low: CheckCircle,
medium: AlertTriangle,
high: AlertTriangle,
critical: XCircle
}[severity];
const Icon = icon;
return (
<span className={className}>
<Icon className="h-3 w-3 mr-1" />
{severity.charAt(0).toUpperCase() + severity.slice(1)}
</span>
);
};
const StatusBadge: React.FC<{ success: boolean }> = ({ success }) => {
return (
<span className={`status-indicator ${success ? 'status-success' : 'status-error'}`}>
{success ? (
<CheckCircle className="h-3 w-3 mr-1" />
) : (
<XCircle className="h-3 w-3 mr-1" />
)}
{success ? 'Success' : 'Failed'}
</span>
);
};
const ActionCell: React.FC<{ log: AuditLogEntry }> = ({ log }) => {
const actionTypeMap = {
authentication: 'Auth',
authorization: 'Authz',
policy_evaluation: 'Policy',
policy_creation: 'Create Policy',
policy_update: 'Update Policy',
policy_deletion: 'Delete Policy',
user_creation: 'Create User',
user_update: 'Update User',
user_deletion: 'Delete User',
role_assignment: 'Assign Role',
role_revocation: 'Revoke Role',
data_access: 'Data Access',
data_modification: 'Data Modify',
data_deletion: 'Data Delete',
system_configuration: 'Config',
backup_creation: 'Backup',
backup_restoration: 'Restore',
security_incident: 'Security',
compliance_check: 'Compliance',
anomaly_detection: 'Anomaly',
session_start: 'Login',
session_end: 'Logout',
mfa_challenge: 'MFA',
password_change: 'Password',
api_access: 'API'
};
return (
<div className="flex flex-col">
<div className="font-medium text-sm">
{actionTypeMap[log.action.type] || log.action.type}
</div>
<div className="text-xs text-base-content/60 truncate max-w-32">
{log.action.resource}
</div>
</div>
);
};
const UserCell: React.FC<{ log: AuditLogEntry }> = ({ log }) => {
return (
<div className="flex flex-col">
<div className="font-medium text-sm">{log.user.username}</div>
<div className="text-xs text-base-content/60">
{log.user.roles.join(', ')}
</div>
</div>
);
};
const ContextCell: React.FC<{ log: AuditLogEntry }> = ({ log }) => {
return (
<div className="flex flex-col">
<div className="text-xs text-base-content/60">{log.context.ipAddress}</div>
{log.context.location && (
<div className="text-xs text-base-content/50 flex items-center">
<MapPin className="h-3 w-3 mr-1" />
{log.context.location.city}, {log.context.location.country}
</div>
)}
</div>
);
};
const ActionsMenu: React.FC<{
log: AuditLogEntry;
onViewDetails: () => void;
onViewCorrelated?: (requestId: string) => void;
onViewSession?: (sessionId: string) => void;
}> = ({ log, onViewDetails, onViewCorrelated, onViewSession }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="dropdown dropdown-end">
<label
tabIndex={0}
className="btn btn-ghost btn-xs"
onClick={() => setIsOpen(!isOpen)}
>
<MoreHorizontal className="h-4 w-4" />
</label>
{isOpen && (
<ul className="dropdown-content z-50 menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-300">
<li>
<button onClick={onViewDetails} className="text-sm">
<Eye className="h-4 w-4" />
View Details
</button>
</li>
{onViewCorrelated && (
<li>
<button
onClick={() => onViewCorrelated(log.context.requestId)}
className="text-sm"
>
<LinkIcon className="h-4 w-4" />
View Correlated
</button>
</li>
)}
{onViewSession && (
<li>
<button
onClick={() => onViewSession(log.context.sessionId)}
className="text-sm"
>
<User className="h-4 w-4" />
View Session
</button>
</li>
)}
<div className="divider my-1"></div>
<li>
<button
onClick={() => navigator.clipboard.writeText(log.id)}
className="text-sm"
>
Copy Log ID
</button>
</li>
<li>
<button
onClick={() => navigator.clipboard.writeText(log.context.requestId)}
className="text-sm"
>
Copy Request ID
</button>
</li>
</ul>
)}
</div>
);
};
export const VirtualizedLogTable: React.FC<VirtualizedLogTableProps> = ({
logs,
isLoading,
hasNextPage,
fetchNextPage,
onRowClick,
onViewCorrelated,
onViewSession,
selectedLogIds = new Set(),
onSelectionChange
}) => {
const [sorting, setSorting] = useState<SortingState>([]);
const columns = useMemo<ColumnDef<AuditLogEntry>[]>(() => [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={selectedLogIds.has(row.original.id)}
onChange={(e) => {
if (onSelectionChange) {
const newSelection = new Set(selectedLogIds);
if (e.target.checked) {
newSelection.add(row.original.id);
} else {
newSelection.delete(row.original.id);
}
onSelectionChange(newSelection);
}
}}
/>
),
size: 50,
},
{
accessorKey: 'timestamp',
header: 'Time',
cell: ({ getValue }) => {
const timestamp = getValue() as Date;
return (
<div className="flex flex-col">
<div className="text-sm font-medium">
{format(timestamp, 'MMM dd, HH:mm:ss')}
</div>
<div className="text-xs text-base-content/60">
{format(timestamp, 'yyyy')}
</div>
</div>
);
},
size: 120,
},
{
id: 'user',
header: 'User',
cell: ({ row }) => <UserCell log={row.original} />,
size: 150,
},
{
id: 'action',
header: 'Action',
cell: ({ row }) => <ActionCell log={row.original} />,
size: 180,
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => <StatusBadge success={row.original.result.success} />,
size: 100,
},
{
id: 'severity',
header: 'Severity',
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
size: 120,
},
{
id: 'context',
header: 'Context',
cell: ({ row }) => <ContextCell log={row.original} />,
size: 140,
},
{
id: 'compliance',
header: 'Compliance',
cell: ({ row }) => {
const compliance = row.original.compliance;
const frameworks = [];
if (compliance.soc2Relevant) frameworks.push('SOC2');
if (compliance.hipaaRelevant) frameworks.push('HIPAA');
if (compliance.pciRelevant) frameworks.push('PCI');
if (compliance.gdprRelevant) frameworks.push('GDPR');
return (
<div className="flex flex-wrap gap-1">
{frameworks.slice(0, 2).map((framework) => (
<span key={framework} className="badge badge-outline badge-xs">
{framework}
</span>
))}
{frameworks.length > 2 && (
<span className="badge badge-outline badge-xs">
+{frameworks.length - 2}
</span>
)}
</div>
);
},
size: 120,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<ActionsMenu
log={row.original}
onViewDetails={() => onRowClick(row.original)}
onViewCorrelated={onViewCorrelated}
onViewSession={onViewSession}
/>
),
size: 60,
},
], [selectedLogIds, onSelectionChange, onRowClick, onViewCorrelated, onViewSession]);
const table = useReactTable({
data: logs,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
debugTable: process.env.NODE_ENV === 'development',
});
const { rows } = table.getRowModel();
// Create a parent ref for the virtualizer
const parentRef = React.useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? rows.length + 1 : rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80,
overscan: 10,
});
// Load more data when scrolling near the end
const virtualItems = rowVirtualizer.getVirtualItems();
const lastItem = virtualItems[virtualItems.length - 1];
React.useEffect(() => {
if (
lastItem &&
lastItem.index >= rows.length - 1 &&
hasNextPage &&
!isLoading
) {
fetchNextPage();
}
}, [lastItem, hasNextPage, fetchNextPage, isLoading, rows.length]);
const handleRowClick = useCallback((log: AuditLogEntry, event: React.MouseEvent) => {
// Don't trigger row click if clicking on checkbox, dropdown, or buttons
const target = event.target as HTMLElement;
if (target.closest('input') || target.closest('.dropdown') || target.closest('button')) {
return;
}
onRowClick(log);
}, [onRowClick]);
if (logs.length === 0 && !isLoading) {
return (
<div className="flex flex-col items-center justify-center h-64 text-base-content/60">
<Database className="h-12 w-12 mb-4" />
<h3 className="text-lg font-semibold mb-2">No audit logs found</h3>
<p className="text-sm">Try adjusting your search filters or date range.</p>
</div>
);
}
return (
<div className="bg-base-100 border border-base-300 rounded-lg overflow-hidden">
{/* Table Header */}
<div className="border-b border-base-300">
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-base-200 text-sm font-medium text-base-content">
<div className="col-span-1">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
</div>
{table.getFlatHeaders().slice(1).map((header, index) => (
<div
key={header.id}
className={`${
index === 0 ? 'col-span-2' :
index === 1 ? 'col-span-2' :
index === 2 ? 'col-span-2' :
index === 6 ? 'col-span-2' :
'col-span-1'
} cursor-pointer flex items-center space-x-1`}
onClick={header.column.getToggleSortingHandler()}
>
<span>
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
{header.column.getCanSort() && (
<div className="flex flex-col">
{header.column.getIsSorted() === 'asc' ? (
<ChevronUp className="h-3 w-3" />
) : header.column.getIsSorted() === 'desc' ? (
<ChevronDown className="h-3 w-3" />
) : (
<div className="h-3 w-3" />
)}
</div>
)}
</div>
))}
</div>
</div>
{/* Virtualized Table Body */}
<div
ref={parentRef}
className="h-96 overflow-auto scrollbar-thin"
style={{ contain: 'strict' }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const isLoaderRow = virtualRow.index > rows.length - 1;
const row = rows[virtualRow.index];
return (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
hasNextPage ? (
<div className="flex items-center justify-center h-full">
<div className="loading-spinner"></div>
<span className="ml-2 text-base-content/60">Loading more logs...</span>
</div>
) : null
) : (
<div
className="grid grid-cols-12 gap-4 px-4 py-3 border-b border-base-300 hover:bg-base-50 cursor-pointer transition-colors"
onClick={(e) => handleRowClick(row.original, e)}
>
{row.getVisibleCells().map((cell, cellIndex) => (
<div
key={cell.id}
className={`${
cellIndex === 1 ? 'col-span-2' :
cellIndex === 2 ? 'col-span-2' :
cellIndex === 3 ? 'col-span-2' :
cellIndex === 7 ? 'col-span-2' :
'col-span-1'
} flex items-center`}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Footer with selection info */}
{selectedLogIds.size > 0 && (
<div className="border-t border-base-300 px-4 py-2 bg-base-200 text-sm text-base-content/60">
{selectedLogIds.size} log{selectedLogIds.size === 1 ? '' : 's'} selected
</div>
)}
</div>
);
};
export default VirtualizedLogTable;