feat: Enhance LiveLogViewer with Security Mode and related tests

- Updated LiveLogViewer to support a new security mode, allowing for the display of security logs.
- Implemented mock functions for connecting to security logs in tests.
- Added tests for rendering, filtering, and displaying security log entries, including blocked requests and source filtering.
- Modified Security page to utilize the new security mode in LiveLogViewer.
- Updated Security page tests to reflect changes in log viewer and ensure proper rendering of security-related components.
- Introduced a new script for CrowdSec startup testing, ensuring proper configuration and parser installation.
- Added pre-flight checks in the CrowdSec integration script to verify successful startup and configuration.
This commit is contained in:
GitHub Actions
2025-12-12 22:18:28 +00:00
parent 7da24a2ffb
commit 4b49ec5f2b
29 changed files with 5900 additions and 107 deletions

View File

@@ -1,78 +1,258 @@
import { useEffect, useRef, useState } from 'react';
import { connectLiveLogs, LiveLogEntry, LiveLogFilter } from '../api/logs';
import { useEffect, useRef, useState, useCallback } from 'react';
import {
connectLiveLogs,
connectSecurityLogs,
LiveLogEntry,
LiveLogFilter,
SecurityLogEntry,
SecurityLogFilter,
} from '../api/logs';
import { Button } from './ui/Button';
import { Pause, Play, Trash2, Filter } from 'lucide-react';
import { Pause, Play, Trash2, Filter, Shield, Globe } from 'lucide-react';
/**
* Log viewing mode: application logs vs security access logs
*/
export type LogMode = 'application' | 'security';
interface LiveLogViewerProps {
/** Filters for application log mode */
filters?: LiveLogFilter;
/** Filters for security log mode */
securityFilters?: SecurityLogFilter;
/** Initial log viewing mode */
mode?: LogMode;
/** Maximum number of log entries to retain */
maxLogs?: number;
/** Additional CSS classes */
className?: string;
}
export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: LiveLogViewerProps) {
const [logs, setLogs] = useState<LiveLogEntry[]>([]);
/**
* Unified display entry for both application and security logs
*/
interface DisplayLogEntry {
timestamp: string;
level: string;
source: string;
message: string;
blocked?: boolean;
blockReason?: string;
clientIP?: string;
method?: string;
host?: string;
uri?: string;
status?: number;
duration?: number;
userAgent?: string;
details?: Record<string, unknown>;
}
/**
* Convert a LiveLogEntry to unified display format
*/
const toDisplayFromLive = (entry: LiveLogEntry): DisplayLogEntry => ({
timestamp: entry.timestamp,
level: entry.level,
source: entry.source || 'app',
message: entry.message,
details: entry.data,
});
/**
* Convert a SecurityLogEntry to unified display format
*/
const toDisplayFromSecurity = (entry: SecurityLogEntry): DisplayLogEntry => ({
timestamp: entry.timestamp,
level: entry.level,
source: entry.source,
message: entry.blocked
? `🚫 BLOCKED: ${entry.block_reason || 'Access denied'}`
: `${entry.method} ${entry.uri}${entry.status}`,
blocked: entry.blocked,
blockReason: entry.block_reason,
clientIP: entry.client_ip,
method: entry.method,
host: entry.host,
uri: entry.uri,
status: entry.status,
duration: entry.duration,
userAgent: entry.user_agent,
details: entry.details,
});
/**
* Get background/text styling based on log entry properties
*/
const getEntryStyle = (log: DisplayLogEntry): string => {
if (log.blocked) {
return 'bg-red-900/30 border-l-2 border-red-500';
}
const level = log.level.toLowerCase();
if (level.includes('error') || level.includes('fatal')) return 'text-red-400';
if (level.includes('warn')) return 'text-yellow-400';
return '';
};
/**
* Get badge color for security source
*/
const getSourceBadgeColor = (source: string): string => {
const colors: Record<string, string> = {
waf: 'bg-orange-600',
crowdsec: 'bg-purple-600',
ratelimit: 'bg-blue-600',
acl: 'bg-green-600',
normal: 'bg-gray-600',
cerberus: 'bg-indigo-600',
app: 'bg-gray-500',
};
return colors[source.toLowerCase()] || 'bg-gray-500';
};
/**
* Format timestamp for display
*/
const formatTimestamp = (timestamp: string): string => {
try {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return timestamp;
}
};
/**
* Get level color for application logs
*/
const getLevelColor = (level: string): string => {
const normalized = level.toLowerCase();
if (normalized.includes('error') || normalized.includes('fatal')) return 'text-red-400';
if (normalized.includes('warn')) return 'text-yellow-400';
if (normalized.includes('info')) return 'text-blue-400';
if (normalized.includes('debug')) return 'text-gray-400';
return 'text-gray-300';
};
export function LiveLogViewer({
filters = {},
securityFilters = {},
mode = 'application',
maxLogs = 500,
className = '',
}: LiveLogViewerProps) {
const [logs, setLogs] = useState<DisplayLogEntry[]>([]);
const [isPaused, setIsPaused] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [currentMode, setCurrentMode] = useState<LogMode>(mode);
const [textFilter, setTextFilter] = useState('');
const [levelFilter, setLevelFilter] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const [showBlockedOnly, setShowBlockedOnly] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null);
const closeConnectionRef = useRef<(() => void) | null>(null);
// Auto-scroll when new logs arrive (only if not paused and user hasn't scrolled up)
const shouldAutoScroll = useRef(true);
// Handle mode change - clear logs and update filters
const handleModeChange = useCallback((newMode: LogMode) => {
setCurrentMode(newMode);
setLogs([]);
setTextFilter('');
setLevelFilter('');
setSourceFilter('');
setShowBlockedOnly(false);
}, []);
// Connection effect - reconnects when mode or external filters change
useEffect(() => {
// Connect to WebSocket
const closeConnection = connectLiveLogs(
filters,
(log: LiveLogEntry) => {
if (!isPaused) {
setLogs((prev) => {
const updated = [...prev, log];
// Keep only last maxLogs entries
if (updated.length > maxLogs) {
return updated.slice(updated.length - maxLogs);
}
return updated;
});
}
},
() => {
// onOpen callback - connection established
console.log('Live log viewer connected');
setIsConnected(true);
},
(error) => {
console.error('WebSocket error:', error);
setIsConnected(false);
},
() => {
console.log('Live log viewer disconnected');
setIsConnected(false);
}
);
// Close existing connection
if (closeConnectionRef.current) {
closeConnectionRef.current();
closeConnectionRef.current = null;
}
closeConnectionRef.current = closeConnection;
// Don't set isConnected here - wait for onOpen callback
const handleOpen = () => {
console.log(`${currentMode} log viewer connected`);
setIsConnected(true);
};
return () => {
closeConnection();
const handleError = (error: Event) => {
console.error('WebSocket error:', error);
setIsConnected(false);
};
}, [filters, isPaused, maxLogs]);
// Handle auto-scroll
const handleClose = () => {
console.log(`${currentMode} log viewer disconnected`);
setIsConnected(false);
};
if (currentMode === 'security') {
// Connect to security logs endpoint
const handleSecurityMessage = (entry: SecurityLogEntry) => {
if (!isPaused) {
const displayEntry = toDisplayFromSecurity(entry);
setLogs((prev) => {
const updated = [...prev, displayEntry];
return updated.length > maxLogs ? updated.slice(-maxLogs) : updated;
});
}
};
// Build filters including blocked_only if selected
const effectiveFilters: SecurityLogFilter = {
...securityFilters,
blocked_only: showBlockedOnly || securityFilters.blocked_only,
};
closeConnectionRef.current = connectSecurityLogs(
effectiveFilters,
handleSecurityMessage,
handleOpen,
handleError,
handleClose
);
} else {
// Connect to application logs endpoint
const handleLiveMessage = (entry: LiveLogEntry) => {
if (!isPaused) {
const displayEntry = toDisplayFromLive(entry);
setLogs((prev) => {
const updated = [...prev, displayEntry];
return updated.length > maxLogs ? updated.slice(-maxLogs) : updated;
});
}
};
closeConnectionRef.current = connectLiveLogs(
filters,
handleLiveMessage,
handleOpen,
handleError,
handleClose
);
}
return () => {
if (closeConnectionRef.current) {
closeConnectionRef.current();
closeConnectionRef.current = null;
}
setIsConnected(false);
};
}, [currentMode, filters, securityFilters, isPaused, maxLogs, showBlockedOnly]);
// Auto-scroll effect
useEffect(() => {
if (shouldAutoScroll.current && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs]);
// Track if user has manually scrolled
// Track manual scrolling
const handleScroll = () => {
if (logContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
// If scrolled to bottom (within 50px), enable auto-scroll
// Enable auto-scroll if scrolled to bottom (within 50px threshold)
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 50;
}
};
@@ -85,42 +265,45 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
setIsPaused(!isPaused);
};
// Filter logs based on text and level
// Client-side filtering
const filteredLogs = logs.filter((log) => {
if (textFilter && !log.message.toLowerCase().includes(textFilter.toLowerCase())) {
return false;
// Text filter - search in message, URI, host, IP
if (textFilter) {
const searchText = textFilter.toLowerCase();
const matchFields = [
log.message,
log.uri,
log.host,
log.clientIP,
log.blockReason,
].filter(Boolean).map(s => s!.toLowerCase());
if (!matchFields.some(field => field.includes(searchText))) {
return false;
}
}
// Level filter
if (levelFilter && log.level.toLowerCase() !== levelFilter.toLowerCase()) {
return false;
}
// Source filter (security mode only)
if (sourceFilter && log.source.toLowerCase() !== sourceFilter.toLowerCase()) {
return false;
}
return true;
});
// Color coding based on log level
const getLevelColor = (level: string) => {
const normalized = level.toLowerCase();
if (normalized.includes('error') || normalized.includes('fatal')) return 'text-red-400';
if (normalized.includes('warn')) return 'text-yellow-400';
if (normalized.includes('info')) return 'text-blue-400';
if (normalized.includes('debug')) return 'text-gray-400';
return 'text-gray-300';
};
const formatTimestamp = (timestamp: string) => {
try {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return timestamp;
}
};
return (
<div className={`bg-gray-900 rounded-lg border border-gray-700 ${className}`}>
{/* Header with controls */}
{/* Header with mode toggle and controls */}
<div className="flex items-center justify-between p-3 border-b border-gray-700">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">Live Security Logs</h3>
<h3 className="text-sm font-semibold text-white">
{currentMode === 'security' ? 'Security Access Logs' : 'Live Security Logs'}
</h3>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
isConnected ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'
@@ -130,6 +313,30 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
</span>
</div>
<div className="flex items-center gap-2">
{/* Mode toggle */}
<div className="flex bg-gray-800 rounded-md p-0.5">
<button
onClick={() => handleModeChange('application')}
className={`px-2 py-1 text-xs rounded flex items-center gap-1 transition-colors ${
currentMode === 'application' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
}`}
title="Application logs"
>
<Globe className="w-4 h-4" />
<span className="hidden sm:inline">App</span>
</button>
<button
onClick={() => handleModeChange('security')}
className={`px-2 py-1 text-xs rounded flex items-center gap-1 transition-colors ${
currentMode === 'security' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
}`}
title="Security access logs"
>
<Shield className="w-4 h-4" />
<span className="hidden sm:inline">Security</span>
</button>
</div>
{/* Pause/Resume */}
<Button
variant="ghost"
size="sm"
@@ -139,6 +346,7 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
>
{isPaused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
</Button>
{/* Clear */}
<Button
variant="ghost"
size="sm"
@@ -152,14 +360,14 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
</div>
{/* Filters */}
<div className="flex items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
<div className="flex flex-wrap items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
<Filter className="w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Filter by text..."
value={textFilter}
onChange={(e) => setTextFilter(e.target.value)}
className="flex-1 px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500"
className="flex-1 min-w-32 px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500"
/>
<select
value={levelFilter}
@@ -173,6 +381,32 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
<option value="error">Error</option>
<option value="fatal">Fatal</option>
</select>
{/* Security mode specific filters */}
{currentMode === 'security' && (
<>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className="px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500"
>
<option value="">All Sources</option>
<option value="waf">WAF</option>
<option value="crowdsec">CrowdSec</option>
<option value="ratelimit">Rate Limit</option>
<option value="acl">ACL</option>
<option value="normal">Normal</option>
</select>
<label className="flex items-center gap-1 text-xs text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={showBlockedOnly}
onChange={(e) => setShowBlockedOnly(e.target.checked)}
className="rounded border-gray-600 bg-gray-700 text-blue-500 focus:ring-blue-500"
/>
Blocked only
</label>
</>
)}
</div>
{/* Log display */}
@@ -188,14 +422,62 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
</div>
)}
{filteredLogs.map((log, index) => (
<div key={index} className="mb-1 hover:bg-gray-900 px-1 -mx-1 rounded">
<div
key={index}
className={`mb-1 hover:bg-gray-900 px-1 -mx-1 rounded ${getEntryStyle(log)}`}
>
<span className="text-gray-500">{formatTimestamp(log.timestamp)}</span>
<span className={`ml-2 font-semibold ${getLevelColor(log.level)}`}>{log.level.toUpperCase()}</span>
{log.source && <span className="ml-2 text-purple-400">[{log.source}]</span>}
{/* Source badge for security mode */}
{currentMode === 'security' && (
<span className={`ml-2 px-1 rounded text-xs text-white ${getSourceBadgeColor(log.source)}`}>
{log.source.toUpperCase()}
</span>
)}
{/* Level badge for application mode */}
{currentMode === 'application' && (
<span className={`ml-2 font-semibold ${getLevelColor(log.level)}`}>
{log.level.toUpperCase()}
</span>
)}
{/* Client IP for security logs */}
{currentMode === 'security' && log.clientIP && (
<span className="ml-2 text-cyan-400">{log.clientIP}</span>
)}
{/* Source tag for application logs */}
{currentMode === 'application' && log.source && log.source !== 'app' && (
<span className="ml-2 text-purple-400">[{log.source}]</span>
)}
{/* Message */}
<span className="ml-2 text-gray-200">{log.message}</span>
{log.data && Object.keys(log.data).length > 0 && (
{/* Block reason badge */}
{log.blocked && log.blockReason && (
<span className="ml-2 text-red-400 text-xs">[{log.blockReason}]</span>
)}
{/* Status code for security logs */}
{currentMode === 'security' && log.status && !log.blocked && (
<span className={`ml-2 ${log.status >= 400 ? 'text-red-400' : log.status >= 300 ? 'text-yellow-400' : 'text-green-400'}`}>
[{log.status}]
</span>
)}
{/* Duration for security logs */}
{currentMode === 'security' && log.duration !== undefined && (
<span className="ml-1 text-gray-500">
{log.duration < 1 ? `${(log.duration * 1000).toFixed(1)}ms` : `${log.duration.toFixed(2)}s`}
</span>
)}
{/* Additional data */}
{log.details && Object.keys(log.details).length > 0 && (
<div className="ml-8 text-gray-400 text-xs">
{JSON.stringify(log.data, null, 2)}
{JSON.stringify(log.details, null, 2)}
</div>
)}
</div>