fix: convert mantis-frontend from tracked files to submodule

This commit is contained in:
ParrotXray 2026-05-22 04:47:25 +00:00
parent 6f40a8f81c
commit 11b4d423d8
40 changed files with 1 additions and 13092 deletions

1
mantis-frontend Submodule

@ -0,0 +1 @@
Subproject commit f1fa091f4c1abc90049b32c36f2d339400090713

View File

@ -1 +0,0 @@
* text=auto eol=lf

View File

@ -1,26 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.next
out
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,26 +0,0 @@
# Next.js + Tailwind CSS
This template provides a minimal setup to quickly get started with Next.js and Tailwind CSS development.
## Features
- **Fast Refresh**: Provides instant updates during development.
- **Tailwind CSS**: A powerful and highly customizable CSS framework that helps you quickly build responsive UIs.
- **ESLint**: Provides basic code linting rules to ensure code quality.
## Getting Started
1. Install dependencies:
```bash
npm install
# or
yarn install
```
2. Start the development server:
```bash
npm run dev
# or
yarn dev
```

View File

@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

View File

@ -1,29 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
trailingSlash: false,
output: 'export',
images: {
remotePatterns: [
// {
// protocol: 'https',
// hostname: 'cdn.discordapp.com',
// pathname: '/avatars/**',
// },
],
},
// i18n: {
// locales: ['tw', 'cn', 'ja', 'en'],
// defaultLocale: 'tw',
// localeDetection: true,
// },
//
// allowedDevOrigins: [
// '192.168.1.118',
// 'localhost',
// '127.0.0.1',
// ],
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@ -1,53 +0,0 @@
{
"name": "netguardia-dashboard",
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"start:https": "node server.js",
"dev:https": "node server.js"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.1",
"@headlessui/react": "^2.2.9",
"@tailwindcss/typography": "^0.5.19",
"bcryptjs": "^3.0.2",
"better-sqlite3": "12.2.0",
"chart.js": "^4.4.1",
"echarts": "6.0.0",
"echarts-for-react": "3.0.4",
"formidable": "^3.5.2",
"framer-motion": "^12.23.12",
"jsonwebtoken": "^9.0.2",
"maplibre-gl": "^5.8.0",
"next": "^16.0.10",
"react": "^19.2.3",
"react-chartjs-2": "^5.2.0",
"react-dom": "^19.2.3",
"react-error-boundary": "^6.0.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"rxjs": "^7.8.2",
"uuid": "^11.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.17",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "24.10.11",
"@types/react": "19.2.13",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^9.0.8",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.17",
"typescript": "5.9.3"
}
}

View File

@ -1,6 +0,0 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,104 +0,0 @@
// server.js - HTTPS server with English logs
import { createServer } from 'https';
import { parse } from 'url';
import next from 'next';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// ES Module 中取得 __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Determine environment based on npm script
const scriptName = process.env.npm_lifecycle_event;
let dev;
if (scriptName === 'dev:https') {
dev = true;
console.log('Development mode');
} else if (scriptName === 'start:https') {
dev = false;
console.log('Production mode');
} else {
// Default to production mode
dev = false;
console.log('Unknown script, using production mode');
}
const hostname = '0.0.0.0';
const port = 3000;
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
// SSL certificate file paths
const sslFiles = {
ca: path.join(__dirname, 'ssl/root.ca-bundle'),
cert: path.join(__dirname, 'ssl/server.crt'),
key: path.join(__dirname, 'ssl/server.key')
};
// Check and load SSL certificates
function loadSSLCertificates() {
try {
// Check if all certificate files exist
for (const [type, filePath] of Object.entries(sslFiles)) {
if (!fs.existsSync(filePath)) {
throw new Error(`SSL ${type} file not found: ${filePath}`);
}
}
console.log('All SSL certificate files found');
return {
ca: fs.readFileSync(sslFiles.ca),
cert: fs.readFileSync(sslFiles.cert),
key: fs.readFileSync(sslFiles.key)
};
} catch (error) {
console.error('Failed to load SSL certificates:', error.message);
console.error('Please ensure the following files exist:');
console.error(' - ssl/*.ca-bundle');
console.error(' - ssl/*.crt');
console.error(' - ssl/*.key');
process.exit(1);
}
}
// Start the application
app.prepare().then(() => {
const httpsOptions = loadSSLCertificates();
createServer(httpsOptions, (req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
}).listen(port, hostname, (err) => {
if (err) {
console.error('Failed to start HTTPS server:', err);
process.exit(1);
}
console.log(`HTTPS Server ready on https://${hostname}:${port}`);
if (dev) {
console.log('Development mode started with hot reload support');
} else {
console.log('Production mode started with optimized performance');
}
});
}).catch((ex) => {
console.error('Failed to start Next.js application:', ex.stack);
process.exit(1);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('Shutting down server...');
process.exit(0);
});
process.on('SIGINT', () => {
console.log('Shutting down server...');
process.exit(0);
});

View File

@ -1,55 +0,0 @@
'use client'
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
interface ErrorDialogProps {
error: Error
resetErrorBoundary: () => void
}
const ErrorDialog: React.FC<ErrorDialogProps> = ({ error, resetErrorBoundary }) => {
const handleClose = () => {
window.location.href = '/'
}
return (
<div className="modal-overlay">
<div className="modal-content max-w-md">
<div className="flex items-center gap-3 mb-4">
<FontAwesomeIcon icon={faExclamationTriangle} className="text-red-500 text-2xl" />
<h2 className="text-xl font-semibold text-gray-800">Oops! Something went wrong</h2>
</div>
<div className="mb-6">
<p className="text-gray-600 mb-3">
We encountered an unexpected error. Here are the details:
</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<code className="text-red-700 text-sm break-all">
{error ? error.toString() : 'Something went wrong'}
</code>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={handleClose}
className="btn-secondary"
>
Go Home
</button>
<button
onClick={resetErrorBoundary}
className="btn-primary"
>
Try Again
</button>
</div>
</div>
</div>
)
}
export default ErrorDialog

View File

@ -1,53 +0,0 @@
// src/components/Layout.tsx
import React from 'react'
import Head from 'next/head'
import { siteUrl } from '../config'
import Sidebar from './Sidebar'
interface LayoutProps {
children: React.ReactNode
title?: string
description?: string
}
const Layout: React.FC<LayoutProps> = ({
children,
title = "NetGuardia",
description = "NetGuardia"
}) => {
const imageUrl = `${siteUrl}/logo.png`
return (
<>
<Head>
<title>{title}</title>
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
{/* Open Graph Meta Tags */}
<meta property="og:type" content="website"/>
<meta property="og:title" content={title}/>
<meta property="og:description" content={description}/>
<meta property="og:url" content={siteUrl}/>
<meta property="og:image" content={imageUrl}/>
<meta property="og:image:width" content="200"/>
<meta property="og:image:height" content="200"/>
<meta property="og:image:alt" content="NetGuardia"/>
{/* Twitter Card Meta Tags */}
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content={title}/>
<meta name="twitter:description" content={description}/>
<meta name="twitter:image" content={imageUrl}/>
</Head>
{/* Wrap Sidebar around the content */}
<Sidebar>
{children}
</Sidebar>
</>
)
}
export default Layout

View File

@ -1,11 +0,0 @@
import React from 'react'
const LoadingSpinner: React.FC = () => {
return (
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
export default LoadingSpinner

View File

@ -1,196 +0,0 @@
// src/components/Sidebar.tsx
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { motion } from 'framer-motion'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faTachometerAlt,
faChartBar,
faShieldAlt,
faRobot,
faBars,
faHome,
faMapMarkedAlt
} from '@fortawesome/free-solid-svg-icons'
import { useTheme } from '../providers/ThemeProvider'
import ThemeToggle from './ThemeToggle'
interface MenuItem {
href: string
icon: any
label: string
}
const menuItems: MenuItem[] = [
{
href: '/',
icon: faHome,
label: 'Home'
},
{
href: '/dashboard',
icon: faTachometerAlt,
label: 'Dashboard'
},
{
href: '/statistics',
icon: faChartBar,
label: 'Statistics'
},
{
href: '/traffic-map',
icon: faMapMarkedAlt,
label: 'Traffic Map'
},
{
href: '/access-control',
icon: faShieldAlt,
label: 'Access Control'
},
{
href: '/detection',
icon: faRobot,
label: 'Detection'
}
]
interface SidebarProps {
children: React.ReactNode
}
export const Sidebar: React.FC<SidebarProps> = ({ children }) => {
const router = useRouter()
const { actualTheme } = useTheme()
const [isCollapsed, setIsCollapsed] = useState(false)
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed)
}
// 檢查當前路徑是否為活動狀態
const isActive = (href: string) => {
if (href === '/') {
return router.pathname === '/'
}
return router.pathname.startsWith(href)
}
const sidebarVariants = {
expanded: { width: "240px" },
collapsed: { width: "80px" }
}
const menuItemVariants = {
expanded: { x: 0, opacity: 1 },
collapsed: { x: -10, opacity: 0 }
}
// 深色模式樣式
const isDark = actualTheme === 'dark'
return (
<div className={`flex min-h-screen transition-colors duration-300 ${
isDark ? 'bg-gray-900' : 'bg-gray-100'
}`}>
{/* Sidebar */}
<motion.aside
className={`fixed top-0 left-0 h-screen flex flex-col shadow-xl z-50 overflow-hidden transition-colors duration-300 ${
isDark
? 'bg-gray-600 text-gray-100'
: 'bg-slate-800 text-white'
}`}
initial="expanded"
animate={isCollapsed ? "collapsed" : "expanded"}
variants={sidebarVariants}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
{/* Sidebar Header */}
<div className={`flex items-center justify-between p-4 min-h-[70px] border-b transition-colors duration-300 ${
isDark ? 'border-gray-700' : 'border-white/10'
}`}>
{!isCollapsed && (
<Link href="/" className="no-underline text-inherit">
<motion.h1
className="text-2xl font-bold text-blue-400 m-0 whitespace-nowrap"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
NetGuardia
</motion.h1>
</Link>
)}
<button
onClick={toggleSidebar}
className={`bg-transparent border-none cursor-pointer p-2 rounded-md flex items-center justify-center w-10 h-10 transition-colors duration-200 ${
isDark
? 'text-gray-300 hover:bg-gray-700'
: 'text-white hover:bg-white/10'
}`}
>
<FontAwesomeIcon icon={faBars} />
</button>
</div>
{/* Navigation Menu */}
<nav className="flex-1 py-4 flex flex-col gap-2">
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`
flex items-center no-underline transition-all duration-200 relative mx-2 rounded-lg
${isCollapsed
? 'px-2 py-2 justify-center h-12 w-12'
: 'px-4 py-3'
}
${isActive(item.href)
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: (isDark
? 'text-gray-400 hover:bg-gray-700 hover:text-gray-200'
: 'text-slate-300 hover:bg-white/10 hover:text-white'
)
}
`}
>
<div className="flex items-center justify-center flex-shrink-0 w-6 h-6">
<FontAwesomeIcon icon={item.icon} className="text-lg" />
</div>
{!isCollapsed && (
<motion.span
className="font-medium whitespace-nowrap overflow-hidden ml-3"
variants={menuItemVariants}
initial="expanded"
animate={isCollapsed ? "collapsed" : "expanded"}
transition={{ duration: 0.2 }}
>
{item.label}
</motion.span>
)}
</Link>
))}
</nav>
{/* Theme Toggle - 放在 Sidebar 底部 */}
<div className={`mt-auto border-t transition-colors duration-300 ${
isDark ? 'border-gray-700' : 'border-white/10'
}`}>
<ThemeToggle isCollapsed={isCollapsed} />
</div>
</motion.aside>
{/* Main Content */}
<div className={`flex-1 flex flex-col transition-all duration-300 ${isCollapsed ? 'ml-20' : 'ml-60'}`}>
<section className="flex-1 p-8">
<div className="max-w-full mx-auto">
{children}
</div>
</section>
</div>
</div>
)
}
export default Sidebar

View File

@ -1,173 +0,0 @@
// src/components/ThemeToggle.tsx
'use client'
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faSun,
faMoon,
faDesktop,
faChevronDown
} from '@fortawesome/free-solid-svg-icons'
import { useTheme } from '../providers/ThemeProvider'
interface ThemeToggleProps {
isCollapsed?: boolean
}
const ThemeToggle: React.FC<ThemeToggleProps> = ({ isCollapsed = false }) => {
const { theme, actualTheme, setTheme, toggleTheme } = useTheme()
const [showOptions, setShowOptions] = React.useState(false)
const themeOptions = [
{
value: 'light' as const,
label: 'Light Mode',
icon: faSun,
},
{
value: 'dark' as const,
label: 'Dark Mode',
icon: faMoon,
},
{
value: 'system' as const,
label: 'System Default',
icon: faDesktop,
}
]
const currentThemeOption = themeOptions.find(option => option.value === theme) || themeOptions[0]
// 如果 sidebar 收起,只顯示簡單的切換按鈕
if (isCollapsed) {
return (
<div className="p-2">
<button
onClick={toggleTheme}
className={`
w-full h-12 rounded-lg flex items-center justify-center
transition-all duration-300 relative group
`}
title={`當前: ${currentThemeOption.label}`}
>
<FontAwesomeIcon
icon={currentThemeOption.icon}
className="text-lg text-gray-500"
/>
{/* 懸停提示 */}
<div className="absolute left-full ml-2 px-2 py-1 bg-gray-900 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-50">
{currentThemeOption.label}
</div>
</button>
</div>
)
}
return (
<div className="p-4 relative">
{/* 主題切換按鈕 */}
<button
onClick={() => setShowOptions(!showOptions)}
className={`
w-full rounded-lg p-3 flex items-center justify-between
transition-all duration-300 text-left
`}
>
<div className="flex items-center space-x-3">
<div className="p-2 rounded-lg">
<FontAwesomeIcon
icon={currentThemeOption.icon}
className="text-gray-500"
/>
</div>
<div>
<div className="font-medium text-sm">Theme Mode</div>
<div className="text-xs text-gray-400">
{currentThemeOption.label}
</div>
</div>
</div>
<motion.div
animate={{ rotate: showOptions ? 180 : 0 }}
transition={{ duration: 0.2 }}
className={actualTheme === 'dark' ? 'text-gray-400' : 'text-gray-500'}
>
<FontAwesomeIcon icon={faChevronDown} className="text-sm" />
</motion.div>
</button>
{/* 主題選項下拉選單 */}
<AnimatePresence>
{showOptions && (
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.2 }}
className={`
absolute bottom-full left-4 right-4 mb-2 rounded-lg shadow-xl border
${actualTheme === 'dark'
? 'bg-gray-800 border-gray-700'
: 'bg-gray-300 border-gray-200'
}
`}
style={{ zIndex: 1000 }}
>
{themeOptions.map((option) => (
<button
key={option.value}
onClick={() => {
setTheme(option.value)
setShowOptions(false)
}}
className={`
w-full p-3 flex items-center space-x-3 transition-colors duration-200
first:rounded-t-lg last:rounded-b-lg
${theme === option.value
? (actualTheme === 'dark'
? 'bg-blue-600 text-white'
: 'bg-blue-500 text-white'
)
: (actualTheme === 'dark'
? 'text-gray-300 hover:bg-gray-700'
: 'text-gray-700 hover:bg-gray-50'
)
}
`}
>
<div className="p-2 rounded-lg">
<FontAwesomeIcon
icon={option.icon}
className={theme === option.value ? 'text-white' : 'text-gray-500'}
/>
</div>
<div className="flex-1 text-left">
<div className="font-medium text-sm">{option.label}</div>
</div>
{theme === option.value && (
<div className="w-2 h-2 bg-white rounded-full"></div>
)}
</button>
))}
</motion.div>
)}
</AnimatePresence>
{/* 點擊外部關閉 */}
{showOptions && (
<div
className="fixed inset-0 z-40"
onClick={() => setShowOptions(false)}
/>
)}
</div>
)
}
export default ThemeToggle

View File

@ -1,32 +0,0 @@
let httpProtocol = "http";
let websocketProtocol = "ws";
const hostname = "192.168.1.3";
const port = 8080;
export const host = `${hostname}:${port}`;
export const siteUrl = `${httpProtocol}://${host}`
export const urls = {
bootTime: `${httpProtocol}://${host}/misc/boot_time`,
access_control: {
ipv4: {
white_list: `${httpProtocol}://${host}/ebpf/access_control/ipv4/source/white_list`,
black_list: `${httpProtocol}://${host}/ebpf/access_control/ipv4/source/black_list`,
},
ipv6: {
white_list: `${httpProtocol}://${host}/ebpf/access_control/ipv6/source/white_list`,
black_list: `${httpProtocol}://${host}/ebpf/access_control/ipv6/source/black_list`,
}
}
} as const;
export const websocketUrl = {
ipv4FlowStats: (direction: string, flow_direction: string, timeType: string) =>
`${websocketProtocol}://${host}/ebpf/statistics/websocket/ipv4/${direction}/${flow_direction}/${timeType}`,
ipv6FlowStats: (direction: string, flow_direction: string, timeType: string) =>
`${websocketProtocol}://${host}/ebpf/statistics/websocket/ipv6/${direction}/${flow_direction}/${timeType}`,
systemHealth: `${websocketProtocol}://${host}/health/websocket/metrics`,
detectionAlert: `${websocketProtocol}://${host}/detection/websocket/alert`,
} as const;
export const REFRESH_INTERVAL = 50000;

View File

@ -1,216 +0,0 @@
// pages/404.tsx
import React from 'react';
import Link from 'next/link';
import Head from 'next/head';
import { motion } from 'framer-motion';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faHome,
faArrowLeft,
faExclamationTriangle,
faShieldAlt,
faRobot,
faTachometerAlt,
faChartBar
} from '@fortawesome/free-solid-svg-icons';
const Custom404: React.FC = () => {
const navigationCards = [
{
href: "/dashboard",
icon: faTachometerAlt,
title: "Dashboard",
description: "System Dashboard Overview",
color: "blue"
},
{
href: "/statistics",
icon: faChartBar,
title: "Statistics",
description: "Data Statistics and Analysis",
color: "green"
},
{
href: "/access_control",
icon: faShieldAlt,
title: "Access Control",
description: "Access Control Management",
color: "red"
},
{
href: "/detection",
icon: faRobot,
title: "Detection",
description: "Threat Detection System",
color: "purple"
}
];
const getColorClasses = (color: string) => {
const colorMap = {
blue: {
bg: "bg-blue-500",
hover: "hover:bg-blue-600",
border: "border-blue-200",
hoverBorder: "hover:border-blue-300",
hoverBg: "hover:bg-blue-50",
text: "text-blue-600"
},
purple: {
bg: "bg-purple-500",
hover: "hover:bg-purple-600",
border: "border-purple-200",
hoverBorder: "hover:border-purple-300",
hoverBg: "hover:bg-purple-50",
text: "text-purple-600"
},
green: {
bg: "bg-green-500",
hover: "hover:bg-green-600",
border: "border-green-200",
hoverBorder: "hover:border-green-300",
hoverBg: "hover:bg-green-50",
text: "text-green-600"
},
red: {
bg: "bg-red-500",
hover: "hover:bg-red-600",
border: "border-red-200",
hoverBorder: "hover:border-red-300",
hoverBg: "hover:bg-red-50",
text: "text-red-600"
}
};
return colorMap[color as keyof typeof colorMap];
};
return (
<>
<Head>
<title>404 - Page Not Found | NetGuardia</title>
<meta name="description" content="The page you are looking for does not exist." />
</Head>
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100">
<div className="container mx-auto px-4 py-16">
<div className="max-w-4xl mx-auto">
{/* 主要錯誤區域 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center mb-12"
>
<h1 className="text-7xl font-bold bg-gradient-to-r from-gray-700 to-gray-900 bg-clip-text text-transparent mb-4">
404
</h1>
<h2 className="text-3xl font-semibold text-gray-800 mb-4">
Page Not Found
</h2>
<p className="text-lg text-gray-600 max-w-lg mx-auto leading-relaxed">
Sorry, the page you are looking for does not exist or has been moved.<br />
Please choose from the options below to continue using the NetGuardia system.
</p>
</motion.div>
{/* 導航卡片區域 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="mb-12"
>
<h3 className="text-xl font-semibold text-gray-800 mb-6 text-center">
Quick Navigation to Main Features
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{navigationCards.map((card, index) => {
const colors = getColorClasses(card.color);
return (
<motion.div
key={card.href}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 + index * 0.1 }}
whileHover={{ y: -4 }}
>
<Link href={card.href} className="block">
<div className={`bg-white rounded-xl p-6 shadow-md border-2 ${colors.border} ${colors.hoverBorder} ${colors.hoverBg} transition-all duration-300 hover:shadow-lg group h-full`}>
<div className="flex items-start space-x-4">
<div className={`w-14 h-14 ${colors.bg} ${colors.hover} rounded-xl flex items-center justify-center transition-colors shadow-sm`}>
<FontAwesomeIcon icon={card.icon} className="text-white text-xl" />
</div>
<div className="flex-1">
<h4 className="text-lg font-semibold text-gray-900 mb-2 group-hover:text-gray-700 transition-colors">
{card.title}
</h4>
<p className="text-gray-600 leading-relaxed">
{card.description}
</p>
</div>
</div>
</div>
</Link>
</motion.div>
);
})}
</div>
</motion.div>
{/* 操作按鈕 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.7 }}
className="flex flex-col sm:flex-row gap-4 justify-center mb-12"
>
<Link href="/">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="w-full sm:w-auto bg-gradient-to-r from-blue-600 to-blue-700 text-white px-8 py-4 rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition-all duration-300 flex items-center justify-center gap-3 shadow-lg"
>
<FontAwesomeIcon icon={faHome} />
Back to Home
</motion.button>
</Link>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => window.history.back()}
className="w-full sm:w-auto bg-gray-600 text-white px-8 py-4 rounded-lg font-semibold hover:bg-gray-700 transition-all duration-300 flex items-center justify-center gap-3 shadow-lg"
>
<FontAwesomeIcon icon={faArrowLeft} />
Back to Previous Page
</motion.button>
</motion.div>
{/* 底部信息 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.9 }}
className="text-center"
>
<div className="bg-white rounded-lg p-6 shadow-sm border border-gray-200">
<p className="text-gray-600 mb-2">
<strong>Need Help?</strong>
</p>
<p className="text-sm text-gray-500">
If you believe this is a system error, please contact the technical support team or system administrator.
</p>
</div>
</motion.div>
</div>
</div>
</div>
</>
);
};
export default Custom404;

View File

@ -1,22 +0,0 @@
// src/pages/_app.tsx - 深色模式版本
import type { AppProps } from 'next/app'
import { ErrorBoundary } from 'react-error-boundary'
import { ThemeProvider } from '../providers/ThemeProvider'
import { WebSocketProvider } from '../providers/WebSocketProvider'
import { AccessControlProvider } from '../providers/AccessControlProvider'
import ErrorDialog from '../components/ErrorDialog'
import '../styles/globals.css'
export default function App({ Component, pageProps }: AppProps) {
return (
<ErrorBoundary FallbackComponent={ErrorDialog}>
<ThemeProvider>
<WebSocketProvider>
<AccessControlProvider>
<Component {...pageProps} />
</AccessControlProvider>
</WebSocketProvider>
</ThemeProvider>
</ErrorBoundary>
)
}

View File

@ -1,848 +0,0 @@
import React, { useState, useEffect, useContext, useCallback, useMemo } from 'react'
import Head from 'next/head'
import { motion, AnimatePresence } from 'framer-motion'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faPlus,
faTrash,
faSearch,
faFilter,
faCheck,
faShieldAlt,
faNetworkWired,
faGlobe,
faInfoCircle,
faTimes,
faExclamationTriangle,
faRefresh,
faClock
} from '@fortawesome/free-solid-svg-icons'
import { AccessControlContext } from '../providers/AccessControlProvider'
import { useTheme } from '../providers/ThemeProvider'
import { putData, deleteData } from '../utils/connectionUtils'
import { urls } from '../config'
import Layout from '../components/Layout'
import LoadingSpinner from '../components/LoadingSpinner'
import {
AccessControlItem,
NotificationProps,
ModalProps,
ToggleButtonProps,
StatsCardProps
} from '../types/AccessControlTypes'
// 通知組件
const Notification: React.FC<NotificationProps> = ({ message, type, onClose }) => {
useEffect(() => {
const timer = setTimeout(() => {
onClose()
}, 4000)
return () => clearTimeout(timer)
}, [onClose])
const typeStyles = {
success: 'bg-green-100 border-green-400 text-green-700',
error: 'bg-red-100 border-red-400 text-red-700',
warning: 'bg-yellow-100 border-yellow-400 text-yellow-700',
info: 'bg-blue-100 border-blue-400 text-blue-700'
}
return (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.95 }}
className={`fixed top-4 right-4 z-50 px-4 py-3 border rounded-lg shadow-lg max-w-md ${typeStyles[type]}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{message}</span>
<button
onClick={onClose}
className="ml-3 text-xl font-bold hover:opacity-70 transition-opacity"
>
×
</button>
</div>
</motion.div>
)
}
// 模態框組件
const Modal: React.FC<ModalProps> = ({
title,
children,
onClose,
onSubmit,
submitLabel = 'Submit',
submitDisabled = false
}) => {
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm bg-black/30 p-4"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className={`rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto ${isDark ? 'bg-gray-800' : 'bg-white'}`}
onClick={(e) => e.stopPropagation()}
>
<div className={`px-6 py-4 border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
<div className="flex items-center justify-between">
<h2 className={`text-xl font-semibold ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>{title}</h2>
<button
onClick={onClose}
className={`transition-colors ${isDark ? 'text-gray-400 hover:text-gray-200' : 'text-gray-400 hover:text-gray-600'}`}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
</div>
<div className="px-6 py-4">
{children}
</div>
<div className={`px-6 py-4 border-t flex justify-end space-x-3 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
<button
onClick={onClose}
className={`px-4 py-2 rounded-lg transition-colors ${
isDark ? 'text-gray-300 bg-gray-700 hover:bg-gray-600' : 'text-gray-700 bg-gray-100 hover:bg-gray-200'
}`}
>
Cancel
</button>
{onSubmit && (
<button
onClick={onSubmit}
disabled={submitDisabled}
className={`px-4 py-2 rounded-lg transition-colors ${
submitDisabled
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{submitLabel}
</button>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
// 切換按鈕組件
const ToggleButton: React.FC<ToggleButtonProps> = ({ isActive, onClick, children, className = "" }) => {
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
return (
<button
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors ${className} ${
isActive
? "bg-blue-600 text-white"
: `${isDark ? 'bg-gray-700 text-gray-300 hover:bg-gray-650' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`
}`}
onClick={onClick}
>
{children}
</button>
)
}
const StatsCard: React.FC<StatsCardProps> = ({ title, value, icon, color }) => {
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={`rounded-lg shadow-md p-6 ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
<div className="flex items-center justify-between">
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>{title}</p>
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>{value.toLocaleString()}</p>
</div>
<div className={`${color} p-3 rounded-lg`}>
<FontAwesomeIcon icon={icon} className="text-white text-xl" />
</div>
</div>
</motion.div>
)
}
// 主要組件
const AccessControl: React.FC = () => {
const {
currentData,
filteredData,
isLoading,
setFilteredData,
switchTo,
refreshData,
getCacheStatus
} = useContext(AccessControlContext)
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
// 狀態管理
const [isIPv6, setIsIPv6] = useState(false)
const [listType, setListType] = useState<'black_list' | 'white_list'>('black_list')
const [searchTerm, setSearchTerm] = useState('')
const [isModalOpen, setIsModalOpen] = useState(false)
const [newIp, setNewIp] = useState('')
const [newPort, setNewPort] = useState('')
const [blockAllPorts, setBlockAllPorts] = useState(false)
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false)
const [itemToDelete, setItemToDelete] = useState<{ ip: string; port: string | number } | null>(null)
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// 切换IP版本和列表类型 - 无跳动的平滑切换
const handleIPVersionChange = useCallback((newIsIPv6: boolean) => {
if (newIsIPv6 !== isIPv6) {
setIsIPv6(newIsIPv6)
switchTo(newIsIPv6, listType)
}
}, [isIPv6, listType, switchTo])
const handleListTypeChange = useCallback((newListType: 'black_list' | 'white_list') => {
if (newListType !== listType) {
setListType(newListType)
switchTo(isIPv6, newListType)
}
}, [isIPv6, listType, switchTo])
// 其他回调函数
const handleAddItemClick = useCallback(() => {
setIsModalOpen(true)
setNewIp('')
setNewPort('')
setBlockAllPorts(false)
}, [])
const handleModalClose = useCallback(() => {
setIsModalOpen(false)
setNewIp('')
setNewPort('')
setBlockAllPorts(false)
}, [])
// 通知功能
const showNotification = useCallback((message: string, type: 'success' | 'error' | 'warning' | 'info' = 'success') => {
setNotification({ message, type })
}, [])
const closeNotification = useCallback(() => {
setNotification(null)
}, [])
// 手动更新資料
const handleRefresh = useCallback(async () => {
try {
await refreshData(isIPv6, listType)
showNotification('Data updated', 'success')
} catch (error) {
showNotification('Update failed', 'error')
}
}, [isIPv6, listType, refreshData, showNotification])
// 初始化資料 - 组件挂载时加载默认資料
useEffect(() => {
switchTo(isIPv6, listType)
}, []) // 只在组件挂载时执行一次
// 搜索过滤 - 基于currentData而不是重新获取
useEffect(() => {
if (!searchTerm.trim()) {
setFilteredData(currentData)
return
}
const lowerSearchTerm = searchTerm.toLowerCase()
const filtered = currentData.filter(({ ip }) =>
ip.toLowerCase().includes(lowerSearchTerm)
)
setFilteredData(filtered)
}, [searchTerm, currentData, setFilteredData])
// 展平資料,便於表格顯示
const flatData = useMemo(() =>
filteredData.flatMap(({ ip, ports }) =>
ports.map((port) => ({ ip, port }))
),
[filteredData]
)
// 統計資料
const stats = useMemo(() => {
const uniqueIPs = new Set(filteredData.map(item => item.ip)).size
const totalEntries = flatData.length
const allPortsEntries = flatData.filter(item => item.port === "0" || item.port === 0).length
return {
uniqueIPs,
totalEntries,
allPortsEntries,
specificPortEntries: totalEntries - allPortsEntries
}
}, [filteredData, flatData])
// IP 驗證
const validateIP = useCallback((ip: string, isIPv6: boolean): boolean => {
if (isIPv6) {
// 簡單的 IPv6 驗證
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$/
return ipv6Regex.test(ip) || ip.includes('::')
} else {
// IPv4 驗證
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
if (!ipv4Regex.test(ip)) return false
const parts = ip.split('.')
return parts.every(part => {
const num = parseInt(part, 10)
return num >= 0 && num <= 255
})
}
}, [])
// 端口驗證
const validatePort = useCallback((port: string): boolean => {
if (!port.trim()) return false
const portNum = parseInt(port, 10)
return !isNaN(portNum) && portNum >= 1 && portNum <= 65535
}, [])
// 添加項目
const handleAddItemSubmit = useCallback(async () => {
if (!newIp.trim()) {
showNotification('Please enter a valid IP address', 'error')
return
}
if (!validateIP(newIp.trim(), isIPv6)) {
showNotification(`Please enter a valid ${isIPv6 ? 'IPv6' : 'IPv4'} address`, 'error')
return
}
if (!blockAllPorts && !validatePort(newPort)) {
showNotification('Please enter a valid port number (1-65535)', 'error')
return
}
const portToSend = blockAllPorts ? "0" : newPort
const url = isIPv6
? urls.access_control.ipv6[listType]
: urls.access_control.ipv4[listType]
setIsSubmitting(true)
try {
await new Promise<void>((resolve, reject) => {
putData(
url,
`${newIp.trim()}:${portToSend}`,
() => {
showNotification(
`Successfully added: ${newIp.trim()}:${blockAllPorts ? '*' : portToSend}`,
'success'
)
setIsModalOpen(false)
setNewIp('')
setNewPort('')
setBlockAllPorts(false)
// 更新当前資料
refreshData(isIPv6, listType)
resolve()
},
(error) => {
showNotification(`Failed to add: ${error.message}`, 'error')
reject(error)
}
)
})
} catch (error) {
console.error('Add item error:', error)
} finally {
setIsSubmitting(false)
}
}, [newIp, newPort, blockAllPorts, isIPv6, listType, refreshData, showNotification, validateIP, validatePort])
// 刪除處理
const handleDeleteClick = useCallback((ip: string, port: string | number) => {
setItemToDelete({ ip, port })
setIsConfirmModalOpen(true)
}, [])
const handleDeleteCancel = useCallback(() => {
setIsConfirmModalOpen(false)
setItemToDelete(null)
}, [])
const handleDeleteConfirm = useCallback(async () => {
if (!itemToDelete) return
const { ip, port } = itemToDelete
const formattedIp = isIPv6 ? `[${ip}]` : ip
const url = isIPv6
? urls.access_control.ipv6[listType]
: urls.access_control.ipv4[listType]
setIsSubmitting(true)
try {
await new Promise<void>((resolve, reject) => {
deleteData(
url,
`${formattedIp}:${port}`,
() => {
showNotification(
`Successfully deleted: ${formattedIp}:${port === "0" || port === 0 ? "*" : port}`,
'success'
)
// 更新当前資料
refreshData(isIPv6, listType)
setIsConfirmModalOpen(false)
setItemToDelete(null)
resolve()
},
(error) => {
showNotification(`Failed to delete: ${error.message}`, 'error')
setIsConfirmModalOpen(false)
setItemToDelete(null)
reject(error)
}
)
})
} catch (error) {
console.error('Delete item error:', error)
} finally {
setIsSubmitting(false)
}
}, [itemToDelete, isIPv6, listType, refreshData, showNotification])
// 搜索處理
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
}, [])
// 全部端口勾選處理
const handleBlockAllPortsChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setBlockAllPorts(e.target.checked)
if (e.target.checked) {
setNewPort('')
}
}, [])
// 获取缓存状态用于调试
const cacheStatus = getCacheStatus()
return (
<>
<Head>
<title>Access Control - NetGuardia</title>
<meta name="description" content="NetGuardia Access Control Management - IP Whitelist and Blacklist" />
</Head>
<Layout>
{/* 通知 */}
<AnimatePresence>
{notification && (
<Notification
message={notification.message}
type={notification.type}
onClose={closeNotification}
/>
)}
</AnimatePresence>
{/* 頁面標題 */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<div className="flex items-center justify-between">
<div>
<h1 className={`text-3xl font-bold mb-2 flex items-center ${isDark ? 'text-white' : 'text-gray-900'}`}>
<FontAwesomeIcon icon={faShieldAlt} className="mr-3 text-blue-600" />
Access Control Management
</h1>
<p className={isDark ? 'text-gray-400' : 'text-gray-600'}>
Manage IPv4 and IPv6 network access whitelists and blacklists
</p>
</div>
</div>
</motion.div>
{/* 統計卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Unique IPs"
value={stats.uniqueIPs}
icon={faGlobe}
color="bg-blue-500"
/>
<StatsCard
title="Total Rules"
value={stats.totalEntries}
icon={faNetworkWired}
color="bg-green-500"
/>
<StatsCard
title="All Ports Rules"
value={stats.allPortsEntries}
icon={faFilter}
color="bg-purple-500"
/>
<StatsCard
title="Specific Port Rules"
value={stats.specificPortEntries}
icon={faCheck}
color="bg-orange-500"
/>
</div>
{/* 控制面板 */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className={`rounded-lg shadow-md p-6 mb-6 ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
<div className="flex flex-wrap items-center gap-4">
{/* 搜索框 */}
<div className="flex-1 min-w-64">
<div className="relative">
<FontAwesomeIcon
icon={faSearch}
className={`absolute left-2.5 top-1/2 transform -translate-y-1/2 text-sm ${isDark ? 'text-gray-400' : 'text-gray-400'}`}
/>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search IP Address..."
className={`w-full pl-8 pr-3 py-1 text-sm border rounded-lg focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 ${
isDark ? 'bg-gray-700 border-gray-500 text-gray-300 placeholder-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400'
}`}
/>
</div>
</div>
{/* IP 版本選擇 */}
<div className="flex items-center space-x-2">
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>IP Version:</span>
<div className="flex space-x-1">
<ToggleButton
isActive={!isIPv6}
onClick={() => setIsIPv6(false)}
>
IPv4
</ToggleButton>
<ToggleButton
isActive={isIPv6}
onClick={() => setIsIPv6(true)}
>
IPv6
</ToggleButton>
</div>
</div>
{/* 列表類型選擇 */}
<div className="flex items-center space-x-2">
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>List Type:</span>
<div className="flex space-x-1">
<ToggleButton
isActive={listType === "black_list"}
onClick={() => handleListTypeChange("black_list")}
>
<FontAwesomeIcon icon={faFilter} className="mr-1" />
Blacklist
</ToggleButton>
<ToggleButton
isActive={listType === "white_list"}
onClick={() => handleListTypeChange("white_list")}
>
<FontAwesomeIcon icon={faCheck} className="mr-1" />
Whitelist
</ToggleButton>
</div>
</div>
{/* 操作按鈕 */}
<div className="flex items-center space-x-2 ml-auto">
<button
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors flex items-center space-x-1 ${
isDark ? 'bg-gray-700 text-gray-300 hover:bg-gray-650' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
onClick={handleRefresh}
title="Update"
>
<FontAwesomeIcon icon={faRefresh} className="text-xs" />
<span>Update</span>
</button>
<button
className="px-3 py-1 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors flex items-center space-x-1"
onClick={handleAddItemClick}
>
<FontAwesomeIcon icon={faPlus} className="text-xs" />
<span>Add Rule</span>
</button>
</div>
</div>
</motion.div>
{/* 資料表格 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={`rounded-lg shadow-md overflow-hidden ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
<div className={`px-6 py-4 border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
<div className="flex justify-between items-center">
<h2 className={`text-xl font-semibold flex items-center ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>
{isIPv6 ? 'IPv6' : 'IPv4'} {listType === 'black_list' ? 'Blacklist' : 'Whitelist'}
{isLoading && (
<div className="ml-3">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
</div>
)}
</h2>
<span className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
{flatData.length} Rules
</span>
</div>
</div>
{/* 简化表格切换,移除奇怪的动画 */}
{flatData.length === 0 ? (
<div className="p-12 text-center">
<FontAwesomeIcon icon={faInfoCircle} className={`text-6xl mb-4 ${isDark ? 'text-gray-500' : 'text-gray-300'}`} />
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
{searchTerm ? 'No matching results found' : 'No data yet'}
</h3>
<p className={`mb-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
{searchTerm ? 'Please try adjusting your search criteria' : 'Click the button above to add the first rule'}
</p>
{!searchTerm && (
<button
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={handleAddItemClick}
>
<FontAwesomeIcon icon={faPlus} className="mr-2" />
Add Rule
</button>
)}
</div>
) : (
<div className="overflow-x-auto">
<table className={`min-w-full divide-y ${isDark ? 'divide-gray-700' : 'divide-gray-200'}`}>
<thead className={isDark ? 'bg-gray-700' : 'bg-gray-50'}>
<tr>
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
IP Address
</th>
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
Port
</th>
<th className={`px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
Action
</th>
</tr>
</thead>
<tbody className={`divide-y ${isDark ? 'bg-gray-600 divide-gray-700' : 'bg-white divide-gray-200'}`}>
{flatData.map(({ ip, port }, index) => (
<tr
key={`${ip}-${port}-${index}`}
className={`transition-colors ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-50'}`}
>
<td className={`px-6 py-4 whitespace-nowrap text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>
{ip}
</td>
<td className={`px-6 py-4 whitespace-nowrap text-sm ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
port === "0" || port === 0
? 'bg-red-100 text-red-800'
: (isDark ? 'bg-blue-900/50 text-blue-300' : 'bg-blue-100 text-blue-800')
}`}>
{port === "0" || port === 0 ? "All ports (*)" : port}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
className="px-3 py-1 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 transition-colors flex items-center space-x-1"
onClick={() => handleDeleteClick(ip, port)}
title="Delete Rule"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</motion.div>
{/* 添加規則模態框 */}
{isModalOpen && (
<Modal
title={`Add ${isIPv6 ? 'IPv6' : 'IPv4'} ${listType === 'black_list' ? 'Blacklist' : 'Whitelist'} Rule`}
onClose={handleModalClose}
onSubmit={handleAddItemSubmit}
submitLabel="Add"
submitDisabled={isSubmitting}
>
<div className="space-y-4">
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
IP Address
</label>
<input
type="text"
value={newIp}
onChange={(e) => setNewIp(e.target.value)}
placeholder={isIPv6 ? "2001:db8::" : "192.168.1.1"}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 ${
isDark ? 'bg-gray-700 border-gray-600 text-gray-300 placeholder-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400'
}`}
/>
</div>
<div>
<div className="flex items-center space-x-2 mb-2">
<input
id="block-all-ports"
type="checkbox"
checked={blockAllPorts}
onChange={handleBlockAllPortsChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="block-all-ports" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Block All Ports
</label>
</div>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Port
</label>
<input
type="text"
value={blockAllPorts ? "" : newPort}
onChange={(e) => setNewPort(e.target.value)}
placeholder="80"
disabled={blockAllPorts}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 ${
blockAllPorts
? (isDark ? 'bg-gray-600 text-gray-500 border-gray-600' : 'bg-gray-100 text-gray-500 border-gray-300')
: (isDark ? 'bg-gray-700 border-gray-600 text-gray-300' : 'bg-white border-gray-300 text-gray-900')
}`}
/>
{blockAllPorts && (
<p className={`mt-1 text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
Block all ports for this IP
</p>
)}
</div>
<div className={`border rounded-lg p-3 ${isDark ? 'bg-blue-900/30 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
<div className="flex items-start">
<FontAwesomeIcon icon={faInfoCircle} className={`mt-0.5 mr-2 ${isDark ? 'text-blue-400' : 'text-blue-400'}`} />
<div className={`text-sm ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
<p className="font-medium mb-1">
Add to {listType === 'black_list' ? 'Blacklist' : 'Whitelist'}
</p>
<p>
{listType === 'black_list'
? 'IP addresses in the Blacklist will be denied access'
: 'Only IP addresses in the Whitelist can access'
}
{blockAllPorts && ', all ports will be affected'}
</p>
</div>
</div>
</div>
</div>
</Modal>
)}
{/* 確認刪除模態框 */}
{isConfirmModalOpen && (
<Modal
title="Confirm Deletion"
onClose={handleDeleteCancel}
onSubmit={handleDeleteConfirm}
submitLabel="Delete"
submitDisabled={isSubmitting}
>
<div className="space-y-4">
<div className="flex items-center text-yellow-600 mb-4">
<FontAwesomeIcon icon={faExclamationTriangle} className="mr-2" />
<span className="font-medium">Warning</span>
</div>
<p className={isDark ? 'text-gray-300' : 'text-gray-700'}>
Are you sure you want to delete this rule?
</p>
{itemToDelete && (
<div className={`border rounded-lg p-4 ${isDark ? 'bg-gray-700 border-gray-600' : 'bg-gray-50 border-gray-200'}`}>
<div className="space-y-2">
<div className="flex justify-between">
<span className={`font-medium ${isDark ? 'text-gray-400' : 'text-gray-700'}`}>IP Address:</span>
<span className={isDark ? 'text-gray-300' : 'text-gray-900'}>{itemToDelete.ip}</span>
</div>
<div className="flex justify-between">
<span className={`font-medium ${isDark ? 'text-gray-400' : 'text-gray-700'}`}>Port:</span>
<span className={isDark ? 'text-gray-300' : 'text-gray-900'}>
{itemToDelete.port === "0" || itemToDelete.port === 0
? "All Ports (*)"
: itemToDelete.port
}
</span>
</div>
<div className="flex justify-between">
<span className={`font-medium ${isDark ? 'text-gray-400' : 'text-gray-700'}`}>List Type:</span>
<span className={isDark ? 'text-gray-300' : 'text-gray-900'}>
{listType === 'black_list' ? 'Blacklist' : 'Whitelist'}
</span>
</div>
</div>
</div>
)}
<div className={`border rounded-lg p-3 ${isDark ? 'bg-red-900/30 border-red-800' : 'bg-red-50 border-red-200'}`}>
<div className="flex items-start">
<FontAwesomeIcon icon={faExclamationTriangle} className={`mt-0.5 mr-2 ${isDark ? 'text-red-400' : 'text-red-400'}`} />
<p className={`text-sm ${isDark ? 'text-red-300' : 'text-red-700'}`}>
This action cannot be undone. Once deleted, this rule will take effect immediately.
</p>
</div>
</div>
</div>
</Modal>
)}
</Layout>
</>
)
}
export default AccessControl

File diff suppressed because it is too large Load Diff

View File

@ -1,856 +0,0 @@
import React, { useContext, useEffect, useState, useCallback, useMemo, useRef } from 'react'
import Head from 'next/head'
import { motion, AnimatePresence } from 'framer-motion'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faRobot,
faShieldAlt,
faExclamationTriangle,
faFilter,
faCheck,
faGlobe,
faTimes,
faClock,
faArrowRight,
faEye,
faList,
faPause,
faPlay,
} from '@fortawesome/free-solid-svg-icons'
import { WebsocketContext } from '../providers/WebSocketProvider'
import { useTheme } from '../providers/ThemeProvider'
import { putData } from '../utils/connectionUtils'
import { urls } from '../config'
import Layout from '../components/Layout'
import LoadingSpinner from '../components/LoadingSpinner'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type AlertSource = 'ml' | 'rule' | 'fusion'
type AlertSeverity = 'high' | 'critical'
interface UnifiedAlert {
timestamp: number
flow_key: string
src_ip: string
dst_ip: string
src_port: number
dst_port: number
protocol: number
source: AlertSource
severity: AlertSeverity
is_attack: boolean
attack_type: string | null
confidence: number
ae_score: number
rule_sid: number | null
rule_msg: string | null
}
interface PriorityInfo {
color: string
bgColor: string
label: string
}
interface NotificationProps {
message: string
type: 'success' | 'error' | 'warning' | 'info'
onClose: () => void
}
interface AlertDetailsProps {
log: UnifiedAlert
onClose: () => void
showNotification: (message: string, type: 'success' | 'error' | 'warning' | 'info') => void
}
interface AlertItemProps {
log: UnifiedAlert
index: number
onClick: (index: number) => void
}
interface FilterButtonProps {
isActive: boolean
onClick: () => void
children: React.ReactNode
className?: string
activeClassName?: string
activeHoverClassName?: string
}
// ---------------------------------------------------------------------------
// Constants & helpers
// ---------------------------------------------------------------------------
const UPDATE_INTERVALS = [
{ label: 'immediate', value: 0 },
{ label: '1s', value: 1000 },
{ label: '3s', value: 3000 },
{ label: '5s', value: 5000 },
{ label: '10s', value: 10000 },
]
const SOURCE_CONFIG: Record<AlertSource, { label: string; color: string; bgColor: string }> = {
ml: { label: 'ML', color: '#7c3aed', bgColor: 'rgba(124,58,237,0.1)' },
rule: { label: 'Rule', color: '#0284c7', bgColor: 'rgba(2,132,199,0.1)' },
fusion: { label: 'Fusion', color: '#0f766e', bgColor: 'rgba(15,118,110,0.1)' },
}
const SEVERITY_CONFIG: Record<AlertSeverity, PriorityInfo> = {
critical: { color: '#e53e3e', bgColor: 'rgba(239,68,68,0.1)', label: 'Critical' },
high: { color: '#dd6b20', bgColor: 'rgba(251,146,60,0.1)', label: 'High' },
}
const getProtocolName = (protocol: number): string => {
switch (protocol) {
case 1: return 'ICMP'
case 6: return 'TCP'
case 17: return 'UDP'
case 47: return 'GRE'
case 50: return 'ESP'
case 51: return 'AH'
case 58: return 'ICMPv6'
default: return `Proto ${protocol}`
}
}
const formatTimestamp = (timestamp: number): string => {
if (!timestamp) return 'N/A'
return new Date(timestamp * 1000).toLocaleString('en-GB', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
}
const parseAlertData = (rawData: any): UnifiedAlert | null => {
try {
const data = typeof rawData === 'string' ? JSON.parse(rawData) : rawData
if (!data || typeof data !== 'object') return null
const source: AlertSource = data.source === 'ml' || data.source === 'rule' || data.source === 'fusion'
? data.source : 'ml'
const severity: AlertSeverity = data.severity === 'critical' ? 'critical' : 'high'
return {
timestamp: data.timestamp ?? 0,
flow_key: data.flow_key ?? '',
src_ip: data.src_ip ?? 'Unknown',
dst_ip: data.dst_ip ?? 'Unknown',
src_port: data.src_port ?? 0,
dst_port: data.dst_port ?? 0,
protocol: data.protocol ?? 0,
source,
severity,
is_attack: data.is_attack ?? false,
attack_type: data.attack_type ?? null,
confidence: data.confidence ?? 0,
ae_score: data.ae_score ?? 0,
rule_sid: data.rule_sid ?? null,
rule_msg: data.rule_msg ?? null,
}
} catch {
return null
}
}
// ---------------------------------------------------------------------------
// Notification
// ---------------------------------------------------------------------------
const Notification: React.FC<NotificationProps> = ({ message, type, onClose }) => {
useEffect(() => {
const timer = setTimeout(onClose, 4000)
return () => clearTimeout(timer)
}, [onClose])
const typeStyles = {
success: 'bg-green-100 border-green-400 text-green-700',
error: 'bg-red-100 border-red-400 text-red-700',
warning: 'bg-yellow-100 border-yellow-400 text-yellow-700',
info: 'bg-blue-100 border-blue-400 text-blue-700',
}
return (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.95 }}
className={`fixed top-4 right-4 z-50 px-4 py-3 border rounded-lg shadow-lg max-w-md ${typeStyles[type]}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{message}</span>
<button onClick={onClose} className="ml-3 text-xl font-bold hover:opacity-70 transition-opacity">×</button>
</div>
</motion.div>
)
}
// ---------------------------------------------------------------------------
// AlertDetails
// ---------------------------------------------------------------------------
const AlertDetails: React.FC<AlertDetailsProps> = ({ log, onClose, showNotification }) => {
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
const priorityInfo = SEVERITY_CONFIG[log.severity]
const sourceInfo = SOURCE_CONFIG[log.source]
const isIPv6Source = log.src_ip.includes(':') && !log.src_ip.includes('.')
const isIPv6Dest = log.dst_ip.includes(':') && !log.dst_ip.includes('.')
const handleBlockIP = useCallback((ip: string, port: number, isSource: boolean, blockAllPorts = false) => {
const isIPv6 = isSource ? isIPv6Source : isIPv6Dest
const formattedIp = isIPv6 ? `[${ip}]` : ip
const url = isIPv6 ? urls.access_control.ipv6.black_list : urls.access_control.ipv4.black_list
const portToSend = blockAllPorts ? '0' : String(port)
const displayPort = blockAllPorts ? '*' : String(port)
putData(
url,
`${formattedIp}:${portToSend}`,
() => showNotification(`Successfully added to blacklist: ${ip}:${displayPort}`, 'success'),
(error) => showNotification(`Failed to add to blacklist: ${error.message}`, 'error'),
)
}, [isIPv6Source, isIPv6Dest, showNotification])
const handleAllowIP = useCallback((ip: string, port: number, isSource: boolean, allowAllPorts = false) => {
const isIPv6 = isSource ? isIPv6Source : isIPv6Dest
const formattedIp = isIPv6 ? `[${ip}]` : ip
const url = isIPv6 ? urls.access_control.ipv6.white_list : urls.access_control.ipv4.white_list
const portToSend = allowAllPorts ? '0' : String(port)
const displayPort = allowAllPorts ? '*' : String(port)
putData(
url,
`${formattedIp}:${portToSend}`,
() => showNotification(`Successfully added to whitelist: ${ip}:${displayPort}`, 'success'),
(error) => showNotification(`Failed to add to whitelist: ${error.message}`, 'error'),
)
}, [isIPv6Source, isIPv6Dest, showNotification])
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className={`rounded-lg shadow-md p-6 max-h-[600px] overflow-y-auto ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h3 className={`text-xl font-semibold flex items-center ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>
<FontAwesomeIcon icon={faEye} className="mr-2 text-blue-600" />
Alert Details
</h3>
<button
onClick={onClose}
className={`transition-colors ${isDark ? 'text-gray-400 hover:text-gray-200' : 'text-gray-400 hover:text-gray-600'}`}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
{/* Basic info */}
<div className="space-y-4 mb-6">
<div className="flex justify-between">
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Timestamp:</span>
<span className={`text-sm ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>{formatTimestamp(log.timestamp)}</span>
</div>
<div className="flex justify-between items-center">
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Severity:</span>
<span
className="inline-flex px-2 py-1 text-xs font-medium rounded-full"
style={{ backgroundColor: priorityInfo.bgColor, color: priorityInfo.color }}
>
<FontAwesomeIcon icon={faExclamationTriangle} className="mr-1" />
{priorityInfo.label}
</span>
</div>
<div className="flex justify-between items-center">
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Source:</span>
<span
className="inline-flex px-2 py-1 text-xs font-medium rounded-full"
style={{ backgroundColor: sourceInfo.bgColor, color: sourceInfo.color }}
>
{sourceInfo.label}
</span>
</div>
<div className="flex justify-between">
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Attack Type:</span>
<span className={`text-sm ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>{log.attack_type ?? '—'}</span>
</div>
<div className="flex justify-between">
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Protocol:</span>
<span className={`text-sm ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>{getProtocolName(log.protocol)}</span>
</div>
<div className="flex justify-between">
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Is Attack:</span>
<span className={`text-sm font-medium ${log.is_attack ? 'text-red-500' : 'text-green-500'}`}>
{log.is_attack ? 'Yes' : 'No'}
</span>
</div>
</div>
{/* ML scores — only when source is ml or fusion */}
{(log.source === 'ml' || log.source === 'fusion') && (
<div className="mb-6">
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>ML Score Details:</span>
<div className={`mt-2 p-3 rounded-lg space-y-2 ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
{[
{ label: 'Confidence', value: log.confidence },
{ label: 'AE Score', value: log.ae_score },
].map(({ label, value }) => (
<div key={label} className="flex justify-between items-center">
<span className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>{label}:</span>
<span className={`text-sm font-mono font-semibold ${
value >= 1.0 ? 'text-red-500' :
value >= 0.5 ? 'text-orange-500' :
value >= 0.1 ? 'text-yellow-500' :
'text-green-500'
}`}>
{value.toFixed(4)}
</span>
</div>
))}
</div>
</div>
)}
{/* Rule details — only when source is rule or fusion */}
{(log.source === 'rule' || log.source === 'fusion') && (log.rule_sid !== null || log.rule_msg !== null) && (
<div className="mb-6">
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Rule Match:</span>
<div className={`mt-2 p-3 rounded-lg space-y-2 ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
{log.rule_sid !== null && (
<div className="flex justify-between">
<span className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>SID:</span>
<span className={`text-sm font-mono ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>{log.rule_sid}</span>
</div>
)}
{log.rule_msg !== null && (
<div className="flex justify-between gap-4">
<span className={`text-sm flex-shrink-0 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Message:</span>
<span className={`text-sm text-right ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>{log.rule_msg}</span>
</div>
)}
</div>
</div>
)}
{/* Connection details */}
<div className="mb-6">
<h4 className={`text-lg font-medium mb-3 ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>Connection Details</h4>
<div className={`flex items-center justify-between rounded-lg p-4 ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<div className="text-center">
<div className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Source</div>
<div className="text-lg font-semibold text-blue-600">{log.src_ip}</div>
<div className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Port: {log.src_port}</div>
</div>
<FontAwesomeIcon icon={faArrowRight} className={isDark ? 'text-gray-500' : 'text-gray-400'} />
<div className="text-center">
<div className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Destination</div>
<div className="text-lg font-semibold text-red-600">{log.dst_ip}</div>
<div className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Port: {log.dst_port}</div>
</div>
</div>
</div>
{/* IP management */}
<div className="space-y-6">
<h4 className={`text-lg font-medium ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>IP Management</h4>
{[
{ label: 'Source IP', ip: log.src_ip, port: log.src_port, isSource: true, bgClass: isDark ? 'bg-blue-900/30' : 'bg-blue-50' },
{ label: 'Destination IP', ip: log.dst_ip, port: log.dst_port, isSource: false, bgClass: isDark ? 'bg-red-900/30' : 'bg-red-50' },
].map(({ label, ip, port, isSource, bgClass }) => (
<div key={label} className={`rounded-lg p-4 ${bgClass}`}>
<h5 className={`font-medium mb-3 ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>{label}: {ip}</h5>
<div className="grid grid-cols-2 gap-2">
<button
className="px-3 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors"
onClick={() => handleBlockIP(ip, port, isSource)}
>
<FontAwesomeIcon icon={faFilter} className="mr-1" />
Block Port {port}
</button>
<button
className="px-3 py-2 bg-red-700 text-white text-sm rounded-lg hover:bg-red-800 transition-colors"
onClick={() => handleBlockIP(ip, port, isSource, true)}
>
<FontAwesomeIcon icon={faFilter} className="mr-1" />
Block All Ports
</button>
<button
className="px-3 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors"
onClick={() => handleAllowIP(ip, port, isSource)}
>
<FontAwesomeIcon icon={faCheck} className="mr-1" />
Allow Port {port}
</button>
<button
className="px-3 py-2 bg-green-700 text-white text-sm rounded-lg hover:bg-green-800 transition-colors"
onClick={() => handleAllowIP(ip, port, isSource, true)}
>
<FontAwesomeIcon icon={faCheck} className="mr-1" />
Allow All Ports
</button>
</div>
</div>
))}
</div>
</motion.div>
</AnimatePresence>
)
}
// ---------------------------------------------------------------------------
// AlertItem
// ---------------------------------------------------------------------------
const AlertItem: React.FC<AlertItemProps> = ({ log, index, onClick }) => {
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
const priorityInfo = SEVERITY_CONFIG[log.severity]
const sourceInfo = SOURCE_CONFIG[log.source]
const handleClick = useCallback(() => onClick(index), [index, onClick])
return (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className={`border rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow duration-200 ${
isDark ? 'bg-gray-700 border-gray-600' : 'bg-white border-gray-200'
}`}
onClick={handleClick}
>
<div className="flex items-start space-x-4">
<div
className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center"
style={{ backgroundColor: priorityInfo.bgColor }}
>
<FontAwesomeIcon icon={faExclamationTriangle} style={{ color: priorityInfo.color }} className="text-sm" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<span className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
{formatTimestamp(log.timestamp)}
</span>
<div className="flex items-center gap-1">
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ backgroundColor: sourceInfo.bgColor, color: sourceInfo.color }}
>
{sourceInfo.label}
</span>
{log.attack_type && (
<span className={`text-xs px-2 py-1 rounded-full ${
isDark ? 'bg-blue-900/50 text-blue-300' : 'bg-blue-100 text-blue-800'
}`}>
{log.attack_type}
</span>
)}
</div>
</div>
<div className={`text-sm mb-2 ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>
<span>{log.src_ip}:{log.src_port} </span>
<FontAwesomeIcon icon={faArrowRight} className={isDark ? 'text-gray-500' : 'text-gray-400'} />
<span> {log.dst_ip}:{log.dst_port}</span>
<span className="ml-1 text-xs opacity-70">({getProtocolName(log.protocol)})</span>
</div>
<div className="flex items-center justify-between">
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ backgroundColor: priorityInfo.bgColor, color: priorityInfo.color }}
>
{priorityInfo.label}
</span>
{(log.source === 'ml' || log.source === 'fusion') && (
<span className={`text-xs font-mono ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
{log.confidence.toFixed(4)}
</span>
)}
{(log.source === 'rule' || log.source === 'fusion') && log.rule_sid !== null && (
<span className={`text-xs font-mono ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
SID {log.rule_sid}
</span>
)}
</div>
</div>
</div>
</motion.div>
)
}
// ---------------------------------------------------------------------------
// FilterButton
// ---------------------------------------------------------------------------
const FilterButton: React.FC<FilterButtonProps> = ({
isActive, onClick, children,
className = '', activeClassName = '', activeHoverClassName = '',
}) => {
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
return (
<button
className={`px-4 py-2 rounded-lg transition-all duration-200 font-medium ${
isActive
? `${activeClassName || 'bg-blue-600 text-white shadow-lg'} ${activeHoverClassName}`
: `${isDark ? 'bg-gray-700 text-gray-300 border border-gray-600 hover:bg-gray-650' : 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'} ${className}`
}`}
onClick={onClick}
>
{children}
</button>
)
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
const Detection: React.FC = () => {
const { getDetectionAlertStream } = useContext(WebsocketContext)
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
const [logs, setLogs] = useState<UnifiedAlert[]>([])
const [selectedLogIndex, setSelectedLogIndex] = useState<number | null>(null)
const [severityFilter, setSeverityFilter] = useState<string>('all')
const [sourceFilter, setSourceFilter] = useState<string>('all')
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isPaused, setIsPaused] = useState(false)
const [updateInterval, setUpdateInterval] = useState(0)
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null)
const bufferRef = useRef<UnifiedAlert[]>([])
const updateIntervalRef = useRef<number>(updateInterval)
const isPausedRef = useRef<boolean>(isPaused)
useEffect(() => { updateIntervalRef.current = updateInterval }, [updateInterval])
useEffect(() => { isPausedRef.current = isPaused }, [isPaused])
const showNotification = useCallback((message: string, type: 'success' | 'error' | 'warning' | 'info' = 'success') => {
setNotification({ message, type })
}, [])
const closeNotification = useCallback(() => setNotification(null), [])
const flushBuffer = useCallback(() => {
if (bufferRef.current.length === 0) return
const newEntries = [...bufferRef.current]
bufferRef.current = []
setLogs(prev => [...newEntries, ...prev].slice(0, 200))
setLastUpdateTime(new Date())
}, [])
useEffect(() => {
if (updateInterval === 0 || isPaused) return
const timer = setInterval(flushBuffer, updateInterval)
return () => clearInterval(timer)
}, [updateInterval, isPaused, flushBuffer])
// Reads isPaused via ref so this callback never changes, avoiding WS re-subscription on pause toggle
const processNewLogData = useCallback((rawData: any) => {
if (isPausedRef.current) return
const alert = parseAlertData(rawData)
if (!alert) return
if (updateIntervalRef.current === 0) {
setLogs(prev => [alert, ...prev.slice(0, 199)])
setLastUpdateTime(new Date())
} else {
bufferRef.current.push(alert)
}
}, [])
useEffect(() => {
if (!getDetectionAlertStream) {
setIsLoading(false)
return
}
const loadingTimeout = setTimeout(() => setIsLoading(false), 3000)
const subscription = getDetectionAlertStream().subscribe({
next: (data: any) => {
clearTimeout(loadingTimeout)
processNewLogData(data)
setIsLoading(false)
},
error: () => {
clearTimeout(loadingTimeout)
setIsLoading(false)
},
})
return () => {
clearTimeout(loadingTimeout)
subscription.unsubscribe()
}
}, [getDetectionAlertStream, processNewLogData])
const filteredLogs = useMemo(() => {
return logs.filter(log => {
const severityOk = severityFilter === 'all' || log.severity === severityFilter
const sourceOk = sourceFilter === 'all' || log.source === sourceFilter
return severityOk && sourceOk
})
}, [logs, severityFilter, sourceFilter])
const stats = useMemo(() => ({
total: logs.length,
critical: logs.filter(l => l.severity === 'critical').length,
high: logs.filter(l => l.severity === 'high').length,
ml: logs.filter(l => l.source === 'ml').length,
rule: logs.filter(l => l.source === 'rule').length,
fusion: logs.filter(l => l.source === 'fusion').length,
uniqueIPs: new Set([...logs.map(l => l.src_ip), ...logs.map(l => l.dst_ip)]).size,
}), [logs])
const handleAlertClick = useCallback((index: number) => setSelectedLogIndex(index), [])
const handleCloseDetails = useCallback(() => setSelectedLogIndex(null), [])
const togglePause = useCallback(() => setIsPaused(p => !p), [])
if (isLoading) {
return (
<>
<Head><title>Detection - NetGuardia</title></Head>
<Layout>
<div className="flex items-center justify-center w-full h-full min-h-[calc(100vh-4rem)]">
<LoadingSpinner />
</div>
</Layout>
</>
)
}
return (
<>
<Head>
<title>Detection - NetGuardia</title>
<meta name="description" content="NetGuardia Unified Threat Detection" />
</Head>
<Layout>
<AnimatePresence>
{notification && (
<Notification message={notification.message} type={notification.type} onClose={closeNotification} />
)}
</AnimatePresence>
{/* Page header */}
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
<h1 className={`text-3xl font-bold mb-2 flex items-center ${isDark ? 'text-white' : 'text-gray-900'}`}>
<FontAwesomeIcon icon={faRobot} className="mr-3 text-purple-600" />
Threat Detection
</h1>
<p className={isDark ? 'text-gray-400' : 'text-gray-600'}>
Real-time alerts from ML inference, rule matching, and fusion engine
</p>
</motion.div>
{/* Stats cards */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 mb-8">
{[
{ label: 'Total', value: stats.total, icon: faList, bg: 'bg-blue-500' },
{ label: 'Critical', value: stats.critical, icon: faExclamationTriangle, bg: 'bg-red-500' },
{ label: 'High', value: stats.high, icon: faExclamationTriangle, bg: 'bg-orange-500' },
{ label: 'ML', value: stats.ml, icon: faRobot, bg: 'bg-purple-500' },
{ label: 'Rule', value: stats.rule, icon: faShieldAlt, bg: 'bg-sky-500' },
{ label: 'Fusion', value: stats.fusion, icon: faShieldAlt, bg: 'bg-teal-500' },
{ label: 'Unique IPs', value: stats.uniqueIPs, icon: faGlobe, bg: 'bg-indigo-500' },
].map(({ label, value, icon, bg }, i) => (
<motion.div
key={label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className={`rounded-lg shadow-md p-4 ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
<div className="flex items-center justify-between">
<div>
<p className={`text-xs font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>{label}</p>
<p className={`text-xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>{value}</p>
</div>
<div className={`${bg} p-2 rounded-lg`}>
<FontAwesomeIcon icon={icon} className="text-white" />
</div>
</div>
</motion.div>
))}
</div>
{/* Controls & filters */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className={`rounded-lg shadow-md p-6 mb-6 ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
{/* System controls */}
<div className={`flex flex-wrap items-center gap-4 p-3 rounded-lg mb-4 ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<div className="flex items-center space-x-2">
<FontAwesomeIcon icon={faClock} className={isDark ? 'text-gray-400' : 'text-gray-500'} />
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>Update Interval:</span>
<select
value={updateInterval}
onChange={(e) => setUpdateInterval(Number(e.target.value))}
className={`px-3 py-1 border rounded-md text-sm focus:outline-none focus:border-blue-500 ${
isDark ? 'bg-gray-600 border-gray-500 text-gray-300' : 'bg-white border-gray-300 text-gray-700'
}`}
>
{UPDATE_INTERVALS.map(i => (
<option key={i.value} value={i.value}>{i.label}</option>
))}
</select>
</div>
<button
onClick={togglePause}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
isPaused
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-orange-100 text-orange-700 hover:bg-orange-200'
}`}
>
<FontAwesomeIcon icon={isPaused ? faPlay : faPause} className="mr-1" />
{isPaused ? 'Resume' : 'Pause'}
</button>
{lastUpdateTime && (
<div className={`text-xs ml-auto ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
Last Update: {lastUpdateTime.toLocaleTimeString()}
</div>
)}
</div>
{/* Severity filter */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className={`text-sm font-medium mr-1 ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>Severity:</span>
<FilterButton isActive={severityFilter === 'all'} onClick={() => { setSeverityFilter('all'); setSelectedLogIndex(null) }}>
All
</FilterButton>
<FilterButton
isActive={severityFilter === 'critical'}
onClick={() => { setSeverityFilter('critical'); setSelectedLogIndex(null) }}
activeClassName="bg-red-600 text-white shadow-lg" activeHoverClassName="hover:bg-red-700"
>
Critical ({stats.critical})
</FilterButton>
<FilterButton
isActive={severityFilter === 'high'}
onClick={() => { setSeverityFilter('high'); setSelectedLogIndex(null) }}
activeClassName="bg-orange-600 text-white shadow-lg" activeHoverClassName="hover:bg-orange-700"
>
High ({stats.high})
</FilterButton>
</div>
{/* Source filter */}
<div className="flex flex-wrap items-center gap-2">
<span className={`text-sm font-medium mr-1 ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>Source:</span>
<FilterButton isActive={sourceFilter === 'all'} onClick={() => { setSourceFilter('all'); setSelectedLogIndex(null) }}>
All
</FilterButton>
<FilterButton
isActive={sourceFilter === 'ml'}
onClick={() => { setSourceFilter('ml'); setSelectedLogIndex(null) }}
activeClassName="bg-purple-600 text-white shadow-lg" activeHoverClassName="hover:bg-purple-700"
>
ML ({stats.ml})
</FilterButton>
<FilterButton
isActive={sourceFilter === 'rule'}
onClick={() => { setSourceFilter('rule'); setSelectedLogIndex(null) }}
activeClassName="bg-sky-600 text-white shadow-lg" activeHoverClassName="hover:bg-sky-700"
>
Rule ({stats.rule})
</FilterButton>
<FilterButton
isActive={sourceFilter === 'fusion'}
onClick={() => { setSourceFilter('fusion'); setSelectedLogIndex(null) }}
activeClassName="bg-teal-600 text-white shadow-lg" activeHoverClassName="hover:bg-teal-700"
>
Fusion ({stats.fusion})
</FilterButton>
</div>
</motion.div>
{/* Main content */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1, duration: 0.25, ease: 'linear' }}
className={selectedLogIndex !== null && filteredLogs[selectedLogIndex] ? 'lg:col-span-7' : 'lg:col-span-12'}
>
<div className={`rounded-lg shadow-md overflow-hidden flex flex-col max-h-[600px] ${isDark ? 'bg-gray-600' : 'bg-white'}`}>
<div className={`px-6 py-4 border-b flex-shrink-0 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
<div className="flex justify-between items-center">
<h2 className={`text-xl font-semibold flex items-center ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>
<FontAwesomeIcon icon={faShieldAlt} className="mr-2 text-blue-600" />
Security Alerts
</h2>
<span className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
{filteredLogs.length} Alerts
</span>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filteredLogs.length === 0 ? (
<div className="p-12 text-center">
<FontAwesomeIcon icon={faShieldAlt} className={`text-6xl mb-4 ${isDark ? 'text-gray-500' : 'text-gray-300'}`} />
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
{logs.length === 0 ? 'No Alerts Detected' : 'No Matching Alerts'}
</h3>
<p className={isDark ? 'text-gray-400' : 'text-gray-500'}>
{logs.length === 0
? 'Alerts will appear here when threats are detected'
: 'Try adjusting filter criteria'}
</p>
</div>
) : (
<div className="p-4 space-y-4">
{filteredLogs.map((log, index) => (
<AlertItem
key={`${log.flow_key}-${log.timestamp}-${index}`}
log={log}
index={index}
onClick={handleAlertClick}
/>
))}
</div>
)}
</div>
</div>
</motion.div>
{selectedLogIndex !== null && filteredLogs[selectedLogIndex] && (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="lg:col-span-5"
>
<AlertDetails
log={filteredLogs[selectedLogIndex]}
onClose={handleCloseDetails}
showNotification={showNotification}
/>
</motion.div>
)}
</div>
</Layout>
</>
)
}
export default Detection

File diff suppressed because it is too large Load Diff

View File

@ -1,943 +0,0 @@
import React, { useContext, useState, useEffect, useCallback, useMemo, useRef } from "react";
import Head from 'next/head';
import { motion } from 'framer-motion';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faChartBar,
faNetworkWired,
faGlobe,
faSearch,
faFilter,
faSortUp,
faSortDown,
faSort,
faRefresh,
faDownload,
faInfoCircle,
faPause,
faPlay,
faClock
} from '@fortawesome/free-solid-svg-icons';
import { WebsocketContext } from "../providers/WebSocketProvider";
import { useTheme } from '../providers/ThemeProvider';
import Layout from '../components/Layout';
import LoadingSpinner from '../components/LoadingSpinner';
import { combineLatest, of } from 'rxjs';
import { map, catchError, throttleTime, distinctUntilChanged } from 'rxjs/operators';
import {
ToggleButtonProps,
TableHeaderProps,
UpdateControlProps,
ControlPanelProps,
DataTableProps,
NoDataStateProps,
StatsOverviewProps
} from '../types/StatisticsTypes';
// 常量定義
const TRAFFIC_TYPES = ["source", "destination"];
const DIRECTIONS = ["ingress", "egress"];
const TIME_RANGES = ["1min", "10min", "1hour"];
// 更新频率选項 (毫秒)
const UPDATE_INTERVALS = [
{ label: "immediate", value: 0 },
{ label: "1s", value: 1000 },
{ label: "3s", value: 3000 },
{ label: "5s", value: 5000 },
{ label: "10s", value: 10000 }
];
// 格式化時間戳
const formatTimestamp = (timestamp: number | string, bootTime: number | null): string => {
if (!timestamp || !bootTime) return "Loading...";
const date = new Date((bootTime + Number(timestamp)) / 1e6);
date.setHours(date.getHours() + 8); // GMT+8
return date.toISOString().replace("T", " ").split(".")[0];
};
// 解析流量資料
const parseFlowData = (rawData: any): any => {
try {
if (typeof rawData === 'object' && rawData !== null) {
return rawData;
}
return JSON.parse(rawData);
} catch (error) {
console.error("Error parsing flow data:", error);
return {};
}
};
// 格式化位元組大小
const formatBytes = (bytes: number): string => {
const units = ["B", "KB", "MB", "GB", "TB"];
if (bytes < 1024) return `${bytes} B`;
let i = 0;
let value = bytes;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return `${value.toFixed(2)} ${units[i]}`;
};
// IP 排序輔助函數
const getIPSortValue = (ip: string, isIPv6: boolean): any => {
if (isIPv6) {
const cleanIPv6 = ip.replace(/\[|\]|:[0-9]+/g, "");
const expandIPv6 = (ip: string) => {
const parts = ip.split("::");
const left = (parts[0] || "").split(":");
const right = (parts[1] || "").split(":");
const missingZeros = 8 - (left.length + right.length);
const zeros = Array(missingZeros).fill("0");
return [...left, ...zeros, ...right].map((part) => part || "0");
};
const expanded = expandIPv6(cleanIPv6);
try {
const hexString = expanded.reduce((acc, part) => acc + part.padStart(4, "0"), "");
return BigInt("0x" + hexString);
} catch (error) {
console.error("Error converting IPv6 to number:", error);
return ip;
}
} else {
try {
return ip.split(".").reduce((acc, octet) => acc * 256 + parseInt(octet, 10), 0);
} catch (error) {
console.error("Error converting IPv4 to number:", error);
return ip;
}
}
};
const ToggleButton: React.FC<ToggleButtonProps> = ({ isActive, onClick, children, className = "" }) => {
const { actualTheme } = useTheme();
const isDark = actualTheme === 'dark';
return (
<button
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors ${className} ${
isActive
? "bg-blue-600 text-white"
: `${isDark ? 'bg-gray-700 text-gray-300 hover:bg-gray-650' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`
}`}
onClick={onClick}
>
{children}
</button>
);
};
const TableHeader: React.FC<TableHeaderProps> = ({ column, sortConfig, onSort }) => {
const { actualTheme } = useTheme();
const isDark = actualTheme === 'dark';
return (
<th
onClick={() => onSort(column.key)}
className={`px-4 py-3 text-left text-xs font-medium uppercase tracking-wider cursor-pointer transition-colors ${
sortConfig.key === column.key
? (isDark ? 'bg-blue-900/30 text-blue-300' : 'bg-blue-50 text-gray-500')
: (isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-500 hover:bg-gray-100')
}`}
>
<div className="flex items-center space-x-1">
<span>{column.label}</span>
<FontAwesomeIcon
icon={
sortConfig.key === column.key
? sortConfig.direction === "asc" ? faSortUp : faSortDown
: faSort
}
className="text-xs"
/>
</div>
</th>
);
};
const UpdateControl: React.FC<UpdateControlProps> = ({
updateInterval,
setUpdateInterval,
isPaused,
setIsPaused,
lastUpdateTime
}) => {
const { actualTheme } = useTheme();
const isDark = actualTheme === 'dark';
return (
<div className={`flex flex-wrap items-center gap-4 p-3 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
{/* 更新频率 */}
<div className="flex items-center space-x-2">
<FontAwesomeIcon icon={faClock} className={isDark ? 'text-gray-400' : 'text-gray-500'} />
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>Update Interval:</span>
<select
value={updateInterval}
onChange={(e) => setUpdateInterval(Number(e.target.value))}
className={`px-3 py-1 border rounded-md text-sm focus:outline-none focus:border-blue-500 ${
isDark ? 'bg-gray-600 border-gray-500 text-gray-300' : 'bg-white border-gray-300 text-gray-700'
}`}
>
{UPDATE_INTERVALS.map(interval => (
<option key={interval.value} value={interval.value}>
{interval.label}
</option>
))}
</select>
</div>
{/* 暂停/恢复按钮 */}
<button
onClick={() => setIsPaused(!isPaused)}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
isPaused
? "bg-green-100 text-green-700 hover:bg-green-200"
: "bg-orange-100 text-orange-700 hover:bg-orange-200"
}`}
>
<FontAwesomeIcon icon={isPaused ? faPlay : faPause} className="mr-1" />
{isPaused ? "resume" : "pause"}
</button>
{/* 更新資料按钮 */}
<button
onClick={() => window.location.reload()}
className="px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors flex items-center space-x-1"
>
<FontAwesomeIcon icon={faRefresh} className="text-xs" />
<span>Update</span>
</button>
{/* 最后更新时间 */}
{lastUpdateTime && (
<div className={`text-xs ml-auto ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
Last Update: {lastUpdateTime.toLocaleTimeString()}
</div>
)}
</div>
);
};
const ControlPanel: React.FC<ControlPanelProps> = ({
isIPv6, setIsIPv6,
direction, setDirection,
trafficType, setTrafficType,
timeRange, setTimeRange,
searchTerm, setSearchTerm,
updateInterval, setUpdateInterval,
isPaused, setIsPaused,
lastUpdateTime
}) => {
const { actualTheme } = useTheme();
const isDark = actualTheme === 'dark';
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className={`rounded-lg shadow-md p-6 mb-6 space-y-4 ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
{/* 更新控制 */}
<UpdateControl
updateInterval={updateInterval}
setUpdateInterval={setUpdateInterval}
isPaused={isPaused}
setIsPaused={setIsPaused}
lastUpdateTime={lastUpdateTime}
/>
<div className="flex flex-wrap items-center gap-4">
{/* 搜索框 */}
<div className="flex-1 min-w-64">
<div className="relative">
<FontAwesomeIcon
icon={faSearch}
className={`absolute left-2.5 top-1/2 transform -translate-y-1/2 text-sm ${isDark ? 'text-gray-400' : 'text-gray-400'}`}
/>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search IP Address..."
className={`w-full pl-8 pr-3 py-1 text-sm border rounded-lg focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 ${
isDark ? 'bg-gray-700 border-gray-500 text-gray-300 placeholder-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400'
}`}
/>
</div>
</div>
{/* IP Version Selection */}
<div className="flex items-center space-x-2">
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>IP Version:</span>
<div className="flex space-x-1">
<ToggleButton
isActive={!isIPv6}
onClick={() => setIsIPv6(false)}
>
IPv4
</ToggleButton>
<ToggleButton
isActive={isIPv6}
onClick={() => setIsIPv6(true)}
>
IPv6
</ToggleButton>
</div>
</div>
{/* 流量方向 */}
<div className="flex items-center space-x-2">
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>Direction:</span>
<div className="flex space-x-1">
<ToggleButton
isActive={direction === "ingress"}
onClick={() => setDirection("ingress")}
>
Ingress
</ToggleButton>
<ToggleButton
isActive={direction === "egress"}
onClick={() => setDirection("egress")}
>
Egress
</ToggleButton>
</div>
</div>
{/* 流量類型 */}
<div className="flex items-center space-x-2">
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>Traffic Type:</span>
<div className="flex space-x-1">
<ToggleButton
isActive={trafficType === "source"}
onClick={() => setTrafficType("source")}
>
Source
</ToggleButton>
<ToggleButton
isActive={trafficType === "destination"}
onClick={() => setTrafficType("destination")}
>
Destination
</ToggleButton>
</div>
</div>
{/* 時間範圍 */}
<div className="flex items-center space-x-2">
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>Time:</span>
<div className="flex space-x-1">
{TIME_RANGES.map((range) => (
<ToggleButton
key={range}
isActive={timeRange === range}
onClick={() => setTimeRange(range)}
>
{range}
</ToggleButton>
))}
</div>
</div>
</div>
</motion.div>
);
};
const DataTable: React.FC<DataTableProps> = ({ data, sortConfig, handleSort, bootTime, isUpdating }) => {
const { actualTheme } = useTheme();
const isDark = actualTheme === 'dark';
const columns = [
{ key: "ip", label: "IP Address" },
{ key: "geo", label: "Location" },
{ key: "bytes", label: "Bytes" },
{ key: "packets", label: "Packets" },
{ key: "last_seen", label: "Last Seen (GMT+8)" }
];
const formatGeoLocation = (geo: any) => {
if (!geo) {
return (
<span className={`text-sm px-2 py-1 rounded font-medium ${
isDark ? 'bg-blue-900/50 text-blue-300' : 'bg-blue-100 text-blue-700'
}`}>
Unknown
</span>
);
}
return (
<div className="flex items-center space-x-2">
<span className={`text-sm px-2 py-1 rounded font-medium ${
isDark ? 'bg-blue-900/50 text-blue-300' : 'bg-blue-100 text-blue-700'
}`}>
{geo.country_code || 'Unknown'}
</span>
</div>
);
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={`rounded-lg shadow-md overflow-hidden ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
{/* 表格容器 - 设置最大高度和滚动 */}
<div className="max-h-[600px] overflow-y-auto">
<table className={`min-w-full divide-y ${isDark ? 'divide-gray-700' : 'divide-gray-200'}`}>
{/* 固定表头 */}
<thead className={`sticky top-0 z-10 ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<tr>
{columns.map((column) => (
<TableHeader
key={column.key}
column={column}
sortConfig={sortConfig}
onSort={handleSort}
/>
))}
</tr>
</thead>
<tbody className={`divide-y ${isDark ? 'bg-gray-600 divide-gray-700' : 'bg-white divide-gray-200'}`}>
{data.map((row, index) => (
<motion.tr
key={`${row.ip}-${index}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: Math.min(index * 0.01, 0.5) }}
className={`transition-colors ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-50'}`}
>
<td className={`px-4 py-3 whitespace-nowrap text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>
{row.ip}
</td>
<td className="px-4 py-3 text-sm">
{formatGeoLocation(row.geo)}
</td>
<td className={`px-4 py-3 whitespace-nowrap text-sm ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>
{formatBytes(row.bytes)}
</td>
<td className={`px-4 py-3 whitespace-nowrap text-sm ${isDark ? 'text-gray-300' : 'text-gray-900'}`}>
{row.packets.toLocaleString()}
</td>
<td className={`px-4 py-3 whitespace-nowrap text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
{formatTimestamp(row.last_seen, bootTime)}
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
{/* 資料条数显示 */}
{data.length > 0 && (
<div className={`px-4 py-2 border-t ${isDark ? 'bg-gray-700 border-gray-600' : 'bg-gray-50 border-gray-200'}`}>
<div className={`flex justify-between items-center text-sm ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
<span>Showing {data.length} records</span>
</div>
</div>
)}
</motion.div>
);
};
const NoDataState: React.FC<NoDataStateProps> = ({ isSearchFiltered }) => {
const { actualTheme } = useTheme();
const isDark = actualTheme === 'dark';
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={`rounded-lg shadow-md p-12 text-center ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
<FontAwesomeIcon icon={faInfoCircle} className={`text-6xl mb-4 ${isDark ? 'text-gray-500' : 'text-gray-300'}`} />
{isSearchFiltered ? (
<>
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>No matching results found</h3>
<p className={isDark ? 'text-gray-400' : 'text-gray-500'}>Please try adjusting your search criteria</p>
</>
) : (
<>
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>No data found</h3>
<p className={isDark ? 'text-gray-400' : 'text-gray-500'}>Please try adjusting your filters or time range</p>
</>
)}
</motion.div>
);
};
const StatsOverview: React.FC<StatsOverviewProps> = ({ data, isIPv6, direction, trafficType }) => {
const { actualTheme } = useTheme();
const isDark = actualTheme === 'dark';
const totalBytes = data.reduce((sum, item) => sum + item.bytes, 0);
const totalPackets = data.reduce((sum, item) => sum + item.packets, 0);
const uniqueIPs = new Set(
data.map(item => {
// 移除端口部分只保留IP
if (item.ip.includes('[') && item.ip.includes(']:')) {
// IPv6: [ip]:port -> ip
return item.ip.split(']:')[0] + ']';
} else if (item.ip.includes(':') && !item.ip.includes('[')) {
// IPv4: ip:port -> ip
return item.ip.split(':').slice(0, -1).join(':');
}
return item.ip;
})
).size;
const stats = [
{
title: 'Total Traffic',
value: formatBytes(totalBytes),
icon: faNetworkWired,
color: 'bg-blue-500'
},
{
title: 'Total Packets',
value: totalPackets.toLocaleString(),
icon: faChartBar,
color: 'bg-green-500'
},
{
title: 'Unique IPs',
value: uniqueIPs.toLocaleString(),
icon: faGlobe,
color: 'bg-purple-500'
}
];
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6"
>
{stats.map((stat, index) => (
<motion.div
key={stat.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className={`rounded-lg shadow-md p-6 ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
<div className="flex items-center justify-between">
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
{stat.title}
</p>
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{stat.value}
</p>
</div>
<div className={`${stat.color} p-3 rounded-lg`}>
<FontAwesomeIcon
icon={stat.icon}
className="text-white text-xl"
/>
</div>
</div>
</motion.div>
))}
</motion.div>
);
};
// 主要統計組件
const Statistics: React.FC = () => {
const { bootTime, getIPv4FlowStream, getIPv6FlowStream } = useContext(WebsocketContext);
const { actualTheme } = useTheme();
const isDark = actualTheme === 'dark';
// 狀態管理
const [isIPv6, setIsIPv6] = useState(false);
const [trafficType, setTrafficType] = useState("source");
const [direction, setDirection] = useState("ingress");
const [timeRange, setTimeRange] = useState("1min");
const [sortConfig, setSortConfig] = useState({ key: null, direction: "desc" });
const [searchTerm, setSearchTerm] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [flowData, setFlowData] = useState<any>({});
const [error, setError] = useState<string | null>(null);
// 更新控制狀態
const [updateInterval, setUpdateInterval] = useState(3000);
const [isPaused, setIsPaused] = useState(false);
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
// 用於存儲最新資料和控制更新的 refs
const latestDataRef = useRef<any>({});
const lastUpdateRef = useRef<number>(0);
const updateIntervalRef = useRef<number>(updateInterval);
const isPausedRef = useRef<boolean>(isPaused);
// 同步 refs 與 state
useEffect(() => {
updateIntervalRef.current = updateInterval;
}, [updateInterval]);
useEffect(() => {
isPausedRef.current = isPaused;
}, [isPaused]);
// 解析流量資料 - 使用 useCallback 保持穩定引用
const parseFlowData = useCallback((rawData: any): any => {
try {
if (typeof rawData === 'object' && rawData !== null) {
return rawData;
}
return JSON.parse(rawData);
} catch (error) {
console.error("Error parsing flow data:", error);
return {};
}
}, []);
// 處理 WebSocket 資料 - 使用 useCallback 保持穩定引用
const processWebSocketData = useCallback((results: any[]) => {
const validResults = results.filter(result => result !== null);
if (validResults.length === 0) return;
const newData: any = {};
validResults.forEach(result => {
if (!result) return;
const { key, protocol, data } = result;
if (!newData[key]) {
newData[key] = {};
}
newData[key][protocol] = data;
});
if (Object.keys(newData).length > 0) {
latestDataRef.current = newData;
// 根據當前狀態決定是否立即更新 UI
const now = Date.now();
const currentUpdateInterval = updateIntervalRef.current;
const currentIsPaused = isPausedRef.current;
const shouldUpdate = !currentIsPaused &&
(currentUpdateInterval === 0 || (now - lastUpdateRef.current) >= currentUpdateInterval);
if (shouldUpdate) {
setIsUpdating(true);
setFlowData(newData);
setLastUpdateTime(new Date());
lastUpdateRef.current = now;
setTimeout(() => setIsUpdating(false), 300);
}
}
}, []);
useEffect(() => {
if (!getIPv4FlowStream || !getIPv6FlowStream) {
setError("WebSocket service unavailable");
setIsLoading(false);
return;
}
console.log("Initializing Statistics WebSocket subscriptions...");
setError(null);
setIsLoading(true);
// 使用循環動態生成所有配置
const DIRECTIONS = ['ingress', 'egress'] as const;
const TRAFFIC_TYPES = ['source', 'destination'] as const;
const TIME_RANGES = ['1min', '10min', '1hour'] as const;
const streamConfigs: Array<{
dir: typeof DIRECTIONS[number];
type: typeof TRAFFIC_TYPES[number];
range: typeof TIME_RANGES[number];
protocol: 'ipv4' | 'ipv6';
}> = [];
// 生成所有組合 (2 directions × 2 types × 3 ranges × 2 protocols = 24 configs)
DIRECTIONS.forEach(dir => {
TRAFFIC_TYPES.forEach(type => {
TIME_RANGES.forEach(range => {
streamConfigs.push({ dir, type, range, protocol: "ipv4" });
streamConfigs.push({ dir, type, range, protocol: "ipv6" });
});
});
});
console.log(`Total stream configs: ${streamConfigs.length}`); // 應該是 24
// 創建所有資料流的 Observable
const streams = streamConfigs.map(config => {
const { dir, type, range, protocol } = config;
const getStream = protocol === "ipv4" ? getIPv4FlowStream : getIPv6FlowStream;
return getStream(dir, type, range).pipe(
map((rawData: any) => ({
key: `${dir}_${type}_${range}`,
protocol,
data: parseFlowData(rawData)
})),
catchError(error => {
console.error(`Error in ${protocol} stream (${dir}, ${type}, ${range}):`, error);
return of(null);
})
);
});
// 訂閱合併的資料流
const subscription = combineLatest(streams).subscribe({
next: (results) => {
processWebSocketData(results);
setIsLoading(false);
},
error: (error) => {
console.error("WebSocket subscription error:", error);
setError("WebSocket connection error");
setIsLoading(false);
}
});
// 清理函數:只取消訂閱,不關閉 WebSocket 連線
return () => {
console.log("Unsubscribing from Statistics WebSocket streams");
subscription.unsubscribe();
};
}, [getIPv4FlowStream, getIPv6FlowStream, parseFlowData, processWebSocketData]);
useEffect(() => {
if (!isPaused && Object.keys(latestDataRef.current).length > 0) {
// 恢復時立即更新一次
setFlowData(latestDataRef.current);
setLastUpdateTime(new Date());
lastUpdateRef.current = Date.now();
}
}, [isPaused]);
useEffect(() => {
// 即時模式或暫停時不需要定時器
if (updateInterval === 0 || isPaused) {
return;
}
const intervalId = setInterval(() => {
const now = Date.now();
if (Object.keys(latestDataRef.current).length > 0 &&
(now - lastUpdateRef.current) >= updateInterval &&
!isPausedRef.current) {
setIsUpdating(true);
setFlowData(latestDataRef.current);
setLastUpdateTime(new Date());
lastUpdateRef.current = now;
setTimeout(() => setIsUpdating(false), 300);
}
}, Math.min(updateInterval / 4, 1000)); // 檢查頻率為更新間隔的 1/4最少 1 秒
return () => clearInterval(intervalId);
}, [updateInterval, isPaused]);
// 提取當前選擇的資料
const parseCurrentData = useCallback(() => {
const key = `${direction}_${trafficType}_${timeRange}`;
const protocol = isIPv6 ? "ipv6" : "ipv4";
const data = flowData[key]?.[protocol];
if (!data) return null;
return Object.entries(data).map(([ip, details]: [string, any]) => ({
ip,
bytes: details.bytes || 0,
geo: details.geo || null,
packets: details.packets || 0,
last_seen: details.last_seen || 0,
}));
}, [flowData, direction, trafficType, timeRange, isIPv6]);
// 排序和過濾資料
const sortedData = useMemo(() => {
const data = parseCurrentData();
if (!data) return null;
if (!sortConfig.key) {
if (searchTerm.trim()) {
return data.filter((row) =>
row.ip.toLowerCase().includes(searchTerm.trim().toLowerCase())
);
}
return data;
}
// 排序
const sortedData = [...data].sort((a, b) => {
let valA = a[sortConfig.key as keyof typeof a];
let valB = b[sortConfig.key as keyof typeof b];
if (sortConfig.key === "ip") {
valA = getIPSortValue(valA as string, isIPv6);
valB = getIPSortValue(valB as string, isIPv6);
}
if (valA < valB) {
return sortConfig.direction === "asc" ? -1 : 1;
}
if (valA > valB) {
return sortConfig.direction === "asc" ? 1 : -1;
}
return 0;
});
// 過濾
if (searchTerm.trim()) {
return sortedData.filter((row) =>
row.ip.toLowerCase().includes(searchTerm.trim().toLowerCase())
);
}
return sortedData;
}, [parseCurrentData, sortConfig, searchTerm, isIPv6]);
// 檢查是否由於搜索而過濾
const isSearchFiltered = useMemo(() => {
return searchTerm.trim() !== "" && parseCurrentData() !== null;
}, [searchTerm, parseCurrentData]);
// ============ 事件處理函數 ============
// 處理排序
const handleSort = useCallback((key: string) => {
setSortConfig(prevConfig => {
const direction = prevConfig.key === key && prevConfig.direction === "asc" ? "desc" : "asc";
return { key, direction };
});
}, []);
// 搜索處理
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
}, []);
if (isLoading) {
return (
<>
<Head>
<title>Statistics - NetGuardia</title>
</Head>
<Layout>
<div className="flex items-center justify-center w-full h-full min-h-[calc(100vh-4rem)]">
<div className="flex flex-col items-center space-y-4">
<LoadingSpinner />
</div>
</div>
</Layout>
</>
);
}
if (error) {
return (
<>
<Head>
<title>Statistics - NetGuardia</title>
</Head>
<Layout>
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<div className="text-red-500 text-6xl mb-4"></div>
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-gray-300' : 'text-gray-800'}`}>Loading Error</h2>
<p className={`mb-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Reload
</button>
</div>
</div>
</Layout>
</>
);
}
return (
<>
<Head>
<title>Statistics - NetGuardia</title>
<meta name="description" content="NetGuardia Network Traffic Statistics" />
</Head>
<Layout>
{/* 頁面標題 */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<div className="flex items-center justify-between">
<div>
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
Network Traffic Statistics
</h1>
<p className={isDark ? 'text-gray-400' : 'text-gray-600'}>
Analyze and monitor network traffic data, view detailed connection statistics
</p>
</div>
</div>
</motion.div>
{/* 控制面板 */}
<ControlPanel
isIPv6={isIPv6}
setIsIPv6={setIsIPv6}
direction={direction}
setDirection={setDirection}
trafficType={trafficType}
setTrafficType={setTrafficType}
timeRange={timeRange}
setTimeRange={setTimeRange}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
updateInterval={updateInterval}
setUpdateInterval={setUpdateInterval}
isPaused={isPaused}
setIsPaused={setIsPaused}
lastUpdateTime={lastUpdateTime}
/>
{/* 統計概覽 */}
{sortedData && sortedData.length > 0 && (
<StatsOverview
data={sortedData}
isIPv6={isIPv6}
direction={direction}
trafficType={trafficType}
/>
)}
{/* 資料表格 */}
<div className="mb-8">
{sortedData === null ? (
<NoDataState isSearchFiltered={false} />
) : sortedData.length === 0 ? (
<NoDataState isSearchFiltered={isSearchFiltered} />
) : (
<DataTable
data={sortedData}
sortConfig={sortConfig}
handleSort={handleSort}
bootTime={bootTime}
isUpdating={isUpdating}
/>
)}
</div>
</Layout>
</>
);
};
export default Statistics;

View File

@ -1,793 +0,0 @@
import React, { useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import Head from 'next/head';
import { motion } from 'framer-motion';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faMapMarkedAlt,
faGlobe,
faNetworkWired,
faFilter,
faLayerGroup,
faExpand,
faCompress,
faInfoCircle,
faTimes,
faPause,
faPlay,
faClock,
faRefresh
} from '@fortawesome/free-solid-svg-icons';
import Layout from '../components/Layout';
import LoadingSpinner from '../components/LoadingSpinner';
import { useTheme } from '../providers/ThemeProvider';
import { WebsocketContext } from '../providers/WebSocketProvider';
import { combineLatest, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import {
MapPoint,
MapConfig,
GeoLocation,
FlowDetails,
FlowDataRecord,
ProtocolData,
FlowDataState
} from "../types/TrafficMapTypes";
const INITIAL_MAP_CONFIG: MapConfig = {
center: [0, 20],
zoom: 0,
minZoom: 0,
maxZoom: 8,
scrollZoom: true,
boxZoom: false,
doubleClickZoom: false,
touchZoomRotate: false,
dragRotate: false,
pitchWithRotate: false,
renderWorldCopies: false
};
const formatBytes = (bytes: number): string => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
if (bytes < 1024) return `${bytes} B`;
let i = 0;
let value = bytes;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return `${value.toFixed(2)} ${units[i]}`;
};
const ToggleButton: React.FC<{
isActive: boolean;
onClick: () => void;
children: React.ReactNode;
isDark?: boolean;
}> = ({ isActive, onClick, children, isDark = false }) => (
<button
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-blue-600 text-white'
: `${isDark ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-700'} hover:bg-gray-200`
}`}
onClick={onClick}
>
{children}
</button>
);
const ControlPanel: React.FC<{
isIPv6: boolean;
setIsIPv6: (value: boolean) => void;
direction: string;
setDirection: (value: string) => void;
trafficType: string;
setTrafficType: (value: string) => void;
timeRange: string;
setTimeRange: (value: string) => void;
isPaused: boolean;
setIsPaused: (value: boolean) => void;
onRefresh: () => void;
lastUpdateTime: Date | null;
}> = ({
isIPv6,
setIsIPv6,
direction,
setDirection,
trafficType,
setTrafficType,
timeRange,
setTimeRange,
isPaused,
setIsPaused,
onRefresh,
lastUpdateTime
}) => {
const { actualTheme } = useTheme();
const isDark = actualTheme === 'dark';
const TIME_RANGES = ["1min", "10min", "1hour"];
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className={`rounded-lg shadow-md p-4 mb-6 ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center space-x-2">
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>IP Version:</span>
<div className="flex space-x-1">
<ToggleButton
isActive={!isIPv6}
onClick={() => setIsIPv6(false)}
isDark={isDark}
>
IPv4
</ToggleButton>
<ToggleButton
isActive={isIPv6}
onClick={() => setIsIPv6(true)}
isDark={isDark}
>
IPv6
</ToggleButton>
</div>
</div>
<div className="flex items-center space-x-2">
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>Direction:</span>
<div className="flex space-x-1">
<ToggleButton
isActive={direction === 'ingress'}
onClick={() => setDirection('ingress')}
isDark={isDark}
>
Ingress
</ToggleButton>
<ToggleButton
isActive={direction === 'egress'}
onClick={() => setDirection('egress')}
isDark={isDark}
>
Egress
</ToggleButton>
</div>
</div>
<div className="flex items-center space-x-2">
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>Traffic Type:</span>
<div className="flex space-x-1">
<ToggleButton
isActive={trafficType === 'source'}
onClick={() => setTrafficType('source')}
isDark={isDark}
>
Source
</ToggleButton>
<ToggleButton
isActive={trafficType === 'destination'}
onClick={() => setTrafficType('destination')}
isDark={isDark}
>
Destination
</ToggleButton>
</div>
</div>
<div className="flex items-center space-x-2">
<span className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>Time:</span>
<div className="flex space-x-1">
{TIME_RANGES.map((range) => (
<ToggleButton
key={range}
isActive={timeRange === range}
onClick={() => setTimeRange(range)}
isDark={isDark}
>
{range}
</ToggleButton>
))}
</div>
</div>
<div className="flex items-center space-x-2 ml-auto">
<button
onClick={() => setIsPaused(!isPaused)}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
isPaused
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-orange-100 text-orange-700 hover:bg-orange-200'
}`}
>
<FontAwesomeIcon icon={isPaused ? faPlay : faPause} className="mr-1" />
{isPaused ? 'Resume' : 'Pause'}
</button>
<button
onClick={onRefresh}
className="px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"
>
<FontAwesomeIcon icon={faRefresh} className="mr-1" />
Refresh
</button>
{lastUpdateTime && (
<div className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
Last Update: {lastUpdateTime.toLocaleTimeString()}
</div>
)}
</div>
</div>
</motion.div>
);
};
const MapComponent: React.FC<{
points: MapPoint[];
isDark: boolean;
}> = ({ points, isDark }) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<any>(null);
useEffect(() => {
if (!mapContainer.current) return;
const initMap = async () => {
const maplibregl = await import('maplibre-gl');
if (map.current) return;
map.current = new maplibregl.Map({
container: mapContainer.current!,
style: isDark
? 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'
: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: INITIAL_MAP_CONFIG.center,
zoom: INITIAL_MAP_CONFIG.zoom,
minZoom: INITIAL_MAP_CONFIG.minZoom,
maxZoom: INITIAL_MAP_CONFIG.maxZoom,
scrollZoom: INITIAL_MAP_CONFIG.scrollZoom,
boxZoom: INITIAL_MAP_CONFIG.boxZoom,
doubleClickZoom: INITIAL_MAP_CONFIG.doubleClickZoom,
touchZoomRotate: INITIAL_MAP_CONFIG.touchZoomRotate,
dragRotate: INITIAL_MAP_CONFIG.dragRotate,
pitchWithRotate: INITIAL_MAP_CONFIG.pitchWithRotate,
renderWorldCopies: INITIAL_MAP_CONFIG.renderWorldCopies
});
map.current.on('load', () => {
if (!map.current) return;
const style = map.current.getStyle();
if (style && style.layers) {
style.layers.forEach((layer: any) => {
if (layer.type === 'symbol') {
map.current.setLayoutProperty(layer.id, 'visibility', 'none');
}
});
}
});
};
initMap().catch(error => {
console.error('Failed to initialize map:', error);
});
return () => {
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, [isDark]);
useEffect(() => {
if (!map.current) return;
const updateMarkers = async () => {
const maplibregl = await import('maplibre-gl');
const updateLayers = () => {
if (!map.current) return;
if (!points.length) {
if (map.current.getSource('traffic-points')) {
const source = map.current.getSource('traffic-points') as any;
source.setData({
type: 'FeatureCollection',
features: []
});
}
return;
}
const geojsonData = {
type: 'FeatureCollection',
features: points.map(point => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [point.longitude, point.latitude]
},
properties: {
ip: point.ip,
regions: point.regions,
code: point.code,
city: point.city,
bytes: point.bytes,
packets: point.packets,
type: point.type,
traffic: formatBytes(point.bytes)
}
}))
};
if (map.current.getSource('traffic-points')) {
const source = map.current.getSource('traffic-points') as any;
source.setData(geojsonData);
return;
}
map.current.addSource('traffic-points', {
type: 'geojson',
data: geojsonData
});
map.current.addLayer({
id: 'traffic-points-glow',
type: 'circle',
source: 'traffic-points',
paint: {
'circle-color': [
'match',
['get', 'type'],
'source', '#3B82F6',
'destination', '#EF4444',
'#8B5CF6'
],
'circle-radius': 16,
'circle-opacity': 0.3,
'circle-blur': 1
}
});
map.current.addLayer({
id: 'traffic-points-layer',
type: 'circle',
source: 'traffic-points',
paint: {
'circle-color': [
'match',
['get', 'type'],
'source', '#3B82F6',
'destination', '#EF4444',
'#8B5CF6'
],
'circle-radius': 5,
'circle-opacity': 0.9,
'circle-blur': 0.2
}
});
map.current.on('mouseenter', 'traffic-points-layer', (e: any) => {
const coordinates = e.features[0].geometry.coordinates.slice();
const props = e.features[0].properties;
const popupContent = `
<div style="padding: 8px; font-family: sans-serif;">
<strong style="font-size: 14px; color: #1f2937;">${props.ip}</strong><br/>
<span style="color: #6b7280; font-size: 12px;">Regions: ${props.regions}</span><br/>
<span style="color: #6b7280; font-size: 12px;">Code: ${props.code}</span><br/>
<span style="color: #6b7280; font-size: 12px;">City: ${props.city}</span><br/>
<span style="color: #6b7280; font-size: 12px;">Traffic: ${props.traffic}</span><br/>
<span style="color: #6b7280; font-size: 12px;">Packets: ${props.packets.toLocaleString()}</span><br/>
<span style="color: #6b7280; font-size: 12px;">Type: ${props.type}</span>
</div>
`;
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false
})
.setLngLat(coordinates)
.setHTML(popupContent)
.addTo(map.current);
(map.current as any)._hoverPopup = popup;
map.current.getCanvas().style.cursor = 'pointer';
});
map.current.on('mouseleave', 'traffic-points-layer', () => {
if ((map.current as any)._hoverPopup) {
(map.current as any)._hoverPopup.remove();
(map.current as any)._hoverPopup = null;
}
map.current.getCanvas().style.cursor = '';
});
};
if (map.current.isStyleLoaded()) {
updateLayers();
} else {
map.current.once('load', updateLayers);
}
};
updateMarkers();
}, [points]);
return (
<div className="relative w-full h-full">
<div
ref={mapContainer}
className="w-full h-full rounded-lg"
style={{ minHeight: '500px' }}
/>
</div>
);
};
const StatsCard: React.FC<{
title: string;
value: string | number;
icon: any;
color: string;
}> = ({ title, value, icon, color }) => {
const { actualTheme } = useTheme();
const isDark = actualTheme === 'dark';
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={`rounded-lg shadow-md p-6 ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
<div className="flex items-center justify-between">
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
{title}
</p>
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{value}
</p>
</div>
<div className={`${color} p-3 rounded-lg`}>
<FontAwesomeIcon icon={icon} className="text-white text-xl" />
</div>
</div>
</motion.div>
);
};
const TrafficMap: React.FC = () => {
const { actualTheme } = useTheme();
const { getIPv4FlowStream, getIPv6FlowStream, bootTime } = useContext(WebsocketContext);
const isDark = actualTheme === 'dark';
// 狀態管理
const [isIPv6, setIsIPv6] = useState(false);
const [direction, setDirection] = useState('ingress');
const [trafficType, setTrafficType] = useState('source');
const [timeRange, setTimeRange] = useState('1min');
const [isPaused, setIsPaused] = useState(false);
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [mapPoints, setMapPoints] = useState<MapPoint[]>([]);
const [flowData, setFlowData] = useState<FlowDataState>({});
const latestDataRef = useRef<any>({});
const isPausedRef = useRef<boolean>(isPaused);
// 同步 isPaused 到 ref
useEffect(() => {
isPausedRef.current = isPaused;
}, [isPaused]);
// 解析流量資料 - 使用 useCallback 保持穩定引用
const parseFlowData = useCallback((rawData: any): FlowDataRecord => {
try {
if (typeof rawData === 'object' && rawData !== null) {
return rawData;
}
return JSON.parse(rawData);
} catch (error) {
console.error("Error parsing flow data:", error);
return {};
}
}, []);
// 處理 WebSocket 資料 - 使用 useCallback 保持穩定引用
const processWebSocketData = useCallback((results: any[]) => {
if (isPausedRef.current) return;
const validResults = results.filter(result => result !== null);
if (validResults.length === 0) return;
const newData: any = {};
validResults.forEach(result => {
if (!result) return;
const { key, protocol, data } = result;
if (!newData[key]) {
newData[key] = {};
}
newData[key][protocol] = data;
});
if (Object.keys(newData).length > 0) {
latestDataRef.current = newData;
setFlowData(newData);
setLastUpdateTime(new Date());
}
}, []);
useEffect(() => {
if (!getIPv4FlowStream || !getIPv6FlowStream) {
setError("WebSocket service unavailable");
setIsLoading(false);
return;
}
console.log("Initializing Traffic Map WebSocket subscriptions...");
setError(null);
setIsLoading(true);
// 定義所有需要訂閱的配置
const DIRECTIONS = ['ingress', 'egress'] as const;
const TRAFFIC_TYPES = ['source', 'destination'] as const;
const TIME_RANGES = ['1min', '10min', '1hour'] as const;
const streamConfigs: Array<{
dir: typeof DIRECTIONS[number];
type: typeof TRAFFIC_TYPES[number];
range: typeof TIME_RANGES[number];
protocol: 'ipv4' | 'ipv6';
}> = [];
// 生成所有組合 (2 directions × 2 types × 3 ranges × 2 protocols = 24 configs)
DIRECTIONS.forEach(dir => {
TRAFFIC_TYPES.forEach(type => {
TIME_RANGES.forEach(range => {
streamConfigs.push({ dir, type, range, protocol: "ipv4" });
streamConfigs.push({ dir, type, range, protocol: "ipv6" });
});
});
});
console.log(`Total stream configs for Traffic Map: ${streamConfigs.length}`); // 24
// 創建所有串流的 Observable
const streams = streamConfigs.map(config => {
const { dir, type, range, protocol } = config;
const getStream = protocol === "ipv4" ? getIPv4FlowStream : getIPv6FlowStream;
return getStream(dir, type, range).pipe(
map((rawData: any) => ({
key: `${dir}_${type}_${range}`,
protocol,
data: parseFlowData(rawData)
})),
catchError(error => {
console.error(`Error in ${protocol} stream (${dir}/${type}/${range}):`, error);
return of(null);
})
);
});
// 訂閱合併的串流
const subscription = combineLatest(streams).subscribe({
next: (results) => {
processWebSocketData(results);
setIsLoading(false);
},
error: (error) => {
console.error("WebSocket subscription error:", error);
setError("WebSocket connection error");
setIsLoading(false);
}
});
// 清理函數:只取消訂閱,不關閉 WebSocket 連線
return () => {
console.log("Unsubscribing from Traffic Map WebSocket streams");
subscription.unsubscribe();
};
}, [getIPv4FlowStream, getIPv6FlowStream, parseFlowData, processWebSocketData]);
useEffect(() => {
if (!isPaused && Object.keys(latestDataRef.current).length > 0) {
// 恢復時立即更新一次
setFlowData(latestDataRef.current);
setLastUpdateTime(new Date());
}
}, [isPaused]);
useEffect(() => {
const key = `${direction}_${trafficType}_${timeRange}`;
const protocol = isIPv6 ? "ipv6" : "ipv4";
const data: FlowDataRecord | undefined = flowData[key]?.[protocol];
if (!data) {
setMapPoints([]);
return;
}
const points: MapPoint[] = [];
const seenLocations: Record<string, MapPoint> = {};
Object.entries(data).forEach(([ipPort, details]: [string, FlowDetails]) => {
// 檢查地理位置資料是否有效
if (!details.geo ||
details.geo.latitude === null ||
details.geo.longitude === null) {
return;
}
// 提取 IP 地址(移除端口)
let ip = ipPort;
if (ipPort.includes('[') && ipPort.includes(']:')) {
// IPv6 格式: [ip]:port
ip = ipPort.split(']:')[0].replace('[', '') + ']';
} else if (ipPort.includes(':')) {
// IPv4 格式: ip:port
const parts = ipPort.split(':');
ip = parts.slice(0, -1).join(':');
}
const locationKey = `${details.geo.latitude},${details.geo.longitude}`;
// 合併相同位置的多個 IP
if (seenLocations[locationKey]) {
const existingPoint = seenLocations[locationKey];
existingPoint.bytes += details.bytes || 0;
existingPoint.packets += details.packets || 0;
if (!existingPoint.ip.includes(ip)) {
existingPoint.ip += `, ${ip}`;
}
} else {
const newPoint: MapPoint = {
id: ipPort,
ip: ip,
latitude: details.geo.latitude,
longitude: details.geo.longitude,
bytes: details.bytes || 0,
packets: details.packets || 0,
regions: details.geo.country || 'Unknown',
city: details.geo.city || 'Unknown',
code: details.geo.country_code || 'Unknown',
type: trafficType as 'source' | 'destination'
};
seenLocations[locationKey] = newPoint;
points.push(newPoint);
}
});
setMapPoints(points);
}, [flowData, direction, trafficType, timeRange, isIPv6]);
const handleRefresh = useCallback(() => {
if (!isPaused && Object.keys(latestDataRef.current).length > 0) {
setFlowData(latestDataRef.current);
setLastUpdateTime(new Date());
}
}, [isPaused]);
const stats = useMemo(() => {
const totalBytes = mapPoints.reduce((sum, point) => sum + point.bytes, 0);
const totalPackets = mapPoints.reduce((sum, point) => sum + point.packets, 0);
const uniqueIPs = new Set<string>();
mapPoints.forEach(point => {
const ips = point.ip.split(',').map(ip => ip.trim());
ips.forEach(ip => uniqueIPs.add(ip));
});
const uniqueRegions = new Set(mapPoints.map(point => point.regions)).size;
return {
totalBytes: formatBytes(totalBytes),
totalPackets: totalPackets.toLocaleString(),
uniqueIPs: uniqueIPs.size,
uniqueRegions
};
}, [mapPoints]);
if (isLoading) {
return (
<>
<Head>
<title>Traffic Map - NetGuardia</title>
</Head>
<Layout>
<div className="flex items-center justify-center w-full h-full min-h-[calc(100vh-4rem)]">
<LoadingSpinner />
</div>
</Layout>
</>
);
}
return (
<>
<Head>
<title>Traffic Map - NetGuardia</title>
<meta name="description" content="NetGuardia Network Traffic Geographic Map" />
<link href='https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css' rel='stylesheet' />
</Head>
<Layout>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
<FontAwesomeIcon icon={faMapMarkedAlt} className="mr-3 text-blue-600" />
Network Traffic Map
</h1>
<p className={isDark ? 'text-gray-400' : 'text-gray-600'}>
Visualize network traffic geographic distribution in real-time
</p>
</motion.div>
<ControlPanel
isIPv6={isIPv6}
setIsIPv6={setIsIPv6}
direction={direction}
setDirection={setDirection}
trafficType={trafficType}
setTrafficType={setTrafficType}
timeRange={timeRange}
setTimeRange={setTimeRange}
isPaused={isPaused}
setIsPaused={setIsPaused}
onRefresh={handleRefresh}
lastUpdateTime={lastUpdateTime}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<StatsCard
title="Total Traffic"
value={stats.totalBytes}
icon={faNetworkWired}
color="bg-blue-500"
/>
<StatsCard
title="Total Packets"
value={stats.totalPackets}
icon={faLayerGroup}
color="bg-green-500"
/>
<StatsCard
title="Unique IPs"
value={stats.uniqueIPs}
icon={faGlobe}
color="bg-purple-500"
/>
<StatsCard
title="Regions"
value={stats.uniqueRegions}
icon={faMapMarkedAlt}
color="bg-orange-500"
/>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={`rounded-lg shadow-md overflow-hidden ${isDark ? 'bg-gray-600' : 'bg-white'}`}
>
<MapComponent points={mapPoints} isDark={isDark} />
</motion.div>
</Layout>
</>
);
};
export default TrafficMap;

View File

@ -1,278 +0,0 @@
import React, { createContext, useState, useCallback, useMemo, useRef } from 'react'
import { fetchData } from '../utils/connectionUtils'
import { urls } from '../config'
interface AccessControlItem {
ip: string;
ports: (string | number)[];
}
interface CachedData {
[key: string]: {
data: AccessControlItem[];
timestamp: number;
isLoading: boolean;
}
}
interface AccessControlContextType {
// 当前显示的数据
currentData: AccessControlItem[];
filteredData: AccessControlItem[];
isLoading: boolean;
// 数据管理方法
setFilteredData: (data: AccessControlItem[]) => void;
switchTo: (isIPv6: boolean, listType: string) => void;
refreshData: (isIPv6?: boolean, listType?: string) => Promise<void>;
// 缓存状态
getCacheStatus: () => { [key: string]: { lastUpdate: Date; isStale: boolean } };
}
export const AccessControlContext = createContext<AccessControlContextType>({
currentData: [],
filteredData: [],
isLoading: false,
setFilteredData: () => {},
switchTo: () => {},
refreshData: async () => {},
getCacheStatus: () => ({}),
})
interface AccessControlProviderProps {
children: React.ReactNode
}
// 缓存过期时间 (10分钟)
const CACHE_EXPIRE_TIME = 10 * 60 * 1000;
// 生成缓存键
const getCacheKey = (isIPv6: boolean, listType: string): string => {
return `${isIPv6 ? 'ipv6' : 'ipv4'}_${listType}`;
}
// 检查缓存是否过期
const isCacheStale = (timestamp: number): boolean => {
return Date.now() - timestamp > CACHE_EXPIRE_TIME;
}
export const AccessControlProvider: React.FC<AccessControlProviderProps> = ({ children }) => {
// 缓存所有数据,避免重复请求
const [cache, setCache] = useState<CachedData>({})
const [filteredData, setFilteredData] = useState<AccessControlItem[]>([])
const [currentKey, setCurrentKey] = useState<string>('')
// 使用 ref 追踪正在进行的请求,避免重复请求
const pendingRequests = useRef<Set<string>>(new Set())
// 从缓存或 API 获取数据
const fetchCachedData = useCallback(async (
isIPv6: boolean,
listType: string,
forceRefresh: boolean = false
): Promise<AccessControlItem[]> => {
const key = getCacheKey(isIPv6, listType);
const cachedItem = cache[key];
// 如果有有效缓存且不强制刷新,直接返回
if (!forceRefresh && cachedItem && !isCacheStale(cachedItem.timestamp) && !cachedItem.isLoading) {
return cachedItem.data;
}
// 如果正在请求中,等待完成
if (pendingRequests.current.has(key)) {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!pendingRequests.current.has(key)) {
clearInterval(checkInterval);
const updatedCache = cache[key];
resolve(updatedCache ? updatedCache.data : []);
}
}, 100);
});
}
// 标记正在加载
pendingRequests.current.add(key);
// 立即更新缓存状态为加载中
setCache(prev => ({
...prev,
[key]: {
data: cachedItem?.data || [],
timestamp: cachedItem?.timestamp || 0,
isLoading: true
}
}));
try {
const url = isIPv6
? urls.access_control.ipv6[listType as keyof typeof urls.access_control.ipv6]
: urls.access_control.ipv4[listType as keyof typeof urls.access_control.ipv4];
const data = await new Promise<AccessControlItem[]>((resolve, reject) => {
fetchData(
url,
(response) => {
try {
const parsedData = JSON.parse(response);
const dataArray: AccessControlItem[] = Object.entries(parsedData).map(([ip, ports]) => ({
ip,
ports: ports as (string | number)[],
}));
resolve(dataArray);
} catch (error) {
console.error("Error parsing data:", error);
reject(new Error("Invalid data format"));
}
},
(error) => {
console.error(`Error fetching from ${url}:`, error);
reject(new Error("Network error: Please check your connection"));
}
);
});
// 更新缓存
setCache(prev => ({
...prev,
[key]: {
data,
timestamp: Date.now(),
isLoading: false
}
}));
return data;
} catch (error) {
// 发生错误时,恢复缓存状态
setCache(prev => ({
...prev,
[key]: {
data: cachedItem?.data || [],
timestamp: cachedItem?.timestamp || 0,
isLoading: false
}
}));
throw error;
} finally {
// 移除请求标记
pendingRequests.current.delete(key);
}
}, [cache]);
// 切换到指定的IP版本和列表类型
const switchTo = useCallback(async (isIPv6: boolean, listType: string) => {
const key = getCacheKey(isIPv6, listType);
setCurrentKey(key);
try {
const data = await fetchCachedData(isIPv6, listType);
setFilteredData(data);
} catch (error) {
console.error(`Failed to switch to ${key}:`, error);
// 发生错误时显示空数据,但不影响用户体验
setFilteredData([]);
}
}, [fetchCachedData]);
// 刷新数据
const refreshData = useCallback(async (isIPv6?: boolean, listType?: string) => {
// 如果没有指定参数,刷新当前数据
if (isIPv6 === undefined || listType === undefined) {
if (!currentKey) return;
const [ipVersion, list] = currentKey.split('_');
const isV6 = ipVersion === 'ipv6';
await switchTo(isV6, list);
return;
}
// 刷新指定数据
try {
const data = await fetchCachedData(isIPv6, listType, true);
const key = getCacheKey(isIPv6, listType);
// 如果刷新的是当前显示的数据,更新显示
if (key === currentKey) {
setFilteredData(data);
}
} catch (error) {
console.error(`Failed to refresh data for ${getCacheKey(isIPv6, listType)}:`, error);
throw error;
}
}, [currentKey, fetchCachedData, switchTo]);
// 获取缓存状态(用于调试和监控)
const getCacheStatus = useCallback(() => {
const status: { [key: string]: { lastUpdate: Date; isStale: boolean } } = {};
Object.entries(cache).forEach(([key, item]) => {
status[key] = {
lastUpdate: new Date(item.timestamp),
isStale: isCacheStale(item.timestamp)
};
});
return status;
}, [cache]);
// 计算当前数据和加载状态
const currentData = useMemo(() => {
return currentKey ? (cache[currentKey]?.data || []) : [];
}, [cache, currentKey]);
const isLoading = useMemo(() => {
return currentKey ? (cache[currentKey]?.isLoading || false) : false;
}, [cache, currentKey]);
// 预加载常用数据IPv4 黑名单)
React.useEffect(() => {
// 在组件挂载时预加载 IPv4 黑名单,这是最常用的
const preloadKey = getCacheKey(false, 'black_list');
if (!cache[preloadKey]) {
fetchCachedData(false, 'black_list').catch(error => {
console.warn('Failed to preload IPv4 blacklist:', error);
});
}
}, []); // 只在组件挂载时执行一次
// 定期清理过期缓存
React.useEffect(() => {
const cleanupInterval = setInterval(() => {
setCache(prev => {
const now = Date.now();
const cleaned: CachedData = {};
Object.entries(prev).forEach(([key, item]) => {
// 保留最近 30 分钟内的缓存
if (now - item.timestamp < 30 * 60 * 1000) {
cleaned[key] = item;
}
});
return cleaned;
});
}, 5 * 60 * 1000); // 每5分钟清理一次
return () => clearInterval(cleanupInterval);
}, []);
const contextValue = useMemo((): AccessControlContextType => ({
currentData,
filteredData,
isLoading,
setFilteredData,
switchTo,
refreshData,
getCacheStatus,
}), [currentData, filteredData, isLoading, switchTo, refreshData, getCacheStatus]);
return (
<AccessControlContext.Provider value={contextValue}>
{children}
</AccessControlContext.Provider>
);
};

View File

@ -1,115 +0,0 @@
// src/providers/ThemeProvider.tsx
'use client'
import React, { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'light' | 'dark' | 'system'
interface ThemeContextType {
theme: Theme
actualTheme: 'light' | 'dark'
setTheme: (theme: Theme) => void
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<Theme>('system')
const [actualTheme, setActualTheme] = useState<'light' | 'dark'>('light')
const getSystemTheme = (): 'light' | 'dark' => {
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return 'light'
}
const calculateActualTheme = (currentTheme: Theme): 'light' | 'dark' => {
if (currentTheme === 'system') {
return getSystemTheme()
}
return currentTheme
}
useEffect(() => {
const savedTheme = localStorage.getItem('theme') as Theme
if (savedTheme && ['light', 'dark', 'system'].includes(savedTheme)) {
setTheme(savedTheme)
}
}, [])
useEffect(() => {
const newActualTheme = calculateActualTheme(theme)
setActualTheme(newActualTheme)
if (typeof window !== 'undefined') {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(newActualTheme)
root.setAttribute('data-theme', newActualTheme)
localStorage.setItem('theme', theme)
}
}, [theme])
useEffect(() => {
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => {
if (theme === 'system') {
const newActualTheme = calculateActualTheme(theme)
setActualTheme(newActualTheme)
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(newActualTheme)
root.setAttribute('data-theme', newActualTheme)
}
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}
}, [theme])
const toggleTheme = () => {
setTheme((currentTheme: Theme) => {
switch (currentTheme) {
case 'light':
return 'dark'
case 'dark':
return 'system'
case 'system':
return 'light'
default:
return 'light'
}
})
}
const handleSetTheme = (newTheme: Theme) => {
setTheme(newTheme)
}
return (
<ThemeContext.Provider value={{
theme,
actualTheme,
setTheme: handleSetTheme,
toggleTheme
}}>
{children}
</ThemeContext.Provider>
)
}

View File

@ -1,189 +0,0 @@
import React, { createContext, useState, useEffect, useRef, useCallback } from 'react'
import { websocketUrl, urls } from '../config'
import { createWebSocket, fetchData } from '../utils/connectionUtils'
import { BehaviorSubject } from 'rxjs'
import { share } from 'rxjs/operators'
interface WebSocketContextType {
bootTime: number | null;
getDetectionAlertStream: () => any;
getIPv4FlowStream: (direction: string, flow_direction: string, timeType: string) => any;
getIPv6FlowStream: (direction: string, flow_direction: string, timeType: string) => any;
getSystemHealthStream: () => any;
getLatestFlowData: (protocol: 'ipv4' | 'ipv6', direction: string, flow_direction: string, timeType: string) => any;
getLatestSystemHealth: () => any;
}
export const WebsocketContext = createContext<WebSocketContextType>({
bootTime: null,
getDetectionAlertStream: () => null,
getIPv4FlowStream: () => null,
getIPv6FlowStream: () => null,
getSystemHealthStream: () => null,
getLatestFlowData: () => null,
getLatestSystemHealth: () => null,
})
export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const wsRefs = useRef<{ [key: string]: WebSocket }>({})
const [bootTime, setBootTime] = useState<number | null>(null)
const retryDelays = useRef<{ [key: string]: number }>({})
const dataSubjects = useRef<{ [key: string]: BehaviorSubject<any> }>({})
const latestDataCache = useRef<{ [key: string]: any }>({})
const fetchBootTime = async () => {
try {
await fetchData(
urls.bootTime,
(data) => setBootTime(Number(data.trim())),
(error) => {
throw new Error(`Failed to fetch boot_time: ${error?.message || 'Unknown error'}`)
}
)
} catch (error) {
console.error('Boot time fetch error:', error)
}
}
const getOrCreateSubject = (url: string) => {
if (!dataSubjects.current[url]) {
dataSubjects.current[url] = new BehaviorSubject(null)
}
return dataSubjects.current[url]
}
const getWebSocketStream = (url: string) => {
const subject = getOrCreateSubject(url)
return subject.asObservable().pipe(share())
}
const setupWebSocket = (url: string, retryCount: number = 0) => {
if (wsRefs.current[url] && wsRefs.current[url].readyState === WebSocket.OPEN) {
console.log(`WebSocket already connected: ${url}`)
return wsRefs.current[url]
}
if (wsRefs.current[url]) {
wsRefs.current[url].close()
}
const ws = createWebSocket(
url,
(data) => {
latestDataCache.current[url] = data
getOrCreateSubject(url).next(data)
},
(error) => console.error(`WebSocket error for ${url}:`, error)
)
ws.onclose = () => {
console.warn(`WebSocket for ${url} closed. Retrying...`)
delete wsRefs.current[url]
let delay = retryDelays.current[url] || 1000
retryDelays.current[url] = Math.min(delay * 2, 30000)
setTimeout(() => setupWebSocket(url, retryCount + 1), delay)
}
ws.onopen = () => {
console.log(`WebSocket connected: ${url}`)
retryDelays.current[url] = 1000
}
wsRefs.current[url] = ws
return ws
}
const getLatestFlowData = useCallback((
protocol: 'ipv4' | 'ipv6',
direction: string,
flow_direction: string,
timeType: string
) => {
const urlFunc = protocol === 'ipv4' ? websocketUrl.ipv4FlowStats : websocketUrl.ipv6FlowStats
const url = urlFunc(direction, flow_direction, timeType)
return latestDataCache.current[url] || null
}, [])
const getLatestSystemHealth = useCallback(() => {
return latestDataCache.current[websocketUrl.systemHealth] || null
}, [])
const getDetectionAlertStream = useCallback(() => {
return getWebSocketStream(websocketUrl.detectionAlert)
}, [])
const getIPv4FlowStream = useCallback((direction: string, flow_direction: string, timeType: string) => {
const url = websocketUrl.ipv4FlowStats(direction, flow_direction, timeType)
return getWebSocketStream(url)
}, [])
const getIPv6FlowStream = useCallback((direction: string, flow_direction: string, timeType: string) => {
const url = websocketUrl.ipv6FlowStats(direction, flow_direction, timeType)
return getWebSocketStream(url)
}, [])
const getSystemHealthStream = useCallback(() => {
return getWebSocketStream(websocketUrl.systemHealth)
}, [])
const initializeWebSockets = useCallback(() => {
console.log("Initializing all WebSocket connections...")
setupWebSocket(websocketUrl.detectionAlert)
setupWebSocket(websocketUrl.systemHealth)
const directions = ["ingress", "egress"]
const flowDirections = ["source", "destination"]
const timeTypes = ["1min", "10min", "1hour"]
directions.forEach(direction => {
flowDirections.forEach(flow_direction => {
timeTypes.forEach(timeType => {
setupWebSocket(websocketUrl.ipv4FlowStats(direction, flow_direction, timeType))
setupWebSocket(websocketUrl.ipv6FlowStats(direction, flow_direction, timeType))
})
})
})
console.log(`Total WebSocket connections: ${Object.keys(wsRefs.current).length}`)
}, [])
const closeWebSockets = useCallback(() => {
console.log("Closing all WebSocket connections...")
Object.entries(wsRefs.current).forEach(([url, ws]) => {
try {
ws.close()
} catch (error) {
console.error(`Failed to close WebSocket ${url}:`, error)
}
})
wsRefs.current = {}
Object.values(dataSubjects.current).forEach(subject => {
subject.complete()
})
dataSubjects.current = {}
latestDataCache.current = {}
}, [])
useEffect(() => {
fetchBootTime()
initializeWebSockets()
return () => closeWebSockets()
}, [])
return (
<WebsocketContext.Provider value={{
bootTime,
getDetectionAlertStream,
getIPv4FlowStream,
getIPv6FlowStream,
getSystemHealthStream,
getLatestFlowData,
getLatestSystemHealth,
}}>
{children}
</WebsocketContext.Provider>
)
}

View File

@ -1,68 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@300;400;500;600&family=Noto+Sans+TC:wght@300;400;500;600&family=Noto+Sans+SC:wght@300;400;500;600&family=Noto+Sans+JP:wght@300;400;500;600&display=swap');
@import "tailwindcss";
svg.svg-inline--fa {
display: inline-block;
height: 1em;
overflow: visible;
vertical-align: -0.125em;
}
/* 確保圖標尺寸穩定 */
.svg-inline--fa {
width: 1em;
}
/* 針對有固定寬高的圖標 */
.svg-inline--fa.fa-fw {
width: 1.25em;
}
/* 防止尺寸跳動 */
.svg-inline--fa {
transition: none !important;
}
/* 確保初始渲染就有正確尺寸 */
.svg-inline--fa:not(:root) {
overflow: visible;
box-sizing: content-box;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(59, 130, 246, 0.3); /* 使用 Tailwind blue-500 */
border-radius: 10px;
transition: background 0.2s ease-in-out; /* 使用完整的 transition 屬性 */
}
::-webkit-scrollbar-thumb:hover {
background: rgba(59, 130, 246, 0.6); /* 懸停時加深 */
}
::-webkit-scrollbar-track {
background: rgba(243, 244, 246, 0.5); /* 淺灰色背景,提供更好的對比 */
border-radius: 10px;
transition: background 0.15s ease; /* 軌道也加上過渡效果 */
}
/* 可選:為暗色主題準備的樣式 */
@media (prefers-color-scheme: dark) {
::-webkit-scrollbar-thumb {
background: rgba(96, 165, 250, 0.4); /* 暗色主題下使用 blue-400 */
transition: background 0.2s ease-in-out;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(96, 165, 250, 0.7);
}
::-webkit-scrollbar-track {
background: rgba(31, 41, 55, 0.3); /* 暗色主題下的軌道背景 */
transition: background 0.15s ease;
}
}

View File

@ -1,50 +0,0 @@
// ai detection types
export interface AlertLog {
timestamp: number
flow_key: string
src_ip: string
dst_ip: string
src_port: number
dst_port: number
protocol: number
is_attack: boolean
attack_type: string
confidence: number
ae_score: number
rf_score: number
ensemble_score: number
}
export interface PriorityInfo {
color: string
bgColor: string
icon: any
label: string
}
export interface NotificationProps {
message: string
type: 'success' | 'error' | 'warning' | 'info'
onClose: () => void
}
export interface AlertDetailsProps {
log: AlertLog
onClose: () => void
showNotification: (message: string, type?: 'success' | 'error' | 'warning' | 'info') => void
}
export interface AlertItemProps {
log: AlertLog
index: number
onClick: (index: number) => void
}
export interface FilterButtonProps {
isActive: boolean
onClick: () => void
children: React.ReactNode
className?: string
activeClassName?: string
activeHoverClassName?: string
}

View File

@ -1,34 +0,0 @@
// access control types
export interface AccessControlItem {
ip: string
ports: (string | number)[]
}
export interface NotificationProps {
message: string
type: 'success' | 'error' | 'warning' | 'info'
onClose: () => void
}
export interface ModalProps {
title: string
children: React.ReactNode
onClose: () => void
onSubmit?: () => void
submitLabel?: string
submitDisabled?: boolean
}
export interface ToggleButtonProps {
isActive: boolean
onClick: () => void
children: React.ReactNode
className?: string
}
export interface StatsCardProps {
title: string
value: number
icon: any
color: string
}

View File

@ -1,36 +0,0 @@
// dashboard types
export interface TrendDataPoint {
time: string
value: number
}
export interface TrendData {
ingressSource: TrendDataPoint[]
egressSource: TrendDataPoint[]
}
export interface TrafficData {
ipv4: {
ingressSource: number
egressSource: number
}
ipv6: {
ingressSource: number
egressSource: number
}
}
export interface UpdateControlProps {
updateInterval: number;
setUpdateInterval: (value: number) => void;
isPaused: boolean;
setIsPaused: (value: boolean) => void;
lastUpdateTime: Date | null;
connectionStatus: string;
}
export interface EChartsComponentProps {
data: TrendData
type: 'ipv4' | 'ipv6'
isPaused: boolean
}

View File

@ -1,86 +0,0 @@
// index types
export interface SystemHealthData {
timestamp: number
boot_time?: number
uptime_seconds?: number
system_info: {
kernel_version: string,
os_name: string,
os_version: string,
architecture: string,
total_processes: number
}
cpu_details?: {
cpu_brand: string
core_count: number
cpu_usage: number
cpu_frequency: number
cores: Array<{
core_id: number
usage_percent: number
frequency: number
}>
}
memory_usage: {
total: number
used: number
available: number
usage_percent: number
swap_total: number
swap_used: number
}
network_stats: {
ingress: NetworkInterface
egress: NetworkInterface
management: NetworkInterface
}
load_average: {
one_minute: number
five_minute: number
fifteen_minute: number
} | null
temperature: number
}
export interface NetworkInterface {
interface: string
bytes_received: number
bytes_transmitted: number
packets_received: number
packets_transmitted: number
errors_received: number
errors_transmitted: number
}
export interface HealthMetric {
name: string
value: number | string
unit?: string
status: 'good' | 'warning' | 'critical' | 'unknown'
icon: any
color: string
bgColor: string
description?: string
}
export interface SystemDetailModalProps {
isOpen: boolean
onClose: () => void
systemHealth: SystemHealthData | null
type: 'cpu' | 'memory' | 'temperature'
}
export interface HealthCardProps {
metric: HealthMetric
index: number
systemHealth?: SystemHealthData | null
onClick?: () => void
}
export interface LoadAverageProps {
loadAverage: SystemHealthData['load_average']
}
export interface NetworkStatsProps {
networkStats: SystemHealthData['network_stats']
}

View File

@ -1,58 +0,0 @@
// statistics types
export interface ToggleButtonProps {
isActive: boolean;
onClick: () => void;
children: React.ReactNode;
className?: string;
}
export interface TableHeaderProps {
column: { key: string; label: string };
sortConfig: { key: string | null; direction: string };
onSort: (key: string) => void;
}
export interface UpdateControlProps {
updateInterval: number;
setUpdateInterval: (value: number) => void;
isPaused: boolean;
setIsPaused: (value: boolean) => void;
lastUpdateTime: Date | null;
}
export interface ControlPanelProps {
isIPv6: boolean;
setIsIPv6: (value: boolean) => void;
direction: string;
setDirection: (value: string) => void;
trafficType: string;
setTrafficType: (value: string) => void;
timeRange: string;
setTimeRange: (value: string) => void;
searchTerm: string;
setSearchTerm: (value: string) => void;
updateInterval: number;
setUpdateInterval: (value: number) => void;
isPaused: boolean;
setIsPaused: (value: boolean) => void;
lastUpdateTime: Date | null;
}
export interface DataTableProps {
data: any[];
sortConfig: { key: string | null; direction: string };
handleSort: (key: string) => void;
bootTime: number | null;
isUpdating: boolean;
}
export interface NoDataStateProps {
isSearchFiltered: boolean;
}
export interface StatsOverviewProps {
data: any[];
isIPv6: boolean;
direction: string;
trafficType: string;
}

View File

@ -1,55 +0,0 @@
// traffic map types
export interface MapPoint {
id: string;
ip: string;
latitude: number;
longitude: number;
bytes: number;
packets: number;
code: string;
city: string;
regions: string;
type: 'source' | 'destination';
}
export interface MapConfig {
center: [number, number];
zoom: number;
minZoom: number,
maxZoom: number,
scrollZoom: boolean,
boxZoom: boolean,
doubleClickZoom: boolean,
touchZoomRotate: boolean,
dragRotate: boolean,
pitchWithRotate: boolean,
renderWorldCopies: boolean
}
export interface GeoLocation {
latitude: number | null;
longitude: number | null;
city?: string;
country?: string;
country_code?: string;
}
export interface FlowDetails {
bytes: number;
packets: number;
geo: GeoLocation;
}
export interface FlowDataRecord {
[ipPort: string]: FlowDetails;
}
export interface ProtocolData {
ipv4?: FlowDataRecord;
ipv6?: FlowDataRecord;
}
export interface FlowDataState {
[key: string]: ProtocolData;
}

View File

@ -1,93 +0,0 @@
export const createWebSocket = (
url: string,
onMessage: (data: any) => void,
onError?: (error: Event) => void
): WebSocket => {
const ws = new WebSocket(url)
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
onMessage(data)
} catch (error) {
console.error("Failed to parse WebSocket message:", error)
}
}
ws.onerror = (error) => {
console.error("WebSocket Error:", error)
if (onError) onError(error)
}
return ws
}
export const fetchData = async (
url: string,
onSuccess: (data: string) => void,
onError?: (error?: Error) => void
): Promise<void> => {
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`)
const data = await response.text()
onSuccess(data)
} catch (error) {
console.error(`Failed to fetch data from URL: ${url}`)
if (onError) onError(error as Error)
}
}
export const putData = async (
url: string,
payload: any,
onSuccess?: (data?: any) => void,
onError?: (error: Error) => void
): Promise<void> => {
try {
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`)
const contentType = response.headers.get("Content-Type")
const isJsonResponse = contentType && contentType.includes("application/json")
const data = isJsonResponse ? await response.json() : null
if (onSuccess) onSuccess(data)
} catch (error) {
console.error(`Failed to send PUT request to URL: ${url}`, error)
if (onError) onError(error as Error)
}
}
export const deleteData = async (
url: string,
data: any,
onSuccess?: (data: string) => void,
onError?: (error: Error) => void
): Promise<void> => {
try {
const response = await fetch(url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`)
const responseData = await response.text()
if (onSuccess) onSuccess(responseData)
} catch (error) {
console.error(`Failed to send DELETE request to URL: ${url}`, error)
if (onError) onError(error as Error)
}
}

View File

@ -1,4 +0,0 @@
@echo off
title KWL-BOT v2.0
npm run dev
pause

View File

@ -1,83 +0,0 @@
#!/bin/bash
# 切換到工作目錄
cd /home
# 檢查是否為首次安裝(通過檢查是否存在特定標記文件)
if [ ! -f ".setup_complete" ]; then
echo "First installation detected, starting system initialization..."
# 更新系統套件
apt-get update
apt-get upgrade -y
# 安裝必要的系統套件
apt install tzdata -y
apt install -y ca-certificates curl gnupg
echo "Installing Node.js LTS (v22.x)..."
# 創建 keyrings 目錄
sudo mkdir -p /etc/apt/keyrings
# 下載並添加 NodeSource GPG key
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
# 設定 Node.js 主要版本號
NODE_MAJOR=22
# 添加 NodeSource repository
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
# 更新套件索引
apt update
# 安裝 Node.js (包含 npm)
apt install nodejs -y
# 驗證安裝
echo "Node.js installation completed!"
echo "Node.js version: $(node --version)"
echo "npm version: $(npm --version)"
# 創建安裝完成標記
touch .setup_complete
echo "System initialization completed!"
else
echo "System initialization already completed, skipping installation steps..."
echo "Current Node.js version: $(node --version)"
echo "Current npm version: $(npm --version)"
fi
# 安裝/更新 npm 依賴套件
echo "Installing/updating npm dependencies..."
if [ -f "package.json" ]; then
npm install
else
echo "No package.json found, skipping npm install..."
fi
# 運行應用程式
echo "Starting application..."
if [ -f "package.json" ]; then
# 檢查是否有 build script
if grep -q '"build"' package.json 2>/dev/null; then
echo "Running build script..."
npm run build
else
echo "No build script found, skipping build step..."
fi
# 檢查是否有 start script
if grep -q '"start:https"' package.json 2>/dev/null; then
echo "Running start script for https..."
npm run start:https
elif grep -q '"start"' package.json 2>/dev/null; then
echo "Running start script..."
npm run start
else
echo "No start script found, please configure your package.json start script..."
fi
else
echo "No package.json found, please initialize your Node.js project first with 'npm init'"
fi

View File

@ -1,14 +0,0 @@
import typography from '@tailwindcss/typography';
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [typography],
}

View File

@ -1,30 +0,0 @@
{
"compilerOptions": {
"target": "es2024",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"incremental": true,
"module": "esnext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}