Files
zclaw_openfang/desktop/src/components/AuditLogsPanel.tsx
iven 0d4fa96b82
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
refactor: 统一项目名称从OpenFang到ZCLAW
重构所有代码和文档中的项目名称,将OpenFang统一更新为ZCLAW。包括:
- 配置文件中的项目名称
- 代码注释和文档引用
- 环境变量和路径
- 类型定义和接口名称
- 测试用例和模拟数据

同时优化部分代码结构,移除未使用的模块,并更新相关依赖项。
2026-03-27 07:36:03 +08:00

961 lines
34 KiB
TypeScript

/**
* 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<string, unknown>;
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className="space-y-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
{/* Time Range */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Time Range
</label>
<div className="flex gap-2">
<input
type="datetime-local"
value={filter.timeRange?.start?.toISOString().slice(0, 16) || ''}
onChange={handleStartTimeChange}
className="flex-1 text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300"
placeholder="Start"
/>
<input
type="datetime-local"
value={filter.timeRange?.end?.toISOString().slice(0, 16) || ''}
onChange={handleEndTimeChange}
className="flex-1 text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300"
placeholder="End"
/>
</div>
</div>
{/* Event Type */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Event Type
</label>
<select
multiple
value={filter.eventTypes || []}
onChange={(e) => onFilterChange({
...filter,
eventTypes: Array.from(e.target.selectedOptions, (opt) => opt.value),
})}
className="w-full text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300"
size={3}
>
{eventTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
{/* Actor */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Actor
</label>
<select
multiple
value={filter.actors || []}
onChange={(e) => onFilterChange({
...filter,
actors: Array.from(e.target.selectedOptions, (opt) => opt.value),
})}
className="w-full text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300"
size={3}
>
{actors.map((actor) => (
<option key={actor} value={actor}>
{actor}
</option>
))}
</select>
</div>
{/* Result */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Result
</label>
<select
value={filter.result || 'all'}
onChange={(e) => onFilterChange({
...filter,
result: e.target.value === 'all' ? undefined : e.target.value as 'success' | 'failure',
})}
className="w-full text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300"
>
<option value="all">All</option>
<option value="success">Success</option>
<option value="failure">Failure</option>
</select>
</div>
{/* Reset Button */}
<button
onClick={onReset}
className="w-full text-xs px-3 py-1.5 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
Reset Filters
</button>
</div>
);
}
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 (
<div className="w-80 border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Log Details</h3>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3">
{/* Timestamp */}
<div>
<span className="block text-xs font-medium text-gray-500 dark:text-gray-400">Timestamp</span>
<span className="text-sm text-gray-900 dark:text-white">{formatTimestamp(log.timestamp)}</span>
</div>
{/* Action */}
<div>
<span className="block text-xs font-medium text-gray-500 dark:text-gray-400">Action</span>
<span className="text-sm text-gray-900 dark:text-white">{log.action}</span>
</div>
{/* Actor */}
<div>
<span className="block text-xs font-medium text-gray-500 dark:text-gray-400">Actor</span>
<span className="text-sm text-gray-900 dark:text-white">{log.actor || '-'}</span>
</div>
{/* Result */}
<div>
<span className="block text-xs font-medium text-gray-500 dark:text-gray-400">Result</span>
<span className={`inline-flex items-center gap-1 text-sm ${
log.result === 'success' ? 'text-green-600' : log.result === 'failure' ? 'text-red-600' : 'text-gray-600'
}`}>
{log.result === 'success' ? (
<CheckCircle2 className="w-4 h-4" />
) : log.result === 'failure' ? (
<XCircle className="w-4 h-4" />
) : null}
{log.result || '-'}
</span>
</div>
{/* Target Resource */}
{log.targetResource && (
<div>
<span className="block text-xs font-medium text-gray-500 dark:text-gray-400">Target Resource</span>
<span className="text-sm text-gray-900 dark:text-white break-all">{log.targetResource}</span>
</div>
)}
{/* IP Address */}
{log.ipAddress && (
<div>
<span className="block text-xs font-medium text-gray-500 dark:text-gray-400">IP Address</span>
<span className="text-sm text-gray-900 dark:text-white font-mono">{log.ipAddress}</span>
</div>
)}
{/* Session ID */}
{log.sessionId && (
<div>
<span className="block text-xs font-medium text-gray-500 dark:text-gray-400">Session ID</span>
<span className="text-sm text-gray-900 dark:text-white font-mono truncate">{log.sessionId}</span>
</div>
)}
{/* Details */}
{log.details && Object.keys(log.details).length > 0 && (
<div>
<span className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Details</span>
<pre className="text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-x-auto">
{JSON.stringify(log.details, null, 2)}
</pre>
</div>
)}
{/* Merkle Hash Section */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<Hash className="w-3.5 h-3.5" />
Merkle Hash Chain
</h4>
{/* Current Hash */}
<div className="mb-2">
<span className="block text-xs font-medium text-gray-500 dark:text-gray-400">Current Hash</span>
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">
{log.hash || 'Not available'}
</span>
</div>
{/* Previous Hash */}
<div className="mb-2">
<span className="block text-xs font-medium text-gray-500 dark:text-gray-400">Previous Hash</span>
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">
{log.previousHash || 'Not available'}
</span>
</div>
{/* Chain Index */}
{log.chainIndex !== undefined && (
<div className="mb-3">
<span className="block text-xs font-medium text-gray-500 dark:text-gray-400">Chain Index</span>
<span className="text-xs text-gray-700 dark:text-gray-300">{log.chainIndex}</span>
</div>
)}
{/* Verify Button */}
<button
onClick={() => onVerify(log)}
disabled={isVerifying || !log.hash}
className="w-full flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isVerifying ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Link className="w-3.5 h-3.5" />
)}
{isVerifying ? 'Verifying...' : 'Verify Chain'}
</button>
{/* Verification Result */}
{verificationResult && (
<div className={`mt-2 p-2 rounded text-xs ${
verificationResult.valid
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400'
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400'
}`}>
<div className="flex items-center gap-1.5 mb-1">
{verificationResult.valid ? (
<CheckCircle2 className="w-3.5 h-3.5" />
) : (
<XCircle className="w-3.5 h-3.5" />
)}
<span className="font-medium">
{verificationResult.valid ? 'Chain Valid' : 'Chain Broken'}
</span>
</div>
{!verificationResult.valid && verificationResult.brokenAtIndex !== undefined && (
<p>Broken at index: {verificationResult.brokenAtIndex}</p>
)}
<p className="text-gray-500 dark:text-gray-400">Depth: {verificationResult.chainDepth}</p>
</div>
)}
</div>
</div>
</div>
);
}
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 (
<div className="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<Link className="w-3.5 h-3.5" />
Hash Chain (Last {visibleLogs.length} entries)
</h4>
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{visibleLogs.map((log, idx) => (
<div key={log.id} className="flex items-center">
<button
onClick={() => onSelect(logs.length - visibleLogs.length + idx)}
className={`flex-shrink-0 w-6 h-6 rounded flex items-center justify-center text-xs font-mono transition-colors ${
selectedIndex === logs.length - visibleLogs.length + idx
? 'bg-blue-600 text-white'
: brokenAtIndex === logs.length - visibleLogs.length + idx
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
title={formatTimestamp(log.timestamp)}
>
{String(logs.length - visibleLogs.length + idx).padStart(2, '0')}
</button>
{idx < visibleLogs.length - 1 && (
<div className={`w-3 h-0.5 ${
brokenAtIndex === logs.length - visibleLogs.length + idx
? 'bg-red-400'
: 'bg-gray-300 dark:bg-gray-600'
}`} />
)}
</div>
))}
</div>
{brokenAtIndex !== undefined && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
<AlertCircle className="w-3.5 h-3.5 inline mr-1" />
Chain broken at index {brokenAtIndex}
</p>
)}
</div>
);
}
// === 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<AuditLogFilter>({});
const [selectedLog, setSelectedLog] = useState<EnhancedAuditLogEntry | null>(null);
const [verificationResult, setVerificationResult] = useState<MerkleVerificationResult | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [chainBrokenAt, setChainBrokenAt] = useState<number | undefined>(undefined);
const logsEndRef = useRef<HTMLDivElement>(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<string>();
const actorSet = new Set<string>();
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 (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
<span className="ml-2 text-gray-500 dark:text-gray-400">Loading...</span>
</div>
);
}
return (
<div className="flex h-full">
{/* Main Content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Audit Logs
</h2>
{/* Real-time streaming controls */}
<div className="flex items-center gap-1">
{isStreaming ? (
<>
<button
onClick={togglePause}
className={`p-1.5 rounded transition-colors ${
isPaused
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600'
: 'bg-green-100 dark:bg-green-900/30 text-green-600'
}`}
title={isPaused ? 'Resume' : 'Pause'}
>
{isPaused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
</button>
<button
onClick={stopStreaming}
className="p-1.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors"
title="Stop streaming"
>
<X className="w-4 h-4" />
</button>
<span className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse" />
{isPaused ? 'Paused' : 'Live'}
</span>
</>
) : (
<button
onClick={startStreaming}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
>
<Play className="w-3.5 h-3.5" />
Live Stream
</button>
)}
</div>
</div>
<div className="flex items-center gap-2">
{/* Search */}
<div className="relative">
<Search className="w-4 h-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => 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"
/>
</div>
{/* Filter Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
showFilters
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<Filter className="w-4 h-4" />
Filter
{showFilters ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{/* Export */}
<div className="flex items-center gap-1">
<button
onClick={handleExportJSON}
className="flex items-center gap-1 px-2 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title="Export as JSON"
>
<FileJson className="w-4 h-4" />
</button>
<button
onClick={handleExportCSV}
className="flex items-center gap-1 px-2 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title="Export as CSV"
>
<FileSpreadsheet className="w-4 h-4" />
</button>
</div>
{/* Limit selector */}
<select
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
className="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 px-2 py-1"
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
<option value={500}>500</option>
</select>
{/* Refresh */}
<button
onClick={() => loadAuditLogs({ limit })}
disabled={isLoading}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Filter Panel */}
{showFilters && (
<div className="mb-4">
<FilterPanel
filter={filter}
onFilterChange={setFilter}
eventTypes={eventTypes}
actors={actors}
onReset={handleResetFilters}
/>
</div>
)}
{/* Hash Chain Visualization */}
{filteredLogs.length > 0 && (
<div className="mb-4">
<HashChainVisualization
logs={filteredLogs}
selectedIndex={selectedLog ? filteredLogs.findIndex((l) => l.id === selectedLog.id) : null}
onSelect={(idx) => setSelectedLog(filteredLogs[idx])}
brokenAtIndex={chainBrokenAt}
/>
</div>
)}
{/* Log Table */}
<div className="flex-1 overflow-auto">
{filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400">
<AlertCircle className="w-8 h-8 mb-2" />
<p>No audit logs found</p>
{(searchTerm || Object.keys(filter).length > 0) && (
<button
onClick={handleResetFilters}
className="mt-2 text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Clear filters
</button>
)}
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white dark:bg-gray-800 z-10">
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 font-medium text-gray-700 dark:text-gray-300 w-40">Time</th>
<th className="text-left py-2 px-3 font-medium text-gray-700 dark:text-gray-300">Action</th>
<th className="text-left py-2 px-3 font-medium text-gray-700 dark:text-gray-300 w-32">Actor</th>
<th className="text-left py-2 px-3 font-medium text-gray-700 dark:text-gray-300 w-20">Result</th>
<th className="text-left py-2 px-3 font-medium text-gray-700 dark:text-gray-300 w-24">Hash</th>
</tr>
</thead>
<tbody>
{filteredLogs.map((log, index) => (
<tr
key={log.id || index}
onClick={() => 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'
}`}
>
<td className="py-2 px-3 text-gray-600 dark:text-gray-400">
{formatTimestamp(log.timestamp)}
</td>
<td className="py-2 px-3 text-gray-900 dark:text-white">
{truncateText(log.action, 40)}
</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-400">
{truncateText(log.actor, 20)}
</td>
<td className={`py-2 px-3 ${log.result ? resultColor[log.result] : 'text-gray-600 dark:text-gray-400'}`}>
<span className="flex items-center gap-1">
{log.result === 'success' ? (
<CheckCircle2 className="w-3.5 h-3.5" />
) : log.result === 'failure' ? (
<XCircle className="w-3.5 h-3.5" />
) : null}
{log.result === 'success' ? 'OK' : log.result === 'failure' ? 'Fail' : '-'}
</span>
</td>
<td className="py-2 px-3 text-gray-500 dark:text-gray-400 font-mono text-xs">
{formatHash(log.hash)}
</td>
</tr>
))}
</tbody>
</table>
)}
<div ref={logsEndRef} />
</div>
{/* Stats Footer */}
<div className="flex items-center justify-between py-2 px-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400">
<span>
Showing {filteredLogs.length} of {auditLogs.length} logs
</span>
<span>
{chainBrokenAt !== undefined && (
<span className="text-red-600 dark:text-red-400">
Chain integrity: BROKEN at index {chainBrokenAt}
</span>
)}
</span>
</div>
</div>
{/* Detail Sidebar */}
{selectedLog && (
<LogDetailSidebar
log={selectedLog}
onClose={() => {
setSelectedLog(null);
setVerificationResult(null);
}}
onVerify={verifyChain}
verificationResult={verificationResult}
isVerifying={isVerifying}
/>
)}
</div>
);
}
export default AuditLogsPanel;