feat: create security monitoring dashboard
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user