feat: create security monitoring dashboard

This commit is contained in:
张翔
2026-03-24 11:27:23 +08:00
parent dea94b9955
commit 2ec4e65836
4 changed files with 479 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
import { describe, it, expect, jest, beforeAll, afterEach } from '@jest/globals';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import SecurityDashboard from './page';
jest.mock('lucide-react', () => ({
Shield: () => <span data-testid="shield-icon" />,
AlertTriangle: () => <span data-testid="alert-icon" />,
Activity: () => <span data-testid="activity-icon" />,
Lock: () => <span data-testid="lock-icon" />,
RefreshCw: () => <span data-testid="refresh-cw-icon" />,
TrendingUp: () => <span data-testid="trending-up-icon" />,
TrendingDown: () => <span data-testid="trending-down-icon" />,
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, disabled, ...props }: any) => (
<button disabled={disabled} {...props}>
{children}
</button>
),
}));
jest.mock('@/components/ui/card', () => ({
Card: ({ children }: any) => <div data-testid="card">{children}</div>,
CardHeader: ({ children }: any) => <div data-testid="card-header">{children}</div>,
CardTitle: ({ children }: any) => <h3 data-testid="card-title">{children}</h3>,
CardContent: ({ children }: any) => <div data-testid="card-content">{children}</div>,
}));
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({
success: true,
logs: [
{
id: '1',
timestamp: Date.now(),
type: 'captcha',
severity: 'high',
message: '验证码验证失败',
ip: '192.168.1.1',
},
],
stats: {
totalRequests: 100,
blockedRequests: 5,
captchaAttempts: 10,
rateLimitHits: 3,
maliciousContentDetected: 2,
successRate: 95,
},
}),
} as Response)
);
describe('SecurityDashboard', () => {
beforeAll(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render security dashboard', () => {
render(<SecurityDashboard />);
expect(screen.getByText('安全监控仪表板')).toBeInTheDocument();
expect(screen.getByText('实时监控网站安全状态和威胁检测')).toBeInTheDocument();
});
it('should render all stat cards', () => {
render(<SecurityDashboard />);
expect(screen.getByText('总请求数')).toBeInTheDocument();
expect(screen.getByText('已拦截请求')).toBeInTheDocument();
expect(screen.getByText('验证码尝试')).toBeInTheDocument();
expect(screen.getByText('频率限制命中')).toBeInTheDocument();
expect(screen.getByText('恶意内容检测')).toBeInTheDocument();
expect(screen.getByText('成功率')).toBeInTheDocument();
});
it('should display stats values', async () => {
render(<SecurityDashboard />);
await waitFor(() => {
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('95%')).toBeInTheDocument();
});
});
});
describe('Security Logs', () => {
it('should render security logs section', async () => {
render(<SecurityDashboard />);
await waitFor(() => {
expect(screen.getByText('安全日志')).toBeInTheDocument();
});
});
it('should display log entries', async () => {
render(<SecurityDashboard />);
await waitFor(() => {
expect(screen.getByText('验证码验证失败')).toBeInTheDocument();
expect(screen.getByText('IP: 192.168.1.1')).toBeInTheDocument();
});
});
it('should have filter buttons', () => {
render(<SecurityDashboard />);
expect(screen.getByText('全部')).toBeInTheDocument();
expect(screen.getByText('高危')).toBeInTheDocument();
expect(screen.getByText('中危')).toBeInTheDocument();
expect(screen.getByText('低危')).toBeInTheDocument();
});
});
describe('Refresh Functionality', () => {
it('should have refresh button', async () => {
render(<SecurityDashboard />);
await waitFor(() => {
expect(screen.getByTestId('refresh-cw-icon')).toBeInTheDocument();
});
});
it('should call fetch when refresh is clicked', async () => {
render(<SecurityDashboard />);
await waitFor(() => {
const refreshButton = screen.getAllByRole('button')[0];
expect(refreshButton).not.toBeDisabled();
refreshButton.click();
expect(global.fetch).toHaveBeenCalled();
});
});
});
});
+271
View File
@@ -0,0 +1,271 @@
'use client';
import { useState, useEffect } from 'react';
import { Shield, AlertTriangle, Activity, Lock, RefreshCw, TrendingUp, TrendingDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface SecurityLog {
id: string;
timestamp: number;
type: 'captcha' | 'rate_limit' | 'sanitization' | 'malicious_content';
severity: 'low' | 'medium' | 'high';
message: string;
ip?: string;
email?: string;
}
interface SecurityStats {
totalRequests: number;
blockedRequests: number;
captchaAttempts: number;
rateLimitHits: number;
maliciousContentDetected: number;
successRate: number;
}
export default function SecurityDashboard() {
const [logs, setLogs] = useState<SecurityLog[]>([]);
const [stats, setStats] = useState<SecurityStats>({
totalRequests: 0,
blockedRequests: 0,
captchaAttempts: 0,
rateLimitHits: 0,
maliciousContentDetected: 0,
successRate: 100,
});
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'all' | 'high' | 'medium' | 'low'>('all');
useEffect(() => {
fetchSecurityData();
}, []);
const fetchSecurityData = async () => {
setLoading(true);
try {
const response = await fetch('/api/admin/security');
if (response.ok) {
const data = await response.json();
setLogs(data.logs || []);
setStats(data.stats || {
totalRequests: 0,
blockedRequests: 0,
captchaAttempts: 0,
rateLimitHits: 0,
maliciousContentDetected: 0,
successRate: 100,
});
}
} catch (error) {
console.error('Failed to fetch security data:', error);
} finally {
setLoading(false);
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'high':
return 'text-red-600 bg-red-50';
case 'medium':
return 'text-yellow-600 bg-yellow-50';
case 'low':
return 'text-blue-600 bg-blue-50';
default:
return 'text-gray-600 bg-gray-50';
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'captcha':
return <Lock className="w-4 h-4" />;
case 'rate_limit':
return <Activity className="w-4 h-4" />;
case 'sanitization':
return <Shield className="w-4 h-4" />;
case 'malicious_content':
return <AlertTriangle className="w-4 h-4" />;
default:
return null;
}
};
const filteredLogs = filter === 'all'
? logs
: logs.filter(log => log.severity === filter);
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1"></p>
</div>
<Button
onClick={fetchSecurityData}
disabled={loading}
variant="outline"
size="icon"
>
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-gray-900">{stats.totalRequests}</div>
<div className="flex items-center text-sm text-green-600 mt-2">
<TrendingUp className="w-4 h-4 mr-1" />
<span></span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-red-600">{stats.blockedRequests}</div>
<div className="flex items-center text-sm text-gray-600 mt-2">
<span></span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-blue-600">{stats.captchaAttempts}</div>
<div className="flex items-center text-sm text-gray-600 mt-2">
<span></span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-yellow-600">{stats.rateLimitHits}</div>
<div className="flex items-center text-sm text-gray-600 mt-2">
<span></span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-purple-600">{stats.maliciousContentDetected}</div>
<div className="flex items-center text-sm text-gray-600 mt-2">
<span></span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-green-600">{stats.successRate}%</div>
<div className="flex items-center text-sm text-gray-600 mt-2">
<TrendingDown className="w-4 h-4 mr-1" />
<span></span>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle></CardTitle>
<div className="flex gap-2">
<Button
variant={filter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('all')}
>
</Button>
<Button
variant={filter === 'high' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('high')}
>
</Button>
<Button
variant={filter === 'medium' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('medium')}
>
</Button>
<Button
variant={filter === 'low' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('low')}
>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-8 h-8 animate-spin text-gray-400" />
</div>
) : filteredLogs.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<Shield className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<p></p>
</div>
) : (
<div className="space-y-3">
{filteredLogs.map((log) => (
<div
key={log.id}
className="flex items-start gap-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
>
<div className={`flex-shrink-0 p-2 rounded-full ${getSeverityColor(log.severity)}`}>
{getTypeIcon(log.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{log.message}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${getSeverityColor(log.severity)}`}>
{log.severity === 'high' ? '高危' : log.severity === 'medium' ? '中危' : '低危'}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>{new Date(log.timestamp).toLocaleString('zh-CN')}</span>
{log.ip && <span>IP: {log.ip}</span>}
{log.email && <span>: {log.email}</span>}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { SecurityLogger } from '@/lib/security/logger';
export async function GET(request: NextRequest) {
try {
const logs = SecurityLogger.getRecentLogs(100);
const stats = SecurityLogger.getStats();
return NextResponse.json({
success: true,
logs,
stats,
});
} catch (error) {
console.error('Error fetching security data:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch security data'
},
{ status: 500 }
);
}
}
+36
View File
@@ -104,4 +104,40 @@ export class SecurityLogger {
clear(): void {
this.logger.clear();
}
getStats() {
const logs = this.getRecentLogs();
const totalRequests = logs.length;
const blockedRequests = logs.filter(log =>
log.type === SecurityEventType.RATE_LIMIT_EXCEEDED ||
log.type === SecurityEventType.BLOCKED_REQUEST ||
log.type === SecurityEventType.MALICIOUS_CONTENT ||
log.type === SecurityEventType.XSS_ATTEMPT ||
log.type === SecurityEventType.SQL_INJECTION_ATTEMPT
).length;
const captchaAttempts = logs.filter(log =>
log.type === SecurityEventType.CAPTCHA_FAILED ||
log.type === SecurityEventType.CAPTCHA_EXPIRED
).length;
const rateLimitHits = logs.filter(log =>
log.type === SecurityEventType.RATE_LIMIT_EXCEEDED
).length;
const maliciousContentDetected = logs.filter(log =>
log.type === SecurityEventType.MALICIOUS_CONTENT ||
log.type === SecurityEventType.XSS_ATTEMPT ||
log.type === SecurityEventType.SQL_INJECTION_ATTEMPT
).length;
const successRate = totalRequests > 0
? ((totalRequests - blockedRequests) / totalRequests * 100).toFixed(2)
: 100;
return {
totalRequests,
blockedRequests,
captchaAttempts,
rateLimitHits,
maliciousContentDetected,
successRate: parseFloat(successRate),
};
}
}