mirror of
https://github.com/ParrotXray/Mantis.git
synced 2026-08-24 18:50:28 +09:00
refactor: Rename project from NetGuardia to Mantis
This commit is contained in:
parent
40d420a0d7
commit
6f40a8f81c
@ -1,11 +1,11 @@
|
||||
---
|
||||
name: research
|
||||
description: Interactive research assistant for NetGuardia. You ask questions; I research, reason, and discuss with you.
|
||||
description: Interactive research assistant for Mantis. You ask questions; I research, reason, and discuss with you.
|
||||
---
|
||||
|
||||
# Interactive Research — Ask / Research / Discuss
|
||||
|
||||
You are a research discussion partner for the NetGuardia project.
|
||||
You are a research discussion partner for the Mantis project.
|
||||
|
||||
## Hierarchy
|
||||
|
||||
@ -38,7 +38,7 @@ these priorities and surface that context in your response.
|
||||
|
||||
## How This Works
|
||||
|
||||
1. **User asks a question** — about ML inference, eBPF capture, rule engine, API/frontend, or anything NetGuardia-related.
|
||||
1. **User asks a question** — about ML inference, eBPF capture, rule engine, API/frontend, or anything Mantis-related.
|
||||
2. **Research** — search docs, source code, papers, or reason from existing findings. Check `.research/findings/` for prior context before searching externally.
|
||||
3. **Discuss** — present findings clearly, state confidence level, surface open questions, and invite follow-up.
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -12,7 +12,7 @@ target/
|
||||
logs
|
||||
.log
|
||||
.txt
|
||||
net-guardia/static/web
|
||||
mantis/static/web
|
||||
*.mmdb
|
||||
node_modules/
|
||||
.next/
|
||||
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@ -1,3 +1,3 @@
|
||||
[submodule "net-guardia-frontend"]
|
||||
path = net-guardia-frontend
|
||||
[submodule "mantis-frontend"]
|
||||
path = mantis-frontend
|
||||
url = https://github.com/ParrotXray/NetGuardia-frontend-academic-research.git
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
# NetGuardia Research State
|
||||
# Mantis Research State
|
||||
# Updated: 2026-05-21
|
||||
|
||||
[[epics]]
|
||||
id = "nids-v1"
|
||||
title = "NetGuardia NIDS v1 — Research Prototype"
|
||||
title = "Mantis NIDS v1 — Research Prototype"
|
||||
status = "active"
|
||||
description = """
|
||||
End-to-end NIDS combining eBPF/AF_XDP packet capture, LSTM autoencoder ML inference,
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@ -1,4 +1,4 @@
|
||||
# NetGuardia
|
||||
# Mantis
|
||||
|
||||
Network intrusion detection system combining eBPF packet capture with ML-based anomaly detection and Suricata/Snort rule matching.
|
||||
|
||||
@ -19,12 +19,12 @@ Network intrusion detection system combining eBPF packet capture with ML-based a
|
||||
## Workspace Structure
|
||||
|
||||
```
|
||||
net-guardia/ - Main application (ML, eBPF userspace, HTTP API, WebSocket)
|
||||
mantis/ - Main application (ML, eBPF userspace, HTTP API, WebSocket)
|
||||
common/ - Shared types used by both userspace and eBPF programs
|
||||
macros/ - Procedural macros: log!, traceable!, loggable!
|
||||
ingress-ebpf/ - eBPF ingress packet capture program
|
||||
egress-ebpf/ - eBPF egress packet capture program
|
||||
net-guardia-frontend/ - Next.js web UI
|
||||
mantis-frontend/ - Next.js web UI
|
||||
```
|
||||
|
||||
## Build
|
||||
@ -33,17 +33,17 @@ Full build requires eBPF toolchain and system libs (libelf, boost for vectorscan
|
||||
|
||||
To type-check without eBPF (for ML/API changes):
|
||||
```bash
|
||||
SKIP_EBPF_BUILD=1 cargo check --package net-guardia
|
||||
SKIP_EBPF_BUILD=1 cargo check --package mantis
|
||||
```
|
||||
|
||||
Ignore these expected errors when SKIP_EBPF_BUILD is set:
|
||||
- `environment variable ARTIFACTCS_PATH not defined`
|
||||
- `environment variable CSV_RECORD_PATH not defined`
|
||||
- `environment variable RULES_DB_PATH not defined`
|
||||
- `couldn't read .../net-guardia-ingress`
|
||||
- `couldn't read .../net-guardia-egress`
|
||||
- `couldn't read .../mantis-ingress`
|
||||
- `couldn't read .../mantis-egress`
|
||||
|
||||
## net-guardia Source Layout
|
||||
## mantis Source Layout
|
||||
|
||||
```
|
||||
src/
|
||||
@ -129,7 +129,7 @@ Session is wrapped in `Mutex<Session>` because `Session::run` requires `&mut sel
|
||||
## Config
|
||||
|
||||
Runtime config: `config.toml`
|
||||
ML artifacts: `net-guardia/static/artifacts/`
|
||||
ML artifacts: `mantis/static/artifacts/`
|
||||
- `deep_autoencoder.onnx` - LSTM autoencoder model
|
||||
- `inference_config.json` - window size, feature names, scaler params, threshold
|
||||
|
||||
|
||||
88
Cargo.lock
generated
88
Cargo.lock
generated
@ -1662,6 +1662,50 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mantis"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"actix",
|
||||
"actix-cors",
|
||||
"actix-web",
|
||||
"actix-ws",
|
||||
"aya",
|
||||
"aya-log",
|
||||
"cargo_metadata",
|
||||
"cc",
|
||||
"chrono",
|
||||
"common",
|
||||
"crossbeam",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"libc",
|
||||
"lru",
|
||||
"macros",
|
||||
"maxminddb",
|
||||
"mime_guess",
|
||||
"ndarray 0.17.2",
|
||||
"network-types",
|
||||
"ort",
|
||||
"ort-tract",
|
||||
"parking_lot",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
"thiserror 2.0.16",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"tract-onnx",
|
||||
"url",
|
||||
"xsk-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
@ -1797,50 +1841,6 @@ dependencies = [
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "net-guardia"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"actix",
|
||||
"actix-cors",
|
||||
"actix-web",
|
||||
"actix-ws",
|
||||
"aya",
|
||||
"aya-log",
|
||||
"cargo_metadata",
|
||||
"cc",
|
||||
"chrono",
|
||||
"common",
|
||||
"crossbeam",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"libc",
|
||||
"lru",
|
||||
"macros",
|
||||
"maxminddb",
|
||||
"mime_guess",
|
||||
"ndarray 0.17.2",
|
||||
"network-types",
|
||||
"ort",
|
||||
"ort-tract",
|
||||
"parking_lot",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
"thiserror 2.0.16",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"tract-onnx",
|
||||
"url",
|
||||
"xsk-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "network-types"
|
||||
version = "0.1.0"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["net-guardia", "common", "macros", "ingress-ebpf", "egress-ebpf"]
|
||||
default-members = ["net-guardia", "common"]
|
||||
members = ["mantis", "common", "macros", "ingress-ebpf", "egress-ebpf"]
|
||||
default-members = ["mantis", "common"]
|
||||
|
||||
[workspace.dependencies]
|
||||
aya = { version = "0.13.1", default-features = false }
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# NetGuardia
|
||||
# Mantis
|
||||
|
||||
## Project Overview
|
||||
|
||||
**NetGuardia** is a high-performance network security solution that combines eBPF XDP technology with deep learning models to provide advanced network protection. The system operates as a standalone network appliance that can run on any Ubuntu-based system with compatible network hardware.
|
||||
**Mantis** is a high-performance network security solution that combines eBPF XDP technology with deep learning models to provide advanced network protection. The system operates as a standalone network appliance that can run on any Ubuntu-based system with compatible network hardware.
|
||||
|
||||
## Core Technologies
|
||||
|
||||
@ -50,11 +50,10 @@
|
||||
- Root/sudo access for eBPF program loading
|
||||
|
||||
## Hardware Compatibility
|
||||
NetGuardia is designed to work on any Ubuntu-based system meeting the following requirements:
|
||||
Mantis is designed to work on any Ubuntu-based system meeting the following requirements:
|
||||
|
||||
- Network Interface: Any dual-port NIC supporting XDP native or offload mode (Intel i350 T2 recommended)
|
||||
- CPU: Multi-core processor recommended for optimal performance
|
||||
- Memory: 8GB RAM minimum, 16GB or more for high-traffic environments
|
||||
|
||||
The system is not limited to embedded platforms and can be deployed on standard server hardware, virtual machines, or dedicated appliances running Ubuntu.
|
||||
|
||||
|
||||
6
TODO
6
TODO
@ -1,5 +1,5 @@
|
||||
============================================================
|
||||
NetGuardia TODO (updated 2026-05-21)
|
||||
Mantis TODO (updated 2026-05-21)
|
||||
============================================================
|
||||
|
||||
-- DONE (archived) ----------------------------------------
|
||||
@ -17,7 +17,7 @@ NetGuardia TODO (updated 2026-05-21)
|
||||
Removed: vectorscan, rusqlite, protolens, pcre2, byte_test/jump/extract,
|
||||
app-layer-protocol, threshold, QUIC parser, suppress list (SQLite).
|
||||
Replaced by: suricata daemon + veth mirror + EVE JSON unix socket.
|
||||
Suricata handles all signature matching; NetGuardia reads alerts via output.rs.
|
||||
Suricata handles all signature matching; Mantis reads alerts via output.rs.
|
||||
[x] Suricata EVE socket race condition (bind before spawn)
|
||||
[x] af-packet block-size too small (32768 -> 131072)
|
||||
[x] Suricata noisy rule categories (exclude emerging-info/policy/user_agents)
|
||||
@ -80,4 +80,4 @@ NetGuardia TODO (updated 2026-05-21)
|
||||
[ ] Dashboard attack/protocol counters never reset
|
||||
Accumulate for entire session lifetime; charts show historical totals.
|
||||
Consider: sliding window reset every N minutes, or cap at last 500 alerts.
|
||||
File: net-guardia-frontend/...dashboard.tsx
|
||||
File: mantis-frontend/...dashboard.tsx
|
||||
|
||||
@ -13,7 +13,7 @@ aya-log-ebpf = { workspace = true }
|
||||
which = "8.0.0"
|
||||
|
||||
[[bin]]
|
||||
name = "net-guardia-egress"
|
||||
name = "mantis-egress"
|
||||
path = "src/main.rs"
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
@ -20,7 +20,7 @@ static PARSED_PACKET: PerCpuArray<Event> = PerCpuArray::with_max_entries(1, 0);
|
||||
static EGRESS_XSKS_MAP: XskMap = XskMap::pinned(64, 0);
|
||||
|
||||
#[xdp]
|
||||
pub fn net_guardia(ctx: XdpContext) -> u32 {
|
||||
pub fn mantis(ctx: XdpContext) -> u32 {
|
||||
unsafe {
|
||||
let _ = packet_intake(ctx);
|
||||
xdp_action::XDP_PASS
|
||||
|
||||
@ -14,7 +14,7 @@ network-types = { workspace = true }
|
||||
which = "8.0.0"
|
||||
|
||||
[[bin]]
|
||||
name = "net-guardia-ingress"
|
||||
name = "mantis-ingress"
|
||||
path = "src/main.rs"
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
@ -22,7 +22,7 @@ static PARSED_PACKET: PerCpuArray<Event> = PerCpuArray::with_max_entries(1, 0);
|
||||
static INGRESS_XSKS_MAP: XskMap = XskMap::pinned(64, 0);
|
||||
|
||||
#[xdp]
|
||||
pub fn net_guardia(ctx: XdpContext) -> u32 {
|
||||
pub fn mantis(ctx: XdpContext) -> u32 {
|
||||
unsafe {
|
||||
let _ = packet_intake(&ctx);
|
||||
let _ = PROGRAM_ARRAY.tail_call(&ctx, TRANSMISSION);
|
||||
|
||||
1
mantis-frontend/.gitattributes
vendored
Normal file
1
mantis-frontend/.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
26
mantis-frontend/.gitignore
vendored
Normal file
26
mantis-frontend/.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# 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?
|
||||
26
mantis-frontend/README.md
Normal file
26
mantis-frontend/README.md
Normal file
@ -0,0 +1,26 @@
|
||||
# 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
|
||||
```
|
||||
6
mantis-frontend/next-env.d.ts
vendored
Normal file
6
mantis-frontend/next-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/// <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.
|
||||
29
mantis-frontend/next.config.js
Normal file
29
mantis-frontend/next.config.js
Normal file
@ -0,0 +1,29 @@
|
||||
/** @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;
|
||||
4902
mantis-frontend/package-lock.json
generated
Normal file
4902
mantis-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
mantis-frontend/package.json
Normal file
53
mantis-frontend/package.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
6
mantis-frontend/postcss.config.js
Normal file
6
mantis-frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
mantis-frontend/public/favicon.ico
Normal file
BIN
mantis-frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
104
mantis-frontend/server.js
Normal file
104
mantis-frontend/server.js
Normal file
@ -0,0 +1,104 @@
|
||||
// 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);
|
||||
});
|
||||
55
mantis-frontend/src/components/ErrorDialog.tsx
Normal file
55
mantis-frontend/src/components/ErrorDialog.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
'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
|
||||
53
mantis-frontend/src/components/Layout.tsx
Normal file
53
mantis-frontend/src/components/Layout.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
// 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
|
||||
11
mantis-frontend/src/components/LoadingSpinner.tsx
Normal file
11
mantis-frontend/src/components/LoadingSpinner.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
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
|
||||
196
mantis-frontend/src/components/Sidebar.tsx
Normal file
196
mantis-frontend/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
// 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
|
||||
173
mantis-frontend/src/components/ThemeToggle.tsx
Normal file
173
mantis-frontend/src/components/ThemeToggle.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
// 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
|
||||
32
mantis-frontend/src/config.ts
Normal file
32
mantis-frontend/src/config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
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;
|
||||
216
mantis-frontend/src/pages/404.tsx
Normal file
216
mantis-frontend/src/pages/404.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
// 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;
|
||||
22
mantis-frontend/src/pages/_app.tsx
Normal file
22
mantis-frontend/src/pages/_app.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
848
mantis-frontend/src/pages/access-control.tsx
Normal file
848
mantis-frontend/src/pages/access-control.tsx
Normal file
@ -0,0 +1,848 @@
|
||||
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
|
||||
1076
mantis-frontend/src/pages/dashboard.tsx
Normal file
1076
mantis-frontend/src/pages/dashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
856
mantis-frontend/src/pages/detection.tsx
Normal file
856
mantis-frontend/src/pages/detection.tsx
Normal file
@ -0,0 +1,856 @@
|
||||
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
|
||||
1472
mantis-frontend/src/pages/index.tsx
Normal file
1472
mantis-frontend/src/pages/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
943
mantis-frontend/src/pages/statistics.tsx
Normal file
943
mantis-frontend/src/pages/statistics.tsx
Normal file
@ -0,0 +1,943 @@
|
||||
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;
|
||||
793
mantis-frontend/src/pages/traffic-map.tsx
Normal file
793
mantis-frontend/src/pages/traffic-map.tsx
Normal file
@ -0,0 +1,793 @@
|
||||
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;
|
||||
278
mantis-frontend/src/providers/AccessControlProvider.tsx
Normal file
278
mantis-frontend/src/providers/AccessControlProvider.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
115
mantis-frontend/src/providers/ThemeProvider.tsx
Normal file
115
mantis-frontend/src/providers/ThemeProvider.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
189
mantis-frontend/src/providers/WebSocketProvider.tsx
Normal file
189
mantis-frontend/src/providers/WebSocketProvider.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
68
mantis-frontend/src/styles/globals.css
Normal file
68
mantis-frontend/src/styles/globals.css
Normal file
@ -0,0 +1,68 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
50
mantis-frontend/src/types/AIDetectionTypes.tsx
Normal file
50
mantis-frontend/src/types/AIDetectionTypes.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
// 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
|
||||
}
|
||||
34
mantis-frontend/src/types/AccessControlTypes.tsx
Normal file
34
mantis-frontend/src/types/AccessControlTypes.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
// 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
|
||||
}
|
||||
36
mantis-frontend/src/types/DashboardTypes.tsx
Normal file
36
mantis-frontend/src/types/DashboardTypes.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
// 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
|
||||
}
|
||||
86
mantis-frontend/src/types/HomeTypes.tsx
Normal file
86
mantis-frontend/src/types/HomeTypes.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
// 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']
|
||||
}
|
||||
58
mantis-frontend/src/types/StatisticsTypes.tsx
Normal file
58
mantis-frontend/src/types/StatisticsTypes.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
// 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;
|
||||
}
|
||||
55
mantis-frontend/src/types/TrafficMapTypes.tsx
Normal file
55
mantis-frontend/src/types/TrafficMapTypes.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
// 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;
|
||||
}
|
||||
93
mantis-frontend/src/utils/connectionUtils.ts
Normal file
93
mantis-frontend/src/utils/connectionUtils.ts
Normal file
@ -0,0 +1,93 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
4
mantis-frontend/start.bat
Normal file
4
mantis-frontend/start.bat
Normal file
@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
title KWL-BOT v2.0
|
||||
npm run dev
|
||||
pause
|
||||
83
mantis-frontend/start.sh
Normal file
83
mantis-frontend/start.sh
Normal file
@ -0,0 +1,83 @@
|
||||
#!/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
|
||||
14
mantis-frontend/tailwind.config.js
Normal file
14
mantis-frontend/tailwind.config.js
Normal file
@ -0,0 +1,14 @@
|
||||
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],
|
||||
}
|
||||
30
mantis-frontend/tsconfig.json
Normal file
30
mantis-frontend/tsconfig.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "net-guardia"
|
||||
name = "mantis"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
@ -53,5 +53,5 @@ cc = "1"
|
||||
dotenvy = "0.15.7"
|
||||
|
||||
[[bin]]
|
||||
name = "net-guardia"
|
||||
name = "mantis"
|
||||
path = "src/main.rs"
|
||||
@ -5,7 +5,7 @@ use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use cargo_metadata::{Artifact, CompilerMessage, Message, Metadata, MetadataCommand, Package, Target, TargetKind};
|
||||
use cargo_metadata::{Artifact, CompilerMessage, Message, Metadata, MetadataCommand, Package, Target};
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
@ -22,8 +22,6 @@ fn main() {
|
||||
.unwrap()
|
||||
.join(format!("{}-frontend", project_name));
|
||||
|
||||
// ── All cargo: directives in one place ────────────────────────────────
|
||||
|
||||
println!("cargo:rustc-env=ARTIFACTCS_PATH={}", artifact_dir.display());
|
||||
println!("cargo:rustc-env=CSV_RECORD_PATH={}", csv_dir.display());
|
||||
println!("cargo:rustc-env=ONNXRUNTIME_PATH={}", onnxruntime_dir.display());
|
||||
@ -43,11 +41,9 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed={}", frontend_dir.join(item).display());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
if env::var_os("SKIP_EBPF_BUILD").is_some() {
|
||||
let out = PathBuf::from(env::var_os("OUT_DIR").unwrap());
|
||||
for name in &["net-guardia-ingress", "net-guardia-egress"] {
|
||||
for name in &["mantis-ingress", "mantis-egress"] {
|
||||
let path = out.join(name);
|
||||
if !path.exists() {
|
||||
fs::write(&path, []).unwrap_or_else(|e| panic!("cannot write stub {path:?}: {e}"));
|
||||
@ -79,104 +75,91 @@ fn build_ingress_ebpf() {
|
||||
panic!("unsupported endian={:?}", endian)
|
||||
};
|
||||
|
||||
let build_ebpf = true;
|
||||
if build_ebpf {
|
||||
let arch = env::var_os("CARGO_CFG_TARGET_ARCH").unwrap();
|
||||
let arch = env::var_os("CARGO_CFG_TARGET_ARCH").unwrap();
|
||||
let target = format!("{target}-unknown-none");
|
||||
|
||||
let target = format!("{target}-unknown-none");
|
||||
let Package { manifest_path, .. } = ebpf_package;
|
||||
let ebpf_dir = manifest_path.parent().unwrap();
|
||||
|
||||
let Package { manifest_path, .. } = ebpf_package;
|
||||
let ebpf_dir = manifest_path.parent().unwrap();
|
||||
println!("cargo:rerun-if-changed={}", ebpf_dir.as_str());
|
||||
|
||||
println!("cargo:rerun-if-changed={}", ebpf_dir.as_str());
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.args([
|
||||
"build",
|
||||
"-Z",
|
||||
"build-std=core",
|
||||
"--bins",
|
||||
"--message-format=json",
|
||||
"--release",
|
||||
"--target",
|
||||
&target,
|
||||
]);
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.args([
|
||||
"build",
|
||||
"-Z",
|
||||
"build-std=core",
|
||||
"--bins",
|
||||
"--message-format=json",
|
||||
"--release",
|
||||
"--target",
|
||||
&target,
|
||||
]);
|
||||
cmd.env("CARGO_CFG_BPF_TARGET_ARCH", arch);
|
||||
|
||||
cmd.env("CARGO_CFG_BPF_TARGET_ARCH", arch);
|
||||
for key in ["RUSTUP_TOOLCHAIN", "RUSTC", "RUSTC_WORKSPACE_WRAPPER"] {
|
||||
cmd.env_remove(key);
|
||||
}
|
||||
cmd.current_dir(ebpf_dir);
|
||||
|
||||
for key in ["RUSTUP_TOOLCHAIN", "RUSTC", "RUSTC_WORKSPACE_WRAPPER"] {
|
||||
cmd.env_remove(key);
|
||||
let ebpf_target_dir = out_dir.join("../ingress-ebpf");
|
||||
cmd.arg("--target-dir").arg(&ebpf_target_dir);
|
||||
|
||||
let mut child = cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap_or_else(|err| panic!("failed to spawn {cmd:?}: {err}"));
|
||||
let Child { stdout, stderr, .. } = &mut child;
|
||||
|
||||
let stderr = stderr.take().unwrap();
|
||||
let stderr = BufReader::new(stderr);
|
||||
let stderr = std::thread::spawn(move || {
|
||||
for line in stderr.lines() {
|
||||
let line = line.unwrap();
|
||||
println!("{line}");
|
||||
}
|
||||
cmd.current_dir(ebpf_dir);
|
||||
});
|
||||
|
||||
let ebpf_target_dir = out_dir.join("../ingress-ebpf");
|
||||
cmd.arg("--target-dir").arg(&ebpf_target_dir);
|
||||
|
||||
let mut child = cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap_or_else(|err| panic!("failed to spawn {cmd:?}: {err}"));
|
||||
let Child { stdout, stderr, .. } = &mut child;
|
||||
|
||||
let stderr = stderr.take().unwrap();
|
||||
let stderr = BufReader::new(stderr);
|
||||
let stderr = std::thread::spawn(move || {
|
||||
for line in stderr.lines() {
|
||||
let line = line.unwrap();
|
||||
println!("{line}");
|
||||
let stdout = stdout.take().unwrap();
|
||||
let stdout = BufReader::new(stdout);
|
||||
let mut executables = Vec::new();
|
||||
for message in Message::parse_stream(stdout) {
|
||||
#[allow(clippy::collapsible_match)]
|
||||
match message.expect("valid JSON") {
|
||||
Message::CompilerArtifact(Artifact {
|
||||
executable,
|
||||
target: Target { name, .. },
|
||||
..
|
||||
}) => {
|
||||
if let Some(executable) = executable {
|
||||
executables.push((name, executable.into_std_path_buf()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stdout = stdout.take().unwrap();
|
||||
let stdout = BufReader::new(stdout);
|
||||
let mut executables = Vec::new();
|
||||
for message in Message::parse_stream(stdout) {
|
||||
#[allow(clippy::collapsible_match)]
|
||||
match message.expect("valid JSON") {
|
||||
Message::CompilerArtifact(Artifact {
|
||||
executable,
|
||||
target: Target { name, .. },
|
||||
..
|
||||
}) => {
|
||||
if let Some(executable) = executable {
|
||||
executables.push((name, executable.into_std_path_buf()));
|
||||
}
|
||||
}
|
||||
Message::CompilerMessage(CompilerMessage { message, .. }) => {
|
||||
for line in message.rendered.unwrap_or_default().split('\n') {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
Message::TextLine(line) => {
|
||||
Message::CompilerMessage(CompilerMessage { message, .. }) => {
|
||||
for line in message.rendered.unwrap_or_default().split('\n') {
|
||||
println!("{line}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.unwrap_or_else(|err| panic!("failed to wait for {cmd:?}: {err}"));
|
||||
assert_eq!(status.code(), Some(0), "{cmd:?} failed: {status:?}");
|
||||
|
||||
stderr.join().map_err(std::panic::resume_unwind).unwrap();
|
||||
|
||||
for (name, binary) in executables {
|
||||
let dst = out_dir.join(name);
|
||||
let _: u64 =
|
||||
fs::copy(&binary, &dst).unwrap_or_else(|err| panic!("failed to copy {binary:?} to {dst:?}: {err}"));
|
||||
}
|
||||
} else {
|
||||
let Package { targets, .. } = ebpf_package;
|
||||
for Target { name, kind, .. } in targets {
|
||||
if *kind != [TargetKind::Bin] {
|
||||
continue;
|
||||
Message::TextLine(line) => {
|
||||
println!("{line}");
|
||||
}
|
||||
let dst = out_dir.join(name);
|
||||
fs::write(&dst, []).unwrap_or_else(|err| panic!("failed to create {dst:?}: {err}"));
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.unwrap_or_else(|err| panic!("failed to wait for {cmd:?}: {err}"));
|
||||
assert_eq!(status.code(), Some(0), "{cmd:?} failed: {status:?}");
|
||||
|
||||
stderr.join().map_err(std::panic::resume_unwind).unwrap();
|
||||
|
||||
for (name, binary) in executables {
|
||||
let dst = out_dir.join(name);
|
||||
let _: u64 =
|
||||
fs::copy(&binary, &dst).unwrap_or_else(|err| panic!("failed to copy {binary:?} to {dst:?}: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn build_egress_ebpf() {
|
||||
@ -198,105 +181,92 @@ fn build_egress_ebpf() {
|
||||
panic!("unsupported endian={:?}", endian)
|
||||
};
|
||||
|
||||
let build_ebpf = true;
|
||||
if build_ebpf {
|
||||
let arch = env::var_os("CARGO_CFG_TARGET_ARCH").unwrap();
|
||||
let arch = env::var_os("CARGO_CFG_TARGET_ARCH").unwrap();
|
||||
let target = format!("{target}-unknown-none");
|
||||
|
||||
let target = format!("{target}-unknown-none");
|
||||
let Package { manifest_path, .. } = ebpf_package;
|
||||
let ebpf_dir = manifest_path.parent().unwrap();
|
||||
|
||||
let Package { manifest_path, .. } = ebpf_package;
|
||||
let ebpf_dir = manifest_path.parent().unwrap();
|
||||
println!("cargo:rerun-if-changed={}", ebpf_dir.as_str());
|
||||
|
||||
println!("cargo:rerun-if-changed={}", ebpf_dir.as_str());
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.args([
|
||||
"build",
|
||||
"-Z",
|
||||
"build-std=core",
|
||||
"--bins",
|
||||
"--message-format=json",
|
||||
"--release",
|
||||
"--target",
|
||||
&target,
|
||||
]);
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.args([
|
||||
"build",
|
||||
"-Z",
|
||||
"build-std=core",
|
||||
"--bins",
|
||||
"--message-format=json",
|
||||
"--release",
|
||||
"--target",
|
||||
&target,
|
||||
]);
|
||||
cmd.env("CARGO_CFG_BPF_TARGET_ARCH", arch);
|
||||
cmd.env("CARGO_TERM_COLOR", "always");
|
||||
|
||||
cmd.env("CARGO_CFG_BPF_TARGET_ARCH", arch);
|
||||
cmd.env("CARGO_TERM_COLOR", "always");
|
||||
for key in ["RUSTUP_TOOLCHAIN", "RUSTC", "RUSTC_WORKSPACE_WRAPPER"] {
|
||||
cmd.env_remove(key);
|
||||
}
|
||||
cmd.current_dir(ebpf_dir);
|
||||
|
||||
for key in ["RUSTUP_TOOLCHAIN", "RUSTC", "RUSTC_WORKSPACE_WRAPPER"] {
|
||||
cmd.env_remove(key);
|
||||
let ebpf_target_dir = out_dir.join("../egress-ebpf");
|
||||
cmd.arg("--target-dir").arg(&ebpf_target_dir);
|
||||
|
||||
let mut child = cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap_or_else(|err| panic!("failed to spawn {cmd:?}: {err}"));
|
||||
let Child { stdout, stderr, .. } = &mut child;
|
||||
|
||||
let stderr = stderr.take().unwrap();
|
||||
let stderr = BufReader::new(stderr);
|
||||
let stderr = std::thread::spawn(move || {
|
||||
for line in stderr.lines() {
|
||||
let line = line.unwrap();
|
||||
println!("{line}");
|
||||
}
|
||||
cmd.current_dir(ebpf_dir);
|
||||
});
|
||||
|
||||
let ebpf_target_dir = out_dir.join("../egress-ebpf");
|
||||
cmd.arg("--target-dir").arg(&ebpf_target_dir);
|
||||
|
||||
let mut child = cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap_or_else(|err| panic!("failed to spawn {cmd:?}: {err}"));
|
||||
let Child { stdout, stderr, .. } = &mut child;
|
||||
|
||||
let stderr = stderr.take().unwrap();
|
||||
let stderr = BufReader::new(stderr);
|
||||
let stderr = std::thread::spawn(move || {
|
||||
for line in stderr.lines() {
|
||||
let line = line.unwrap();
|
||||
println!("{line}");
|
||||
let stdout = stdout.take().unwrap();
|
||||
let stdout = BufReader::new(stdout);
|
||||
let mut executables = Vec::new();
|
||||
for message in Message::parse_stream(stdout) {
|
||||
#[allow(clippy::collapsible_match)]
|
||||
match message.expect("valid JSON") {
|
||||
Message::CompilerArtifact(Artifact {
|
||||
executable,
|
||||
target: Target { name, .. },
|
||||
..
|
||||
}) => {
|
||||
if let Some(executable) = executable {
|
||||
executables.push((name, executable.into_std_path_buf()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stdout = stdout.take().unwrap();
|
||||
let stdout = BufReader::new(stdout);
|
||||
let mut executables = Vec::new();
|
||||
for message in Message::parse_stream(stdout) {
|
||||
#[allow(clippy::collapsible_match)]
|
||||
match message.expect("valid JSON") {
|
||||
Message::CompilerArtifact(Artifact {
|
||||
executable,
|
||||
target: Target { name, .. },
|
||||
..
|
||||
}) => {
|
||||
if let Some(executable) = executable {
|
||||
executables.push((name, executable.into_std_path_buf()));
|
||||
}
|
||||
}
|
||||
Message::CompilerMessage(CompilerMessage { message, .. }) => {
|
||||
for line in message.rendered.unwrap_or_default().split('\n') {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
Message::TextLine(line) => {
|
||||
Message::CompilerMessage(CompilerMessage { message, .. }) => {
|
||||
for line in message.rendered.unwrap_or_default().split('\n') {
|
||||
println!("{line}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.unwrap_or_else(|err| panic!("failed to wait for {cmd:?}: {err}"));
|
||||
assert_eq!(status.code(), Some(0), "{cmd:?} failed: {status:?}");
|
||||
|
||||
stderr.join().map_err(std::panic::resume_unwind).unwrap();
|
||||
|
||||
for (name, binary) in executables {
|
||||
let dst = out_dir.join(name);
|
||||
let _: u64 =
|
||||
fs::copy(&binary, &dst).unwrap_or_else(|err| panic!("failed to copy {binary:?} to {dst:?}: {err}"));
|
||||
}
|
||||
} else {
|
||||
let Package { targets, .. } = ebpf_package;
|
||||
for Target { name, kind, .. } in targets {
|
||||
if *kind != [TargetKind::Bin] {
|
||||
continue;
|
||||
Message::TextLine(line) => {
|
||||
println!("{line}");
|
||||
}
|
||||
let dst = out_dir.join(name);
|
||||
fs::write(&dst, []).unwrap_or_else(|err| panic!("failed to create {dst:?}: {err}"));
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.unwrap_or_else(|err| panic!("failed to wait for {cmd:?}: {err}"));
|
||||
assert_eq!(status.code(), Some(0), "{cmd:?} failed: {status:?}");
|
||||
|
||||
stderr.join().map_err(std::panic::resume_unwind).unwrap();
|
||||
|
||||
for (name, binary) in executables {
|
||||
let dst = out_dir.join(name);
|
||||
let _: u64 =
|
||||
fs::copy(&binary, &dst).unwrap_or_else(|err| panic!("failed to copy {binary:?} to {dst:?}: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn build_frontend(frontend_dir: &PathBuf, static_dir: &PathBuf) {
|
||||
@ -433,4 +403,3 @@ fn copy_dir_all(src: &PathBuf, dst: &PathBuf) -> std::io::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ pub struct GeoIpService {
|
||||
|
||||
impl GeoIpService {
|
||||
pub fn new(db_name: &str) -> Result<Self, MaxMindDbError> {
|
||||
let db_path = PathBuf::from("net-guardia/static/geo").join(db_name);
|
||||
let db_path = PathBuf::from("mantis/static/geo").join(db_name);
|
||||
Self::with_cache_size(db_path, 10000)
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// net-guardia/src/core/ebpf/health.rs
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@ -29,7 +28,6 @@ pub struct SystemHealth {
|
||||
broadcast_tx: broadcast::Sender<SystemHealthMetrics>,
|
||||
ingress_interface: String,
|
||||
egress_interface: String,
|
||||
// management_interface: String,
|
||||
}
|
||||
|
||||
|
||||
@ -44,7 +42,6 @@ impl SystemHealth {
|
||||
broadcast_tx,
|
||||
ingress_interface: config.ingress_ifname.clone(),
|
||||
egress_interface: config.egress_ifname.clone(),
|
||||
// management_interface: config.management_ifindex.clone(),
|
||||
};
|
||||
|
||||
Ok(health)
|
||||
@ -88,7 +85,6 @@ impl SystemHealth {
|
||||
&components,
|
||||
&self.ingress_interface,
|
||||
&self.egress_interface,
|
||||
// &self.management_interface,
|
||||
);
|
||||
|
||||
drop(system);
|
||||
@ -108,7 +104,6 @@ impl SystemHealth {
|
||||
components: &Components,
|
||||
ingress_interface: &str,
|
||||
egress_interface: &str,
|
||||
// management_interface: &str,
|
||||
) -> SystemHealthMetrics {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@ -134,7 +129,6 @@ impl SystemHealth {
|
||||
networks,
|
||||
ingress_interface,
|
||||
egress_interface,
|
||||
// management_interface,
|
||||
);
|
||||
|
||||
let load_average = System::load_average();
|
||||
@ -218,7 +212,6 @@ impl SystemHealth {
|
||||
networks: &Networks,
|
||||
ingress_interface: &str,
|
||||
egress_interface: &str,
|
||||
// management_interface: &str,
|
||||
) -> ConfiguredNetworkStats {
|
||||
let create_network_stats = |interface_name: &str| -> Option<NetworkStats> {
|
||||
networks.get(interface_name).map(|network| NetworkStats {
|
||||
@ -234,7 +227,6 @@ impl SystemHealth {
|
||||
|
||||
let ingress = create_network_stats(ingress_interface);
|
||||
let egress = create_network_stats(egress_interface);
|
||||
// let management = create_network_stats(management_interface);
|
||||
|
||||
if ingress.is_none() {
|
||||
log!(Health::InterfaceNotFound("Ingress".to_string(), ingress_interface.to_string()));
|
||||
@ -242,14 +234,10 @@ impl SystemHealth {
|
||||
if egress.is_none() {
|
||||
log!(Health::InterfaceNotFound("Egress".to_string(), egress_interface.to_string()));
|
||||
}
|
||||
// if management.is_none() {
|
||||
// warn!("Management interface '{}' not found", management_interface);
|
||||
// }
|
||||
|
||||
ConfiguredNetworkStats {
|
||||
ingress,
|
||||
egress,
|
||||
// management,
|
||||
}
|
||||
}
|
||||
|
||||
@ -268,7 +256,6 @@ impl SystemHealth {
|
||||
&components,
|
||||
&self.ingress_interface,
|
||||
&self.egress_interface,
|
||||
// &self.management_interface,
|
||||
)
|
||||
}
|
||||
|
||||
@ -330,11 +317,6 @@ impl SystemHealth {
|
||||
status.overall_healthy = false;
|
||||
status.issues.push("Egress interface not available".to_string());
|
||||
}
|
||||
// if metrics.network_stats.management.is_none() {
|
||||
// status
|
||||
// .warnings
|
||||
// .push("Management interface not available".to_string());
|
||||
// }
|
||||
|
||||
status
|
||||
}
|
||||
@ -1,3 +1,3 @@
|
||||
pub mod ebpf;
|
||||
pub mod infrastructure;
|
||||
pub mod system;
|
||||
pub mod ebpf;
|
||||
pub mod infrastructure;
|
||||
pub mod system;
|
||||
@ -112,13 +112,13 @@ impl System {
|
||||
Self::set_memory_limit()?;
|
||||
let ingress_xdp: &mut Xdp = self
|
||||
.ingress_ebpf
|
||||
.program_mut("net_guardia")
|
||||
.program_mut("mantis")
|
||||
.ok_or(EbpfError::ProgramNotFound)?
|
||||
.try_into()
|
||||
.map_err(EbpfError::GetProgramFailed)?;
|
||||
let egress_xdp: &mut Xdp = self
|
||||
.egress_ebpf
|
||||
.program_mut("net_guardia")
|
||||
.program_mut("mantis")
|
||||
.ok_or(EbpfError::ProgramNotFound)?
|
||||
.try_into()
|
||||
.map_err(EbpfError::GetProgramFailed)?;
|
||||
@ -174,7 +174,7 @@ impl System {
|
||||
fn get_ingress_ebpf() -> Result<(Ebpf, ProgramArray<MapData>), Error> {
|
||||
let mut ingress_ebpf = Ebpf::load(aya::include_bytes_aligned!(concat!(
|
||||
env!("OUT_DIR"),
|
||||
"/net-guardia-ingress"
|
||||
"/mantis-ingress"
|
||||
)))
|
||||
.map_err(EbpfError::EbpfNotFound)?;
|
||||
let program_array = ingress_ebpf.take_map("PROGRAM_ARRAY").ok_or(EbpfError::MapNotFound)?;
|
||||
@ -199,7 +199,7 @@ impl System {
|
||||
fn get_egress_ebpf() -> Result<(Ebpf, ProgramArray<MapData>), Error> {
|
||||
let mut egress_ebpf = Ebpf::load(aya::include_bytes_aligned!(concat!(
|
||||
env!("OUT_DIR"),
|
||||
"/net-guardia-egress"
|
||||
"/mantis-egress"
|
||||
)))
|
||||
.map_err(EbpfError::EbpfNotFound)?;
|
||||
let program_array = egress_ebpf.take_map("PROGRAM_ARRAY").ok_or(EbpfError::MapNotFound)?;
|
||||
@ -48,10 +48,6 @@ impl AttackAggregator {
|
||||
false
|
||||
}
|
||||
|
||||
/// L2 aggregation: track anomalous events per src_ip regardless of src_port.
|
||||
/// Returns "FLOOD" when total anomalous flows exceed the threshold, or "SCAN"
|
||||
/// when the number of distinct dst_ports exceeds the scan threshold.
|
||||
/// A per-src_ip cooldown equal to the window duration prevents alert storms.
|
||||
pub fn should_alert_src_ip(&mut self, src_ip: &str, dst_port: u16) -> Option<&'static str> {
|
||||
let now = Instant::now();
|
||||
let window = self.window_duration;
|
||||
@ -222,8 +222,6 @@ impl FlowData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 每個 thread 獨立擁有,不共享,無鎖。
|
||||
/// RSS 保證同一條 flow 永遠落在同一個 queue。
|
||||
pub struct FlowTracker {
|
||||
flows: HashMap<FlowKey, FlowData>,
|
||||
max_flows: usize,
|
||||
@ -308,8 +306,6 @@ impl FlowTracker {
|
||||
.map(|d| d.as_micros() as u64)
|
||||
.unwrap_or(0);
|
||||
self.flows.retain(|_, flow| {
|
||||
// Remove flows that have completed TCP teardown (both FIN or RST)
|
||||
// or have exceeded the idle timeout
|
||||
!flow.is_finished()
|
||||
&& now.saturating_sub(flow.last_time_us) < max_age_us
|
||||
});
|
||||
@ -16,7 +16,6 @@ use super::output;
|
||||
const MIRROR_IFACE: &str = "ng-mirror";
|
||||
const MIRROR_PEER: &str = "ng-mirror-peer";
|
||||
|
||||
/* Channel capacity matches the old libsuricata engine. */
|
||||
const CHANNEL_CAP: usize = 4096;
|
||||
|
||||
pub struct SuricataEngine {
|
||||
@ -48,7 +47,6 @@ impl SuricataEngine {
|
||||
|
||||
let (tx, rx) = bounded::<Vec<u8>>(CHANNEL_CAP);
|
||||
|
||||
/* Mirror worker: reads packet data from channel, sends via raw socket. */
|
||||
thread::Builder::new()
|
||||
.name("suricata-mirror".into())
|
||||
.spawn(move || {
|
||||
@ -102,7 +100,6 @@ impl SuricataEngine {
|
||||
}
|
||||
|
||||
fn setup_veth() -> Result<(), SuricataError> {
|
||||
/* Remove stale pair if it exists. */
|
||||
let _ = Command::new("ip").args(["link", "del", MIRROR_IFACE]).output();
|
||||
|
||||
Command::new("ip")
|
||||
@ -11,8 +11,6 @@ use crate::detection::fusion::FusionEngine;
|
||||
use crate::model::log::suricata::SuricataLog;
|
||||
use crate::model::rule_detection::RuleMatch;
|
||||
|
||||
/* ── EVE JSON structs (alert subset) ─────────────────────────────────────── */
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EveEvent {
|
||||
event_type: String,
|
||||
@ -30,8 +28,6 @@ struct EveAlert {
|
||||
signature: String,
|
||||
}
|
||||
|
||||
/* ── Unix socket EVE reader ──────────────────────────────────────────────── */
|
||||
|
||||
pub fn start_eve_reader(socket_path: &str, fusion: Arc<FusionEngine>) {
|
||||
let path = socket_path.to_owned();
|
||||
|
||||
@ -31,7 +31,7 @@ pub struct Config {
|
||||
pub traffic_logging_mode: bool,
|
||||
pub traffic_log_csv_path: String,
|
||||
/// Optional path to an NSS key log file (SSLKEYLOGFILE) for TLS decryption.
|
||||
/// Only useful in [external]->NetGuardia->[internal] deployments where the
|
||||
/// Only useful in [external]->Mantis->[internal] deployments where the
|
||||
/// internal server can be configured to write TLS session keys.
|
||||
pub tls_keylog_path: Option<String>,
|
||||
/// CPU core range [start, end] (inclusive) for XSK packet threads.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user