Platform restructured into crates/, added AI service and detector,
migrated control-center-ui to Leptos 0.8
519 lines
15 KiB
TypeScript
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; |