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 {
|
clear(): void {
|
||||||
this.logger.clear();
|
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