## Major Features ### Streaming Response System - Implement LlmDriver trait with `stream()` method returning async Stream - Add SSE parsing for Anthropic and OpenAI API streaming - Integrate Tauri event system for frontend streaming (`stream:chunk` events) - Add StreamChunk types: Delta, ToolStart, ToolEnd, Complete, Error ### MCP Protocol Implementation - Add MCP JSON-RPC 2.0 types (mcp_types.rs) - Implement stdio-based MCP transport (mcp_transport.rs) - Support tool discovery, execution, and resource operations ### Browser Hand Implementation - Complete browser automation with Playwright-style actions - Support Navigate, Click, Type, Scrape, Screenshot, Wait actions - Add educational Hands: Whiteboard, Slideshow, Speech, Quiz ### Security Enhancements - Implement command whitelist/blacklist for shell_exec tool - Add SSRF protection with private IP blocking - Create security.toml configuration file ## Test Improvements - Fix test import paths (security-utils, setup) - Fix vi.mock hoisting issues with vi.hoisted() - Update test expectations for validateUrl and sanitizeFilename - Add getUnsupportedLocalGatewayStatus mock ## Documentation Updates - Update architecture documentation - Improve configuration reference - Add quick-start guide updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
961 lines
34 KiB
TypeScript
961 lines
34 KiB
TypeScript
/**
|
|
* AuditLogsPanel - OpenFang 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 OpenFang
|
|
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 OpenFang 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;
|