/** * AuditLogsPanel - ZCLAW Audit Logs UI with Merkle Hash Chain Verification * * Phase 3.4 Enhancement: Full-featured audit log viewer with: * - Complete log entry display * - Merkle hash chain verification * - Export functionality (JSON/CSV) * - Search and filter capabilities * - Real-time log streaming */ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Search, Filter, FileJson, FileSpreadsheet, ChevronDown, ChevronUp, Hash, Link, CheckCircle2, XCircle, AlertCircle, RefreshCw, Pause, Play, X, Loader2 } from 'lucide-react'; import { useSecurityStore, AuditLogEntry } from '../store/securityStore'; import { getClient } from '../store/connectionStore'; // === Types === export interface MerkleVerificationResult { valid: boolean; chainDepth: number; rootHash: string; previousHash: string; currentHash: string; brokenAtIndex?: number; } export interface AuditLogFilter { timeRange?: { start: Date; end: Date }; eventTypes?: string[]; actors?: string[]; searchTerm?: string; result?: 'success' | 'failure' | 'all'; } interface EnhancedAuditLogEntry extends AuditLogEntry { // Extended fields from ZCLAW targetResource?: string; operationDetails?: Record; ipAddress?: string; sessionId?: string; // Merkle chain fields hash?: string; previousHash?: string; chainIndex?: number; } // === Helper Functions === function formatTimestamp(timestamp: string): string { try { const date = new Date(timestamp); return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } catch { return timestamp; } } function formatHash(hash: string | undefined): string { if (!hash) return '-'; if (hash.length <= 16) return hash; return `${hash.slice(0, 8)}...${hash.slice(-8)}`; } function truncateText(text: string | undefined, maxLength: number = 30): string { if (!text) return '-'; if (text.length <= maxLength) return text; return `${text.slice(0, maxLength)}...`; } // === Export Functions === function exportToJSON(logs: EnhancedAuditLogEntry[]): string { const exportData = logs.map(log => ({ id: log.id, timestamp: log.timestamp, action: log.action, actor: log.actor || null, result: log.result || null, targetResource: log.targetResource || null, details: log.details || null, ipAddress: log.ipAddress || null, sessionId: log.sessionId || null, hash: log.hash || null, previousHash: log.previousHash || null, })); return JSON.stringify(exportData, null, 2); } function exportToCSV(logs: EnhancedAuditLogEntry[]): string { const headers = [ 'ID', 'Timestamp', 'Action', 'Actor', 'Result', 'Target Resource', 'IP Address', 'Session ID', 'Hash', 'Previous Hash' ]; const rows = logs.map(log => [ log.id, log.timestamp, `"${(log.action || '').replace(/"/g, '""')}"`, `"${(log.actor || '').replace(/"/g, '""')}"`, log.result || '', `"${(log.targetResource || '').replace(/"/g, '""')}"`, log.ipAddress || '', log.sessionId || '', log.hash || '', log.previousHash || '', ].join(',')); return [headers.join(','), ...rows].join('\n'); } function downloadFile(content: string, filename: string, mimeType: string): void { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // === Sub-Components === interface FilterPanelProps { filter: AuditLogFilter; onFilterChange: (filter: AuditLogFilter) => void; eventTypes: string[]; actors: string[]; onReset: () => void; } function FilterPanel({ filter, onFilterChange, eventTypes, actors, onReset }: FilterPanelProps) { // Helper to safely create time range with proper null handling const handleStartTimeChange = (e: React.ChangeEvent) => { const newStart = e.target.value ? new Date(e.target.value) : undefined; const currentEnd = filter.timeRange?.end; onFilterChange({ ...filter, timeRange: (newStart || currentEnd) ? { start: newStart!, end: currentEnd! } : undefined, }); }; const handleEndTimeChange = (e: React.ChangeEvent) => { const newEnd = e.target.value ? new Date(e.target.value) : undefined; const currentStart = filter.timeRange?.start; onFilterChange({ ...filter, timeRange: (currentStart || newEnd) ? { start: currentStart!, end: newEnd! } : undefined, }); }; return (
{/* Time Range */}
{/* Event Type */}
{/* Actor */}
{/* Result */}
{/* Reset Button */}
); } interface LogDetailSidebarProps { log: EnhancedAuditLogEntry | null; onClose: () => void; onVerify: (log: EnhancedAuditLogEntry) => void; verificationResult: MerkleVerificationResult | null; isVerifying: boolean; } function LogDetailSidebar({ log, onClose, onVerify, verificationResult, isVerifying, }: LogDetailSidebarProps) { if (!log) return null; return (

Log Details

{/* Timestamp */}
Timestamp {formatTimestamp(log.timestamp)}
{/* Action */}
Action {log.action}
{/* Actor */}
Actor {log.actor || '-'}
{/* Result */}
Result {log.result === 'success' ? ( ) : log.result === 'failure' ? ( ) : null} {log.result || '-'}
{/* Target Resource */} {log.targetResource && (
Target Resource {log.targetResource}
)} {/* IP Address */} {log.ipAddress && (
IP Address {log.ipAddress}
)} {/* Session ID */} {log.sessionId && (
Session ID {log.sessionId}
)} {/* Details */} {log.details && Object.keys(log.details).length > 0 && (
Details
              {JSON.stringify(log.details, null, 2)}
            
)} {/* Merkle Hash Section */}

Merkle Hash Chain

{/* Current Hash */}
Current Hash {log.hash || 'Not available'}
{/* Previous Hash */}
Previous Hash {log.previousHash || 'Not available'}
{/* Chain Index */} {log.chainIndex !== undefined && (
Chain Index {log.chainIndex}
)} {/* Verify Button */} {/* Verification Result */} {verificationResult && (
{verificationResult.valid ? ( ) : ( )} {verificationResult.valid ? 'Chain Valid' : 'Chain Broken'}
{!verificationResult.valid && verificationResult.brokenAtIndex !== undefined && (

Broken at index: {verificationResult.brokenAtIndex}

)}

Depth: {verificationResult.chainDepth}

)}
); } interface HashChainVisualizationProps { logs: EnhancedAuditLogEntry[]; selectedIndex: number | null; onSelect: (index: number) => void; brokenAtIndex?: number; } function HashChainVisualization({ logs, selectedIndex, onSelect, brokenAtIndex }: HashChainVisualizationProps) { const visibleLogs = logs.slice(0, 10); // Show last 10 for visualization return (

Hash Chain (Last {visibleLogs.length} entries)

{visibleLogs.map((log, idx) => (
{idx < visibleLogs.length - 1 && (
)}
))}
{brokenAtIndex !== undefined && (

Chain broken at index {brokenAtIndex}

)}
); } // === Main Component === export function AuditLogsPanel() { const auditLogs = useSecurityStore((s) => s.auditLogs); const loadAuditLogs = useSecurityStore((s) => s.loadAuditLogs); const isLoading = useSecurityStore((s) => s.auditLogsLoading); const client = getClient(); // State const [limit, setLimit] = useState(50); const [searchTerm, setSearchTerm] = useState(''); const [showFilters, setShowFilters] = useState(false); const [filter, setFilter] = useState({}); const [selectedLog, setSelectedLog] = useState(null); const [verificationResult, setVerificationResult] = useState(null); const [isVerifying, setIsVerifying] = useState(false); const [isStreaming, setIsStreaming] = useState(false); const [isPaused, setIsPaused] = useState(false); const [chainBrokenAt, setChainBrokenAt] = useState(undefined); const logsEndRef = useRef(null); const streamingRef = useRef<(() => void) | null>(null); // Load logs on mount and when limit changes useEffect(() => { loadAuditLogs({ limit }); }, [loadAuditLogs, limit]); // Auto-scroll to latest when streaming useEffect(() => { if (isStreaming && !isPaused && logsEndRef.current) { logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [auditLogs, isStreaming, isPaused]); // Extract unique event types and actors for filters const { eventTypes, actors } = useMemo(() => { const types = new Set(); const actorSet = new Set(); auditLogs.forEach((log) => { if (log.action) types.add(log.action); if (log.actor) actorSet.add(log.actor); }); return { eventTypes: Array.from(types).sort(), actors: Array.from(actorSet).sort(), }; }, [auditLogs]); // Filter logs const filteredLogs = useMemo(() => { let logs = auditLogs as EnhancedAuditLogEntry[]; // Time range filter if (filter.timeRange?.start) { const startTime = filter.timeRange.start.getTime(); logs = logs.filter((log) => new Date(log.timestamp).getTime() >= startTime); } if (filter.timeRange?.end) { const endTime = filter.timeRange.end.getTime(); logs = logs.filter((log) => new Date(log.timestamp).getTime() <= endTime); } // Event type filter if (filter.eventTypes?.length) { logs = logs.filter((log) => filter.eventTypes!.includes(log.action)); } // Actor filter if (filter.actors?.length) { logs = logs.filter((log) => log.actor && filter.actors!.includes(log.actor)); } // Result filter if (filter.result) { logs = logs.filter((log) => log.result === filter.result); } // Search term if (searchTerm.trim()) { const term = searchTerm.toLowerCase(); logs = logs.filter((log) => log.action.toLowerCase().includes(term) || (log.actor?.toLowerCase().includes(term)) || (log.details && JSON.stringify(log.details).toLowerCase().includes(term)) ); } return logs; }, [auditLogs, filter, searchTerm]); // Real-time streaming setup const startStreaming = useCallback(() => { if (streamingRef.current) return; const unsubscribe = client.on('audit_log', () => { if (!isPaused) { // Reload logs when new audit log event arrives loadAuditLogs({ limit }); } }); streamingRef.current = unsubscribe; setIsStreaming(true); }, [client, isPaused, loadAuditLogs, limit]); const stopStreaming = useCallback(() => { if (streamingRef.current) { streamingRef.current(); streamingRef.current = null; } setIsStreaming(false); }, []); const togglePause = useCallback(() => { setIsPaused((prev) => !prev); }, []); // Verify Merkle chain const verifyChain = useCallback(async (log: EnhancedAuditLogEntry) => { setIsVerifying(true); setVerificationResult(null); try { // Call ZCLAW API to verify the chain const result = await client.verifyAuditLogChain(log.id); const verification: MerkleVerificationResult = { valid: result.valid ?? true, chainDepth: result.chain_depth ?? 0, rootHash: result.root_hash ?? '', previousHash: log.previousHash ?? '', currentHash: log.hash ?? '', brokenAtIndex: result.broken_at_index, }; setVerificationResult(verification); if (!verification.valid && verification.brokenAtIndex !== undefined) { setChainBrokenAt(verification.brokenAtIndex); } } catch { // If API not available, do client-side verification const logIndex = auditLogs.findIndex((l) => l.id === log.id); const previousLog = logIndex > 0 ? (auditLogs[logIndex - 1] as EnhancedAuditLogEntry) : null; // Simple verification: check if previous hash matches const isValid = !previousLog || log.previousHash === previousLog.hash; setVerificationResult({ valid: isValid, chainDepth: logIndex + 1, rootHash: (auditLogs[0] as EnhancedAuditLogEntry)?.hash ?? '', previousHash: log.previousHash ?? '', currentHash: log.hash ?? '', brokenAtIndex: isValid ? undefined : logIndex, }); if (!isValid) { setChainBrokenAt(logIndex); } } finally { setIsVerifying(false); } }, [client, auditLogs]); // Export handlers const handleExportJSON = useCallback(() => { const content = exportToJSON(filteredLogs); downloadFile( content, `audit-logs-${new Date().toISOString().slice(0, 10)}.json`, 'application/json' ); }, [filteredLogs]); const handleExportCSV = useCallback(() => { const content = exportToCSV(filteredLogs); downloadFile( content, `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`, 'text/csv' ); }, [filteredLogs]); const handleResetFilters = useCallback(() => { setFilter({}); setSearchTerm(''); }, []); const resultColor = { success: 'text-green-600 dark:text-green-400', failure: 'text-red-600 dark:text-red-400', }; if (isLoading && auditLogs.length === 0) { return (
Loading...
); } return (
{/* Main Content */}
{/* Header */}

Audit Logs

{/* Real-time streaming controls */}
{isStreaming ? ( <> {isPaused ? 'Paused' : 'Live'} ) : ( )}
{/* Search */}
setSearchTerm(e.target.value)} placeholder="Search..." className="pl-8 pr-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 w-48" />
{/* Filter Toggle */} {/* Export */}
{/* Limit selector */} {/* Refresh */}
{/* Filter Panel */} {showFilters && (
)} {/* Hash Chain Visualization */} {filteredLogs.length > 0 && (
l.id === selectedLog.id) : null} onSelect={(idx) => setSelectedLog(filteredLogs[idx])} brokenAtIndex={chainBrokenAt} />
)} {/* Log Table */}
{filteredLogs.length === 0 ? (

No audit logs found

{(searchTerm || Object.keys(filter).length > 0) && ( )}
) : ( {filteredLogs.map((log, index) => ( setSelectedLog(log)} className={`border-b border-gray-100 dark:border-gray-800 cursor-pointer transition-colors ${ selectedLog?.id === log.id ? 'bg-blue-50 dark:bg-blue-900/20' : chainBrokenAt === index ? 'bg-red-50 dark:bg-red-900/10 hover:bg-red-100 dark:hover:bg-red-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50' }`} > ))}
Time Action Actor Result Hash
{formatTimestamp(log.timestamp)} {truncateText(log.action, 40)} {truncateText(log.actor, 20)} {log.result === 'success' ? ( ) : log.result === 'failure' ? ( ) : null} {log.result === 'success' ? 'OK' : log.result === 'failure' ? 'Fail' : '-'} {formatHash(log.hash)}
)}
{/* Stats Footer */}
Showing {filteredLogs.length} of {auditLogs.length} logs {chainBrokenAt !== undefined && ( Chain integrity: BROKEN at index {chainBrokenAt} )}
{/* Detail Sidebar */} {selectedLog && ( { setSelectedLog(null); setVerificationResult(null); }} onVerify={verifyChain} verificationResult={verificationResult} isVerifying={isVerifying} /> )}
); } export default AuditLogsPanel;