feat: revert main

This commit is contained in:
ParrotXray 2026-07-08 12:03:59 +08:00
commit a58a150986
Signed by: ParrotXray
SSH Key Fingerprint: SHA256:OEnKoo72UOfrmZ3LVSBn9K/UfpEzNrl+q8JMLG9CaAI
49 changed files with 14182 additions and 0 deletions

View File

@ -0,0 +1,15 @@
Research the Mantis frontend codebase to answer the following question or locate the relevant code for the given topic:
$ARGUMENTS
Follow this approach:
1. Start from `src/config.ts` if the question involves an API endpoint or WebSocket URL.
2. Start from `src/providers/WebSocketProvider.tsx` if the question involves real-time data flow or stream subscriptions.
3. Start from `src/providers/AccessControlProvider.tsx` if the question involves IP access control data or caching.
4. Start from the relevant page in `src/pages/` if the question is about a specific UI feature.
5. Check `src/types/` for the shape of data structures used by that page.
After locating the relevant code, provide:
- The file(s) and line numbers that are most relevant.
- A concise explanation of how the code works, including any non-obvious patterns (e.g., the `isPausedRef` pattern that prevents WS re-subscription on pause toggle, the eagerly-opened WS pool in WebSocketProvider, or the bootTime-based timestamp calculation).
- Any other files that interact with the code you found.

1
.gitattributes vendored Normal file
View File

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

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# 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?
tsconfig.tsbuildinfo

57
CLAUDE.md Normal file
View File

@ -0,0 +1,57 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run dev # HTTP dev server (Next.js fast refresh)
npm run build # Static export build (outputs to /out)
npm run start # Serve the static export
npm run lint # ESLint
npm run dev:https # HTTPS dev via custom server.js (requires ssl/ certs)
npm run start:https # HTTPS production via custom server.js
```
No test suite exists in this project.
For HTTPS modes, SSL certificates must exist at `ssl/root.ca-bundle`, `ssl/server.crt`, and `ssl/server.key`.
## Architecture
### Backend connection
All backend endpoints are configured in `src/config.ts`. The target host defaults to `192.168.1.3:8080`. Change `hostname` and `port` there to point at a different backend. All page-level API/WS URLs are derived from that single file.
### Data flow: WebSockets + RxJS
`WebSocketProvider` (`src/providers/WebSocketProvider.tsx`) is the central hub. On mount it eagerly opens **all** WebSocket connections at once — 24 flow-stats streams (2 IP versions × 2 directions × 2 flow types × 3 time ranges) plus one system-health stream and one detection-alert stream. Each connection is wrapped in a RxJS `BehaviorSubject` and exposed as a shared observable via `getIPv4FlowStream()`, `getIPv6FlowStream()`, `getDetectionAlertStream()`, and `getSystemHealthStream()`.
Pages subscribe to the relevant streams using RxJS operators (`combineLatest`, `map`, `catchError`). Closing the provider unsubscribes observables but intentionally **does not close** the underlying WebSocket connections (they are shared).
`bootTime` — fetched once via HTTP at startup and held in `WebSocketProvider` — is required for interpreting flow timestamps, which are nanoseconds since system boot. Pages read it via `useContext(WebsocketContext).bootTime`.
The `isPausedRef` pattern appears throughout: a `useRef` mirrors the `isPaused` state so that WS subscription callbacks can read the current pause state without the subscription itself being torn down and re-created on every pause toggle.
### Providers tree
```
ErrorBoundary
ThemeProvider → actualTheme ('light'|'dark'), persisted to localStorage
WebSocketProvider → all WS streams + bootTime
AccessControlProvider → cached fetch layer for IP allow/block lists
<Page>
```
### AccessControlProvider caching
`AccessControlProvider` (`src/providers/AccessControlProvider.tsx`) caches REST responses for the IP access-control lists with a 10-minute TTL. It deduplicates concurrent requests for the same key using a `pendingRequests` ref. The cache is keyed as `ipv4_black_list`, `ipv4_white_list`, `ipv6_black_list`, `ipv6_white_list`. IPv4 blacklist is preloaded on mount. Expired entries are purged every 5 minutes (entries older than 30 minutes are dropped).
### Static export caveat
`next.config.js` sets `output: 'export'`, meaning `npm run build` produces a fully static site in `/out`. Next.js API routes are therefore not available — all data comes from the external backend at the configured host.
### Styling
Tailwind CSS v4. Dark/light mode is managed entirely through `ThemeProvider`; components read `isDark = actualTheme === 'dark'` and apply conditional class strings inline. There are no CSS modules.

101
README.md Normal file
View File

@ -0,0 +1,101 @@
# Mantis Frontend
Next.js web UI for the [Mantis](https://github.com/ParrotXray/Mantis) network intrusion detection system.
Displays live eBPF traffic statistics, ML anomaly alerts, access control lists, and system logs via WebSocket streams from the Mantis backend.
## Requirements
- Node.js 18+
- Mantis backend running at the configured host (default `192.168.1.3:8080`)
## Getting Started
```bash
npm install
npm run dev # development server with fast refresh (HTTP)
```
Open `http://localhost:3000` in a browser.
## Commands
| Command | Description |
|---------|-------------|
| `npm run dev` | HTTP dev server with Next.js fast refresh |
| `npm run build` | Static export to `/out` |
| `npm run start` | Serve the static export |
| `npm run lint` | ESLint |
| `npm run dev:https` | HTTPS dev via custom server (requires SSL certs) |
| `npm run start:https` | HTTPS production via custom server |
For HTTPS modes, place certificates at `ssl/root.ca-bundle`, `ssl/server.crt`, and `ssl/server.key`.
## Configuration
All backend endpoints are defined in [`src/config.ts`](src/config.ts). Change `hostname` and `port` there to point at a different backend — every page derives its API and WebSocket URLs from that single file.
```ts
const hostname = "192.168.1.3";
const port = 8080;
```
## Pages
| Route | Description |
|-------|-------------|
| `/setup` | First-boot account creation (shown when no accounts exist) |
| `/login` | Authentication |
| `/` | Home / overview |
| `/dashboard` | System health metrics |
| `/statistics` | Live IPv4/IPv6 flow statistics |
| `/traffic-map` | Geographic traffic map |
| `/access-control` | eBPF IP allow/block lists (ingress & egress) |
| `/detection` | ML anomaly and Suricata rule alerts |
| `/logs` | Live system log stream |
## Architecture
### Provider Tree
```
ErrorBoundary
ThemeProvider light/dark/system, persisted to localStorage
AuthProvider JWT auth state, RouteGuard (redirects to /setup or /login)
WebSocketProvider all WS streams + bootTime
AccessControlProvider cached REST layer for IP lists
<Page>
```
### Authentication
On startup `AuthProvider` calls `GET /auth/status` to check whether any account exists:
- No accounts → redirect to `/setup`
- Accounts exist but no local token → redirect to `/login`
- Valid token → proceed normally
All fetch calls inject the Bearer token via `src/utils/authStore.ts` singleton (`getAuthHeaders()`).
### WebSocket Data Flow
`WebSocketProvider` eagerly opens all connections on mount:
- 24 flow-stats streams (2 IP versions × 2 directions × 2 flow types × 3 time ranges)
- System health stream
- Detection alert stream
Each stream is a RxJS `BehaviorSubject`. Pages subscribe via `getIPv4FlowStream()`, `getIPv6FlowStream()`, `getDetectionAlertStream()`, `getSystemHealthStream()`.
Flow timestamps are nanoseconds since boot; `bootTime` (fetched once via `GET /misc/boot_time`) is required to convert them to wall-clock time and is available via `useContext(WebsocketContext).bootTime`.
The `logs` page manages its own WebSocket connection to `GET /logs/websocket` independently of `WebSocketProvider`.
### Access Control Caching
`AccessControlProvider` caches IP list responses with a 10-minute TTL. Cache keys follow the pattern `{nic}_{ipVersion}_{flow}_{listType}` (e.g. `ingress_ipv4_source_black_list`). Concurrent requests for the same key are deduplicated. Stale entries (>30 min) are purged every 5 minutes.
### Static Export
`next.config.js` sets `output: 'export'`, so `npm run build` produces a fully static site in `/out`. Next.js API routes are not available — all data comes from the external Mantis backend.
### Styling
Tailwind CSS v4. Dark/light mode is managed by `ThemeProvider`; components read `isDark = actualTheme === 'dark'` and apply conditional class strings inline. No CSS modules are used. The sidebar uses a fixed dark-teal palette (`#0a2330`) regardless of theme.

6
next-env.d.ts vendored Normal file
View 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
next.config.js Normal file
View 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
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "mantis-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
postcss.config.js Normal file
View File

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

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/mantis-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

104
server.js Normal file
View 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);
});

View 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

49
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,49 @@
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 = "Mantis",
description = "Mantis"
}) => {
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" />
<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="Mantis"/>
<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>
<Sidebar>
{children}
</Sidebar>
</>
)
}
export default Layout

View 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-[#4ab5cc]"></div>
</div>
)
}
export default LoadingSpinner

467
src/components/Sidebar.tsx Normal file
View File

@ -0,0 +1,467 @@
import React, { useState, useCallback, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { motion, AnimatePresence } from 'framer-motion'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faTachometerAlt,
faChartBar,
faShieldAlt,
faRobot,
faBars,
faHome,
faMapMarkedAlt,
faSignOutAlt,
faUser,
faSun,
faMoon,
faDesktop,
faChevronUp,
faScroll,
faClock,
faServer,
} from '@fortawesome/free-solid-svg-icons'
import { useTheme } from '../providers/ThemeProvider'
import { useAuth } from '../contexts/AuthContext'
import { useContext } from 'react'
import { WebsocketContext } from '../providers/WebSocketProvider'
type ThemeValue = 'light' | 'dark' | 'system'
const THEME_OPTIONS: { value: ThemeValue; icon: any; label: string }[] = [
{ value: 'light', icon: faSun, label: 'Light' },
{ value: 'dark', icon: faMoon, label: 'Dark' },
{ value: 'system', icon: faDesktop, label: 'System' },
]
const NAV_GROUPS = [
{
label: 'OVERVIEW',
items: [
{ 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' },
],
},
{
label: 'SECURITY',
items: [
{ href: '/access-control', icon: faShieldAlt, label: 'Access Control' },
{ href: '/detection', icon: faRobot, label: 'Detection' },
{ href: '/logs', icon: faScroll, label: 'Logs' },
],
},
{
label: 'TRAINING',
items: [
{ href: '/training', icon: faServer, label: 'Training' },
],
},
]
const PAGE_META: Record<string, { title: string; icon: any }> = {
'/': { title: 'System Overview', icon: faHome },
'/dashboard': { title: 'Network Dashboard', icon: faTachometerAlt },
'/statistics': { title: 'Traffic Statistics', icon: faChartBar },
'/traffic-map': { title: 'Traffic Map', icon: faMapMarkedAlt },
'/access-control': { title: 'Access Control', icon: faShieldAlt },
'/detection': { title: 'Threat Detection', icon: faRobot },
'/logs': { title: 'System Logs', icon: faScroll },
'/training': { title: 'Training Nodes', icon: faServer },
}
// Logo palette
// Sidebar bg: #0c2130 (dark teal-navy, matches logo circle bg)
// Accent: #4ab5cc (steel teal-blue, matches logo shield)
// Accent hover: rgba(74,181,204,0.15)
// Separator: rgba(74,181,204,0.12)
const ACCENT = '#4ab5cc'
const ACCENT_DIM = 'rgba(74,181,204,0.15)'
const SIDEBAR_BG = '#0c2130'
const SIDEBAR_CARD = '#0a1c28'
interface SidebarProps {
children: React.ReactNode
}
function useClock() {
const [time, setTime] = useState(() => new Date())
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000)
return () => clearInterval(id)
}, [])
return time
}
export const Sidebar: React.FC<SidebarProps> = ({ children }) => {
const router = useRouter()
const { actualTheme, theme, setTheme } = useTheme()
const { user, logout } = useAuth()
const { wsConnectedCount } = useContext(WebsocketContext)
const [isCollapsed, setIsCollapsed] = useState(false)
const [showThemePicker, setShowThemePicker] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [isMobileOpen, setIsMobileOpen] = useState(false)
const now = useClock()
const isDark = actualTheme === 'dark'
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 1024)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Close drawer when navigating on mobile
useEffect(() => {
if (isMobile) setIsMobileOpen(false)
}, [router.pathname, isMobile])
const isActive = (href: string) =>
href === '/' ? router.pathname === '/' : router.pathname.startsWith(href)
const handleLogout = useCallback(() => {
logout()
router.replace('/login')
}, [logout, router])
const currentTheme = THEME_OPTIONS.find(o => o.value === theme) ?? THEME_OPTIONS[1]
const pageMeta = PAGE_META[router.pathname] ?? { title: 'Mantis', icon: faHome }
const pageBg = isDark ? '#080f18' : '#f0f4f7'
const topbarBg = isDark ? '#0c1a24' : '#ffffff'
const topbarBorder = isDark ? 'rgba(74,181,204,0.12)' : '#e2e8f0'
const sidebarAnimate = isMobile
? { x: isMobileOpen ? 0 : -300, width: '240px' }
: { x: 0, width: isCollapsed ? '72px' : '240px' }
return (
<div className="flex min-h-screen" style={{ background: pageBg }}>
{/* ── Mobile backdrop ── */}
<AnimatePresence>
{isMobile && isMobileOpen && (
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setIsMobileOpen(false)}
/>
)}
</AnimatePresence>
{/* ── Sidebar ── */}
<motion.aside
className="fixed top-0 left-0 h-screen flex flex-col z-50 overflow-hidden"
style={{ background: SIDEBAR_BG }}
animate={sidebarAnimate}
transition={{ duration: 0.25, ease: 'easeInOut' }}
>
{/* Logo */}
<div
className="flex items-center justify-between px-3 h-[52px] flex-shrink-0"
style={{ borderBottom: `1px solid rgba(74,181,204,0.14)` }}
>
{!isCollapsed && (
<Link href="/" className="no-underline flex items-center gap-2.5 ml-1">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-2.5"
>
<img src="/mantis-logo.png" alt="Mantis" className="h-7 w-auto flex-shrink-0" />
<span className="text-base font-bold tracking-wide text-white">Mantis</span>
</motion.div>
</Link>
)}
{!isMobile && (
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={`w-8 h-8 flex items-center justify-center rounded-md transition-colors ${
isCollapsed ? 'mx-auto' : ''
}`}
style={{ color: 'rgba(74,181,204,0.6)' }}
onMouseEnter={e => (e.currentTarget.style.color = ACCENT)}
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(74,181,204,0.6)')}
>
<FontAwesomeIcon icon={faBars} className="text-sm" />
</button>
)}
{isMobile && (
<button
onClick={() => setIsMobileOpen(false)}
className="w-8 h-8 flex items-center justify-center rounded-md transition-colors ml-auto"
style={{ color: 'rgba(74,181,204,0.6)' }}
onMouseEnter={e => (e.currentTarget.style.color = ACCENT)}
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(74,181,204,0.6)')}
>
<FontAwesomeIcon icon={faBars} className="text-sm" />
</button>
)}
</div>
{/* Nav groups */}
<nav className="flex-1 py-2 overflow-y-auto overflow-x-hidden">
{NAV_GROUPS.map((group, gi) => (
<div key={group.label} className={gi > 0 ? 'mt-1' : ''}>
{(!isCollapsed || isMobile) ? (
<div className="px-4 pt-3 pb-1">
<span className="text-[10px] font-semibold tracking-widest select-none"
style={{ color: 'rgba(74,181,204,0.4)' }}>
{group.label}
</span>
</div>
) : (
gi > 0 && (
<div className="my-2 mx-3 h-px" style={{ background: 'rgba(74,181,204,0.1)' }} />
)
)}
<div className={`flex flex-col gap-0.5 ${(isCollapsed && !isMobile) ? 'px-2 items-center' : 'px-2'}`}>
{group.items.map((item) => {
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
title={(isCollapsed && !isMobile) ? item.label : undefined}
onClick={() => { if (isMobile) setIsMobileOpen(false) }}
className={`flex items-center no-underline transition-all duration-150 rounded-lg relative ${
(isCollapsed && !isMobile) ? 'w-10 h-10 justify-center' : 'px-3 py-2.5'
}`}
style={{
background: active ? ACCENT_DIM : 'transparent',
color: active ? ACCENT : 'rgba(148,163,184,0.7)',
}}
onMouseEnter={e => {
if (!active) {
e.currentTarget.style.background = 'rgba(74,181,204,0.07)'
e.currentTarget.style.color = '#cbd5e1'
}
}}
onMouseLeave={e => {
if (!active) {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(148,163,184,0.7)'
}
}}
>
{active && (!isCollapsed || isMobile) && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full"
style={{ background: ACCENT }} />
)}
<span className="w-5 h-5 flex items-center justify-center flex-shrink-0">
<FontAwesomeIcon icon={item.icon} className="text-sm" />
</span>
{(!isCollapsed || isMobile) && (
<motion.span
initial={{ opacity: 1 }}
animate={{ opacity: isCollapsed ? 0 : 1 }}
transition={{ duration: 0.15 }}
className="ml-2.5 text-sm font-medium whitespace-nowrap"
>
{item.label}
</motion.span>
)}
</Link>
)
})}
</div>
</div>
))}
</nav>
{/* Bottom: user + theme */}
<div className="flex-shrink-0" style={{ borderTop: `1px solid rgba(74,181,204,0.12)` }}>
{(isCollapsed && !isMobile) ? (
<div className="flex flex-col items-center py-2 gap-1">
<button
onClick={handleLogout}
title="Logout"
className="w-10 h-9 flex items-center justify-center rounded-md transition-colors"
style={{ color: 'rgba(148,163,184,0.5)' }}
onMouseEnter={e => { e.currentTarget.style.color = '#f87171'; e.currentTarget.style.background = 'rgba(239,68,68,0.1)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'rgba(148,163,184,0.5)'; e.currentTarget.style.background = 'transparent' }}
>
<FontAwesomeIcon icon={faSignOutAlt} className="text-sm" />
</button>
<button
onClick={() => {
const idx = THEME_OPTIONS.findIndex(o => o.value === theme)
setTheme(THEME_OPTIONS[(idx + 1) % THEME_OPTIONS.length].value)
}}
title={`Theme: ${currentTheme.label}`}
className="w-10 h-9 flex items-center justify-center rounded-md transition-colors"
style={{ color: 'rgba(148,163,184,0.5)' }}
onMouseEnter={e => { e.currentTarget.style.color = '#cbd5e1'; e.currentTarget.style.background = 'rgba(74,181,204,0.1)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'rgba(148,163,184,0.5)'; e.currentTarget.style.background = 'transparent' }}
>
<FontAwesomeIcon icon={currentTheme.icon} className="text-sm" />
</button>
</div>
) : (
<div className="relative">
<AnimatePresence>
{showThemePicker && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.12 }}
className="absolute bottom-full left-2 right-2 mb-1.5 rounded-xl shadow-2xl overflow-hidden z-50"
style={{ background: SIDEBAR_CARD, border: `1px solid rgba(74,181,204,0.15)` }}
>
{THEME_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => { setTheme(option.value); setShowThemePicker(false) }}
className="w-full px-4 py-2.5 flex items-center gap-3 text-sm transition-colors"
style={{
background: theme === option.value ? ACCENT_DIM : 'transparent',
color: theme === option.value ? ACCENT : 'rgba(148,163,184,0.7)',
}}
onMouseEnter={e => { if (theme !== option.value) { e.currentTarget.style.background = 'rgba(74,181,204,0.07)'; e.currentTarget.style.color = '#cbd5e1' } }}
onMouseLeave={e => { if (theme !== option.value) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(148,163,184,0.7)' } }}
>
<FontAwesomeIcon icon={option.icon} className="w-3.5" />
<span>{option.label}</span>
{theme === option.value && (
<span className="ml-auto w-1.5 h-1.5 rounded-full" style={{ background: ACCENT }} />
)}
</button>
))}
</motion.div>
)}
</AnimatePresence>
{showThemePicker && (
<div className="fixed inset-0 z-40" onClick={() => setShowThemePicker(false)} />
)}
{/* User */}
<div className="flex items-center gap-2.5 px-3 py-2.5"
style={{ borderBottom: `1px solid rgba(74,181,204,0.1)` }}>
<div className="w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: ACCENT_DIM }}>
<FontAwesomeIcon icon={faUser} className="text-xs" style={{ color: ACCENT }} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-200 truncate leading-none mb-0.5">
{user?.username ?? '—'}
</p>
<p className="text-[11px] truncate capitalize" style={{ color: 'rgba(74,181,204,0.45)' }}>
{user?.role ?? ''}
</p>
</div>
<button
onClick={handleLogout}
title="Logout"
className="w-7 h-7 flex items-center justify-center rounded-md transition-colors"
style={{ color: 'rgba(148,163,184,0.4)' }}
onMouseEnter={e => { e.currentTarget.style.color = '#f87171'; e.currentTarget.style.background = 'rgba(239,68,68,0.1)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'rgba(148,163,184,0.4)'; e.currentTarget.style.background = 'transparent' }}
>
<FontAwesomeIcon icon={faSignOutAlt} className="text-xs" />
</button>
</div>
{/* Theme */}
<button
onClick={() => setShowThemePicker(!showThemePicker)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 transition-colors"
style={{ color: 'rgba(148,163,204,0.45)' }}
onMouseEnter={e => { e.currentTarget.style.color = '#94a3b8'; e.currentTarget.style.background = 'rgba(74,181,204,0.05)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'rgba(148,163,204,0.45)'; e.currentTarget.style.background = 'transparent' }}
>
<FontAwesomeIcon icon={currentTheme.icon} className="w-4 flex-shrink-0 text-xs" />
<span className="flex-1 text-xs text-left">{currentTheme.label} Mode</span>
<motion.span
animate={{ rotate: showThemePicker ? 180 : 0 }}
transition={{ duration: 0.18 }}
>
<FontAwesomeIcon icon={faChevronUp} className="text-[10px]" />
</motion.span>
</button>
</div>
)}
</div>
</motion.aside>
{/* ── Right column ── */}
<div
className="flex-1 flex flex-col h-screen overflow-hidden"
style={{
marginLeft: isMobile ? 0 : (isCollapsed ? '72px' : '240px'),
transition: 'margin-left 0.25s ease',
}}
>
{/* Top bar */}
<header
className="sticky top-0 z-40 flex items-center justify-between px-4 lg:px-6 h-[52px] flex-shrink-0"
style={{
background: topbarBg,
borderBottom: `1px solid ${topbarBorder}`,
}}
>
<div className="flex items-center gap-2.5">
{/* Mobile hamburger */}
{isMobile && (
<button
onClick={() => setIsMobileOpen(true)}
className="w-8 h-8 flex items-center justify-center rounded-md mr-1"
style={{ color: 'rgba(74,181,204,0.7)' }}
>
<FontAwesomeIcon icon={faBars} className="text-sm" />
</button>
)}
<div className="w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: ACCENT_DIM }}>
<FontAwesomeIcon icon={pageMeta.icon} className="text-xs" style={{ color: ACCENT }} />
</div>
<h1 className={`text-sm font-semibold tracking-tight ${isDark ? 'text-slate-200' : 'text-slate-800'}`}>
{pageMeta.title}
</h1>
<span className={`hidden sm:block text-xs ${isDark ? 'text-slate-600' : 'text-slate-400'}`}>
/ Mantis NIDS
</span>
</div>
<div className="flex items-center gap-4">
{/* WS status */}
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${
wsConnectedCount > 0 ? 'bg-green-500 animate-pulse' : 'bg-red-500'
}`} />
<span className={`hidden sm:block text-xs tabular-nums ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
{wsConnectedCount > 0 ? `Connected ${wsConnectedCount}` : 'Disconnected'}
</span>
</div>
<div className={`w-px h-3 ${isDark ? 'bg-slate-700' : 'bg-slate-300'}`} />
<div className={`flex items-center gap-1.5 text-xs tabular-nums font-mono ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
<FontAwesomeIcon icon={faClock} style={{ color: ACCENT, opacity: 0.7 }} className="text-[10px]" />
{now.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
<span className={`hidden sm:block text-xs ${isDark ? 'text-slate-600' : 'text-slate-400'}`}>
{now.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: 'numeric' })}
</span>
</div>
</header>
{/* Content */}
<section className="flex-1 overflow-y-auto p-6">
{children}
</section>
</div>
</div>
)
}
export default Sidebar

View File

@ -0,0 +1,167 @@
'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]
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

70
src/config.ts Normal file
View File

@ -0,0 +1,70 @@
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 TokenKey = 'mantis_auth_token'
export type NicType = 'ingress' | 'egress'
export type FlowType = 'source' | 'destination'
export type ListType = 'white_list' | 'black_list'
export type IpVersion = 'ipv4' | 'ipv6'
export const urls = {
bootTime: `${httpProtocol}://${host}/misc/boot_time`,
auth: {
status: `${httpProtocol}://${host}/auth/status`,
register: `${httpProtocol}://${host}/auth/register`,
login: `${httpProtocol}://${host}/auth/login`,
me: `${httpProtocol}://${host}/auth/me`,
logout: `${httpProtocol}://${host}/auth/logout`,
},
access_control: (
nic: NicType,
ipVersion: IpVersion,
flow: FlowType,
listType: ListType
) => `${httpProtocol}://${host}/ebpf/access_control/${nic}/${ipVersion}/${flow}/${listType}`,
training: {
// Node registry lives on the main Mantis backend (SQLCipher app.db).
nodes: `${httpProtocol}://${host}/training/nodes`,
node: (id: string) => `${httpProtocol}://${host}/training/nodes/${id}`,
},
} as const;
// Trainer nodes are arbitrary user-registered host:port addresses, not the
// main Mantis backend, so these are built directly from the node's own
// host/port rather than the fixed `host` above. There is no auth between
// the frontend and a trainer node, so callers must NOT attach getAuthHeaders()
// (that token is for the main backend only) when hitting these URLs.
export const trainerNodeUrls = (nodeHost: string, nodePort: number) => {
const base = `${httpProtocol}://${nodeHost}:${nodePort}`;
return {
health: `${base}/health`,
datasets: `${base}/datasets`,
train: (datasetId: string) => `${base}/datasets/${datasetId}/train`,
jobs: `${base}/jobs`,
job: (jobId: string) => `${base}/jobs/${jobId}`,
} as const;
};
export const trainerNodeWsUrl = (nodeHost: string, nodePort: number, jobId: string) =>
`${websocketProtocol}://${nodeHost}:${nodePort}/jobs/${jobId}/ws`;
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`,
logs: `${websocketProtocol}://${host}/logs/websocket`,
} as const;
export const REFRESH_INTERVAL = 50000;

View File

@ -0,0 +1,192 @@
import React, { createContext, useState, useCallback, useEffect, useContext } from 'react'
import { useRouter } from 'next/router'
import { setAuthToken } from '../utils/authStore'
import { urls, TokenKey } from '../config'
export interface UserInfo {
id: string
username: string
role: string
}
interface AuthContextType {
user: UserInfo | null
token: string | null
isAuthenticated: boolean
isInitialized: boolean
isLoading: boolean
login: (username: string, password: string) => Promise<void>
register: (username: string, password: string) => Promise<void>
logout: () => void
}
export const AuthContext = createContext<AuthContextType>({
user: null,
token: null,
isAuthenticated: false,
isInitialized: true,
isLoading: true,
login: async () => {},
register: async () => {},
logout: () => {},
})
export const useAuth = () => useContext(AuthContext)
interface AuthProviderProps {
children: React.ReactNode
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<UserInfo | null>(null)
const [token, setToken] = useState<string | null>(null)
const [isInitialized, setIsInitialized] = useState(true)
const [isLoading, setIsLoading] = useState(true)
const clearAuth = useCallback(() => {
localStorage.removeItem(TokenKey)
setAuthToken(null)
setToken(null)
setUser(null)
}, [])
const applyToken = useCallback(async (t: string): Promise<boolean> => {
try {
const res = await fetch(urls.auth.me, {
headers: { Authorization: `Bearer ${t}` },
})
if (res.ok) {
const data: UserInfo = await res.json()
setAuthToken(t)
setToken(t)
setUser(data)
return true
}
return false
} catch {
return false
}
}, [])
const issueToken = useCallback(async (rawToken: string) => {
localStorage.setItem(TokenKey, rawToken)
const valid = await applyToken(rawToken)
if (!valid) throw new Error('Token validation failed')
}, [applyToken])
useEffect(() => {
const init = async () => {
try {
const statusRes = await fetch(urls.auth.status)
if (statusRes.ok) {
const { initialized } = await statusRes.json()
setIsInitialized(initialized)
if (!initialized) {
setIsLoading(false)
return
}
}
} catch {
// If status endpoint fails, assume initialized
}
const stored = localStorage.getItem(TokenKey)
if (stored) {
const valid = await applyToken(stored)
if (!valid) clearAuth()
}
setIsLoading(false)
}
init()
}, [])
const login = useCallback(async (username: string, password: string) => {
const res = await fetch(urls.auth.login, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (res.status === 501) {
setAuthToken('disabled')
setToken('disabled')
setUser({ id: 'guest', username: 'Guest', role: 'admin' })
return
}
if (!res.ok) throw new Error('Invalid credentials')
const { token: newToken } = await res.json()
await issueToken(newToken)
}, [issueToken])
const register = useCallback(async (username: string, password: string) => {
const res = await fetch(urls.auth.register, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const msg = await res.text().catch(() => 'Registration failed')
throw new Error(msg)
}
const { token: newToken } = await res.json()
setIsInitialized(true)
await issueToken(newToken)
}, [issueToken])
const logout = useCallback(() => {
if (token && token !== 'disabled') {
fetch(urls.auth.logout, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {})
}
clearAuth()
}, [token, clearAuth])
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!token,
isInitialized,
isLoading,
login,
register,
logout,
}}
>
{children}
</AuthContext.Provider>
)
}
const PUBLIC_ROUTES = ['/login', '/setup']
export const RouteGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, isInitialized, isLoading } = useAuth()
const router = useRouter()
useEffect(() => {
if (isLoading) return
if (!isInitialized && router.pathname !== '/setup') {
router.replace('/setup')
return
}
if (isInitialized && !isAuthenticated && !PUBLIC_ROUTES.includes(router.pathname)) {
router.replace('/login')
}
}, [isAuthenticated, isInitialized, isLoading, router.pathname])
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
</div>
)
}
if (!isInitialized && router.pathname !== '/setup') return null
if (isInitialized && !isAuthenticated && !PUBLIC_ROUTES.includes(router.pathname)) return null
return <>{children}</>
}

211
src/pages/404.tsx Normal file
View File

@ -0,0 +1,211 @@
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-[#4ab5cc]",
hover: "hover:bg-[#3da5bc]",
border: "border-sky-200",
hoverBorder: "hover:border-sky-300",
hoverBg: "hover:bg-sky-50",
text: "text-sky-600"
},
purple: {
bg: "bg-indigo-500",
hover: "hover:bg-indigo-600",
border: "border-indigo-200",
hoverBorder: "hover:border-indigo-300",
hoverBg: "hover:bg-indigo-50",
text: "text-indigo-600"
},
green: {
bg: "bg-emerald-500",
hover: "hover:bg-emerald-600",
border: "border-emerald-200",
hoverBorder: "hover:border-emerald-300",
hoverBg: "hover:bg-emerald-50",
text: "text-emerald-600"
},
red: {
bg: "bg-slate-500",
hover: "hover:bg-slate-600",
border: "border-slate-200",
hoverBorder: "hover:border-slate-300",
hoverBg: "hover:bg-slate-50",
text: "text-slate-600"
}
};
return colorMap[color as keyof typeof colorMap];
};
return (
<>
<Head>
<title>404 - Page Not Found | Mantis</title>
<meta name="description" content="The page you are looking for does not exist." />
</Head>
<div className="min-h-screen bg-slate-50">
<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 Mantis 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-[#4ab5cc] text-white px-8 py-4 rounded-lg font-semibold hover:bg-[#4ab5cc] transition-all duration-300 flex items-center justify-center gap-3 shadow-lg shadow-[#4ab5cc]/15"
>
<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-slate-700 text-white px-8 py-4 rounded-lg font-semibold hover:bg-slate-600 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;

26
src/pages/_app.tsx Normal file
View File

@ -0,0 +1,26 @@
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 { AuthProvider, RouteGuard } from '../contexts/AuthContext'
import ErrorDialog from '../components/ErrorDialog'
import '../styles/globals.css'
export default function App({ Component, pageProps }: AppProps) {
return (
<ErrorBoundary FallbackComponent={ErrorDialog}>
<ThemeProvider>
<AuthProvider>
<RouteGuard>
<WebSocketProvider>
<AccessControlProvider>
<Component {...pageProps} />
</AccessControlProvider>
</WebSocketProvider>
</RouteGuard>
</AuthProvider>
</ThemeProvider>
</ErrorBoundary>
)
}

View File

@ -0,0 +1,712 @@
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,
faArrowDown,
faArrowUp,
} from '@fortawesome/free-solid-svg-icons'
import { AccessControlContext } from '../providers/AccessControlProvider'
import { useTheme } from '../providers/ThemeProvider'
import { putData, deleteData } from '../utils/connectionUtils'
import { urls, NicType, FlowType, ListType } from '../config'
import Layout from '../components/Layout'
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-[#1a2236]' : '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-[#1a2236]' : '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-slate-600 text-slate-400 cursor-not-allowed' : 'bg-[#4ab5cc] text-white hover:bg-[#4ab5cc]'
}`}
>
{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-[#4ab5cc] text-white"
: `${isDark ? 'bg-[#131929] text-slate-300 hover:bg-slate-700/40' : '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-xl border p-4 ${isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'}`}
>
<div className="flex items-center justify-between">
<div>
<p className={`text-xs font-medium mb-1 ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{title}</p>
<p className={`text-2xl font-bold tabular-nums ${isDark ? 'text-white' : 'text-slate-900'}`}>{value.toLocaleString()}</p>
</div>
<div className={`${color} w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0`}>
<FontAwesomeIcon icon={icon} className="text-white" />
</div>
</div>
</motion.div>
)
}
const NIC_LABELS: Record<NicType, string> = {
ingress: 'Ingress',
egress: 'Egress',
}
const FLOW_LABELS: Record<FlowType, string> = {
source: 'Source',
destination: 'Destination',
}
const AccessControl: React.FC = () => {
const {
currentData,
filteredData,
isLoading,
setFilteredData,
switchTo,
refreshData,
getCacheStatus
} = useContext(AccessControlContext)
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
const [nic, setNic] = useState<NicType>('ingress')
const [flow, setFlow] = useState<FlowType>('source')
const [isIPv6, setIsIPv6] = useState(false)
const [listType, setListType] = useState<ListType>('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)
const showNotification = useCallback((message: string, type: 'success' | 'error' | 'warning' | 'info' = 'success') => {
setNotification({ message, type })
}, [])
const closeNotification = useCallback(() => setNotification(null), [])
useEffect(() => {
switchTo(isIPv6, listType, nic, flow)
}, [])
useEffect(() => {
if (!searchTerm.trim()) {
setFilteredData(currentData)
return
}
const lower = searchTerm.toLowerCase()
setFilteredData(currentData.filter(({ ip }) => ip.toLowerCase().includes(lower)))
}, [searchTerm, currentData, setFilteredData])
const handleNicChange = useCallback((newNic: NicType) => {
if (newNic !== nic) {
setNic(newNic)
switchTo(isIPv6, listType, newNic, flow)
}
}, [nic, isIPv6, listType, flow, switchTo])
const handleFlowChange = useCallback((newFlow: FlowType) => {
if (newFlow !== flow) {
setFlow(newFlow)
switchTo(isIPv6, listType, nic, newFlow)
}
}, [flow, isIPv6, listType, nic, switchTo])
const handleIPVersionChange = useCallback((newIsIPv6: boolean) => {
if (newIsIPv6 !== isIPv6) {
setIsIPv6(newIsIPv6)
switchTo(newIsIPv6, listType, nic, flow)
}
}, [isIPv6, listType, nic, flow, switchTo])
const handleListTypeChange = useCallback((newListType: ListType) => {
if (newListType !== listType) {
setListType(newListType)
switchTo(isIPv6, newListType, nic, flow)
}
}, [isIPv6, listType, nic, flow, switchTo])
const handleRefresh = useCallback(async () => {
try {
await refreshData(isIPv6, listType, nic, flow)
showNotification('Data updated', 'success')
} catch {
showNotification('Update failed', 'error')
}
}, [isIPv6, listType, nic, flow, refreshData, showNotification])
const handleAddItemClick = useCallback(() => {
setIsModalOpen(true)
setNewIp('')
setNewPort('')
setBlockAllPorts(false)
}, [])
const handleModalClose = useCallback(() => {
setIsModalOpen(false)
setNewIp('')
setNewPort('')
setBlockAllPorts(false)
}, [])
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])
const validateIP = useCallback((ip: string, isIPv6: boolean): boolean => {
if (isIPv6) {
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$/
return ipv6Regex.test(ip) || ip.includes('::')
}
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
if (!ipv4Regex.test(ip)) return false
return ip.split('.').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 ipVersion = isIPv6 ? 'ipv6' as const : 'ipv4' as const
const url = urls.access_control(nic, ipVersion, flow, 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, nic, flow)
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, nic, flow, 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 ipVersion = isIPv6 ? 'ipv6' as const : 'ipv4' as const
const url = urls.access_control(nic, ipVersion, flow, 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, nic, flow)
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, nic, flow, refreshData, showNotification])
const tableTitle = `${NIC_LABELS[nic]} / ${FLOW_LABELS[flow]} / ${isIPv6 ? 'IPv6' : 'IPv4'} ${listType === 'black_list' ? 'Blacklist' : 'Whitelist'}`
return (
<>
<Head>
<title>Access Control - Mantis</title>
<meta name="description" content="Mantis Access Control Management - IP Whitelist and Blacklist" />
</Head>
<Layout>
<AnimatePresence>
{notification && (
<Notification message={notification.message} type={notification.type} onClose={closeNotification} />
)}
</AnimatePresence>
{/* Stats strip */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-5">
<StatsCard title="Unique IPs" value={stats.uniqueIPs} icon={faGlobe} color="bg-[#4ab5cc]" />
<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-slate-500" />
<StatsCard title="Specific Port Rules" value={stats.specificPortEntries} icon={faCheck} color="bg-orange-500" />
</div>
{/* Compact controls bar */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex flex-wrap items-center gap-3 px-4 py-2.5 rounded-xl border mb-5 ${
isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'
}`}
>
{/* Search */}
<div className="relative flex-1 min-w-40">
<FontAwesomeIcon icon={faSearch} className={`absolute left-2.5 top-1/2 -translate-y-1/2 text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`} />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search IP…"
className={`w-full pl-7 pr-3 py-1 text-xs border rounded-lg focus:outline-none focus:border-[#4ab5cc] ${
isDark ? 'bg-[#131929] border-slate-600 text-slate-300 placeholder-slate-500' : 'bg-white border-slate-300 text-slate-700 placeholder-slate-400'
}`}
/>
</div>
<div className={`w-px h-4 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>NIC:</span>
<ToggleButton isActive={nic === 'ingress'} onClick={() => handleNicChange('ingress')}>
<FontAwesomeIcon icon={faArrowDown} className="mr-1 text-xs" />Ingress
</ToggleButton>
<ToggleButton isActive={nic === 'egress'} onClick={() => handleNicChange('egress')}>
<FontAwesomeIcon icon={faArrowUp} className="mr-1 text-xs" />Egress
</ToggleButton>
</div>
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>Flow:</span>
<ToggleButton isActive={flow === 'source'} onClick={() => handleFlowChange('source')}>Source</ToggleButton>
<ToggleButton isActive={flow === 'destination'} onClick={() => handleFlowChange('destination')}>Destination</ToggleButton>
</div>
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>IP:</span>
<ToggleButton isActive={!isIPv6} onClick={() => handleIPVersionChange(false)}>IPv4</ToggleButton>
<ToggleButton isActive={isIPv6} onClick={() => handleIPVersionChange(true)}>IPv6</ToggleButton>
</div>
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>List:</span>
<ToggleButton isActive={listType === 'black_list'} onClick={() => handleListTypeChange('black_list')}>
<FontAwesomeIcon icon={faFilter} className="mr-1 text-xs" />Blacklist
</ToggleButton>
<ToggleButton isActive={listType === 'white_list'} onClick={() => handleListTypeChange('white_list')}>
<FontAwesomeIcon icon={faCheck} className="mr-1 text-xs" />Whitelist
</ToggleButton>
</div>
<div className="flex items-center gap-2 ml-auto">
<button
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
isDark ? 'bg-slate-700/50 text-slate-300 hover:bg-slate-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
onClick={handleRefresh}
>
<FontAwesomeIcon icon={faRefresh} className="text-xs" />
Refresh
</button>
<button
className="px-3 py-1 bg-[#4ab5cc] text-white rounded-lg text-xs font-medium hover:bg-[#3da5bc] transition-colors flex items-center gap-1.5"
onClick={handleAddItemClick}
>
<FontAwesomeIcon icon={faPlus} className="text-xs" />
Add Rule
</button>
</div>
</motion.div>
{/* Rules table */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={`rounded-xl border overflow-hidden ${isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'}`}
>
<div className={`px-5 py-3 border-b flex items-center justify-between ${isDark ? 'border-slate-700/40' : 'border-slate-200'}`}>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-[#4ab5cc]/15 flex items-center justify-center">
<FontAwesomeIcon icon={faShieldAlt} className="text-[#4ab5cc] text-xs" />
</div>
<span className={`text-sm font-semibold flex items-center gap-2 ${isDark ? 'text-slate-200' : 'text-slate-800'}`}>
{tableTitle}
{isLoading && <div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-[#4ab5cc]" />}
</span>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${isDark ? 'bg-slate-700/50 text-slate-400' : 'bg-slate-100 text-slate-500'}`}>
{flatData.length} rules
</span>
</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-[#4ab5cc] text-white rounded-lg hover:bg-[#4ab5cc] 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-[#131929]' : 'bg-slate-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-[#1a2236] divide-gray-700' : 'bg-white divide-gray-200'}`}>
{flatData.map(({ ip, port }, index) => (
<tr
key={`${ip}-${port}-${index}`}
className={`transition-colors ${isDark ? 'hover:bg-slate-700/30' : 'hover:bg-slate-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)}
>
<FontAwesomeIcon icon={faTrash} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</motion.div>
{isModalOpen && (
<Modal
title={`Add ${NIC_LABELS[nic]} / ${FLOW_LABELS[flow]} / ${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-[#4ab5cc] focus:ring-1 focus:ring-[#4ab5cc] ${
isDark ? 'bg-[#131929] border-slate-600 text-slate-300 placeholder-slate-500' : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400'
}`}
/>
</div>
<div className="flex items-center space-x-2">
<input
id="block-all-ports"
type="checkbox"
checked={blockAllPorts}
onChange={(e) => {
setBlockAllPorts(e.target.checked)
if (e.target.checked) setNewPort('')
}}
className="h-4 w-4 text-[#4ab5cc] focus:ring-[#4ab5cc] 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>
<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-[#4ab5cc] focus:ring-1 focus:ring-[#4ab5cc] ${
blockAllPorts
? (isDark ? 'bg-[#1a2236] 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')
}`}
/>
</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">
{NIC_LABELS[nic]} {FLOW_LABELS[flow]} {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-[#131929] border-slate-700/50' : 'bg-slate-50 border-slate-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'}`}>Target:</span>
<span className={isDark ? 'text-gray-300' : 'text-gray-900'}>
{NIC_LABELS[nic]} / {FLOW_LABELS[flow]} / {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

1061
src/pages/dashboard.tsx Normal file

File diff suppressed because it is too large Load Diff

799
src/pages/detection.tsx Normal file
View File

@ -0,0 +1,799 @@
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'
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
}
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: '#818cf8', bgColor: 'rgba(129,140,248,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
}
}
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 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 ipVersion = isIPv6 ? 'ipv6' as const : 'ipv4' as const
const url = urls.access_control('ingress', ipVersion, 'source', '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 ipVersion = isIPv6 ? 'ipv6' as const : 'ipv4' as const
const url = urls.access_control('ingress', ipVersion, 'source', '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-xl border flex flex-col h-full ${isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'}`}
style={{ maxHeight: 'calc(100vh - 280px)', minHeight: '400px' }}
>
{/* Header */}
<div className={`px-5 py-3 border-b flex items-center justify-between flex-shrink-0 ${isDark ? 'border-slate-700/40' : 'border-slate-200'}`}>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-[#4ab5cc]/15 flex items-center justify-center">
<FontAwesomeIcon icon={faEye} className="text-[#4ab5cc] text-xs" />
</div>
<span className={`text-sm font-semibold ${isDark ? 'text-slate-200' : 'text-slate-800'}`}>Alert Details</span>
</div>
<button
onClick={onClose}
className={`w-6 h-6 rounded-md flex items-center justify-center transition-colors ${isDark ? 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
>
<FontAwesomeIcon icon={faTimes} className="text-xs" />
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Basic info */}
<div className={`rounded-lg border p-3 space-y-2.5 ${isDark ? 'bg-[#131929] border-slate-700/40' : 'bg-slate-50 border-slate-200'}`}>
{[
{ label: 'Timestamp', value: formatTimestamp(log.timestamp) },
{ label: 'Attack Type', value: log.attack_type ?? '—' },
{ label: 'Protocol', value: getProtocolName(log.protocol) },
].map(({ label, value }) => (
<div key={label} className="flex justify-between items-center">
<span className={`text-xs font-medium ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>{label}</span>
<span className={`text-xs ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>{value}</span>
</div>
))}
<div className="flex justify-between items-center">
<span className={`text-xs font-medium ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>Severity</span>
<span
className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full"
style={{ backgroundColor: priorityInfo.bgColor, color: priorityInfo.color }}
>
<FontAwesomeIcon icon={faExclamationTriangle} className="mr-1 text-[10px]" />
{priorityInfo.label}
</span>
</div>
<div className="flex justify-between items-center">
<span className={`text-xs font-medium ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>Source</span>
<span
className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full"
style={{ backgroundColor: sourceInfo.bgColor, color: sourceInfo.color }}
>
{sourceInfo.label}
</span>
</div>
<div className="flex justify-between items-center">
<span className={`text-xs font-medium ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>Is Attack</span>
<span className={`text-xs font-semibold ${log.is_attack ? 'text-amber-400' : 'text-emerald-400'}`}>
{log.is_attack ? 'Yes' : 'No'}
</span>
</div>
</div>
{/* ML Score Details */}
{(log.source === 'ml' || log.source === 'fusion') && (
<div>
<span className={`text-xs font-semibold uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>ML Score Details</span>
<div className={`mt-2 rounded-lg border p-3 space-y-2 ${isDark ? 'bg-[#131929] border-slate-700/40' : 'bg-slate-50 border-slate-200'}`}>
{[
{ 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-xs ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>{label}</span>
<span className={`text-xs font-mono font-semibold ${
value >= 1.0 ? 'text-red-400' :
value >= 0.5 ? 'text-orange-400' :
value >= 0.1 ? 'text-yellow-400' :
'text-emerald-400'
}`}>
{value.toFixed(4)}
</span>
</div>
))}
</div>
</div>
)}
{/* Rule Match */}
{(log.source === 'rule' || log.source === 'fusion') && (log.rule_sid !== null || log.rule_msg !== null) && (
<div>
<span className={`text-xs font-semibold uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>Rule Match</span>
<div className={`mt-2 rounded-lg border p-3 space-y-2 ${isDark ? 'bg-[#131929] border-slate-700/40' : 'bg-slate-50 border-slate-200'}`}>
{log.rule_sid !== null && (
<div className="flex justify-between items-center">
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>SID</span>
<span className={`text-xs font-mono ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>{log.rule_sid}</span>
</div>
)}
{log.rule_msg !== null && (
<div className="flex justify-between gap-4">
<span className={`text-xs flex-shrink-0 ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>Message</span>
<span className={`text-xs text-right ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>{log.rule_msg}</span>
</div>
)}
</div>
</div>
)}
{/* Connection Details */}
<div>
<span className={`text-xs font-semibold uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>Connection</span>
<div className={`mt-2 rounded-lg border p-3 flex items-center justify-between ${isDark ? 'bg-[#131929] border-slate-700/40' : 'bg-slate-50 border-slate-200'}`}>
<div className="text-center">
<div className={`text-xs font-medium mb-1 ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>Source</div>
<div className="text-sm font-semibold text-[#4ab5cc] font-mono">{log.src_ip}</div>
<div className={`text-xs mt-0.5 ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>:{log.src_port}</div>
</div>
<FontAwesomeIcon icon={faArrowRight} className={`text-xs ${isDark ? 'text-slate-600' : 'text-slate-400'}`} />
<div className="text-center">
<div className={`text-xs font-medium mb-1 ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>Destination</div>
<div className="text-sm font-semibold text-amber-400 font-mono">{log.dst_ip}</div>
<div className={`text-xs mt-0.5 ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>:{log.dst_port}</div>
</div>
</div>
</div>
{/* IP Management */}
<div>
<span className={`text-xs font-semibold uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>IP Management</span>
<div className="mt-2 space-y-2">
{[
{ label: 'Source IP', ip: log.src_ip, port: log.src_port, isSource: true },
{ label: 'Destination IP', ip: log.dst_ip, port: log.dst_port, isSource: false },
].map(({ label, ip, port, isSource }) => (
<div key={label} className={`rounded-lg border p-3 ${isDark ? 'bg-[#131929] border-slate-700/40' : 'bg-slate-50 border-slate-200'}`}>
<div className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>
{label}: <span className={`font-mono ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{ip}</span>
</div>
<div className="grid grid-cols-2 gap-1.5">
<button
className={`px-2 py-1.5 rounded-lg text-xs font-medium transition-colors border ${isDark ? 'bg-red-500/10 border-red-500/20 text-red-400 hover:bg-red-500/20' : 'bg-red-50 border-red-200 text-red-600 hover:bg-red-100'}`}
onClick={() => handleBlockIP(ip, port, isSource)}
>
<FontAwesomeIcon icon={faFilter} className="mr-1 text-[10px]" />
Block :{port}
</button>
<button
className={`px-2 py-1.5 rounded-lg text-xs font-medium transition-colors border ${isDark ? 'bg-red-500/10 border-red-500/20 text-red-400 hover:bg-red-500/20' : 'bg-red-50 border-red-200 text-red-600 hover:bg-red-100'}`}
onClick={() => handleBlockIP(ip, port, isSource, true)}
>
<FontAwesomeIcon icon={faFilter} className="mr-1 text-[10px]" />
Block All
</button>
<button
className={`px-2 py-1.5 rounded-lg text-xs font-medium transition-colors border ${isDark ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/20' : 'bg-green-50 border-green-200 text-green-600 hover:bg-green-100'}`}
onClick={() => handleAllowIP(ip, port, isSource)}
>
<FontAwesomeIcon icon={faCheck} className="mr-1 text-[10px]" />
Allow :{port}
</button>
<button
className={`px-2 py-1.5 rounded-lg text-xs font-medium transition-colors border ${isDark ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/20' : 'bg-green-50 border-green-200 text-green-600 hover:bg-green-100'}`}
onClick={() => handleAllowIP(ip, port, isSource, true)}
>
<FontAwesomeIcon icon={faCheck} className="mr-1 text-[10px]" />
Allow All
</button>
</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
</AnimatePresence>
)
}
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-[#131929] border-slate-700/50' : 'bg-white border-slate-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>
)
}
const FilterButton: React.FC<FilterButtonProps> = ({
isActive, onClick, children,
className = '', activeClassName = '', activeHoverClassName = '',
}) => {
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
return (
<button
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
isActive
? `${activeClassName || 'bg-[#4ab5cc] text-white shadow-sm'} ${activeHoverClassName}`
: `${isDark ? 'bg-[#131929] text-slate-300 hover:bg-slate-700/40' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'} ${className}`
}`}
onClick={onClick}
>
{children}
</button>
)
}
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 - Mantis</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 - Mantis</title>
<meta name="description" content="Mantis Unified Threat Detection" />
</Head>
<Layout>
<AnimatePresence>
{notification && (
<Notification message={notification.message} type={notification.type} onClose={closeNotification} />
)}
</AnimatePresence>
{/* Stats strip */}
<div className="grid grid-cols-4 lg:grid-cols-7 gap-3 mb-5">
{[
{ label: 'Total', value: stats.total, icon: faList, color: '#4ab5cc' },
{ label: 'Critical', value: stats.critical, icon: faExclamationTriangle, color: '#ef4444' },
{ label: 'High', value: stats.high, icon: faExclamationTriangle, color: '#f97316' },
{ label: 'ML', value: stats.ml, icon: faRobot, color: '#64748b' },
{ label: 'Rule', value: stats.rule, icon: faShieldAlt, color: '#4ab5cc' },
{ label: 'Fusion', value: stats.fusion, icon: faShieldAlt, color: '#14b8a6' },
{ label: 'Unique IPs', value: stats.uniqueIPs, icon: faGlobe, color: '#6366f1' },
].map(({ label, value, icon, color }, i) => (
<motion.div
key={label}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
className={`rounded-xl border p-3 ${isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'}`}
>
<p className={`text-xs mb-1 ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{label}</p>
<div className="flex items-center justify-between">
<span className={`text-xl font-bold tabular-nums ${isDark ? 'text-white' : 'text-slate-900'}`}>{value}</span>
<FontAwesomeIcon icon={icon} style={{ color }} className="text-sm opacity-80" />
</div>
</motion.div>
))}
</div>
{/* Compact controls + filter bar */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex flex-wrap items-center gap-3 px-4 py-2.5 rounded-xl border mb-5 ${
isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'
}`}
>
{/* Update interval */}
<div className="flex items-center gap-1.5">
<FontAwesomeIcon icon={faClock} className="text-[#4ab5cc] text-xs" />
<select
value={updateInterval}
onChange={(e) => setUpdateInterval(Number(e.target.value))}
className={`text-xs border rounded-lg px-2 py-1 focus:outline-none focus:border-[#4ab5cc] ${
isDark ? 'bg-[#131929] border-slate-600 text-slate-300' : 'bg-white border-slate-300 text-slate-700'
}`}
>
{UPDATE_INTERVALS.map(i => (
<option key={i.value} value={i.value}>{i.label}</option>
))}
</select>
</div>
<button
onClick={togglePause}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
isPaused
? 'bg-green-500/10 text-green-500 hover:bg-green-500/20'
: 'bg-amber-500/10 text-amber-500 hover:bg-amber-500/20'
}`}
>
<FontAwesomeIcon icon={isPaused ? faPlay : faPause} className="mr-1" />
{isPaused ? 'Resume' : 'Pause'}
</button>
<div className={`w-px h-4 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
{/* Severity filters */}
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>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-sm" activeHoverClassName="hover:bg-red-700">
Critical
</FilterButton>
<FilterButton isActive={severityFilter === 'high'} onClick={() => { setSeverityFilter('high'); setSelectedLogIndex(null) }}
activeClassName="bg-orange-600 text-white shadow-sm" activeHoverClassName="hover:bg-orange-700">
High
</FilterButton>
</div>
<div className={`w-px h-4 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
{/* Source filters */}
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>Source:</span>
<FilterButton isActive={sourceFilter === 'all'} onClick={() => { setSourceFilter('all'); setSelectedLogIndex(null) }}>All</FilterButton>
<FilterButton isActive={sourceFilter === 'ml'} onClick={() => { setSourceFilter('ml'); setSelectedLogIndex(null) }}
activeClassName="bg-[#4ab5cc] text-white shadow-sm" activeHoverClassName="hover:bg-[#3da5bc]">ML</FilterButton>
<FilterButton isActive={sourceFilter === 'rule'} onClick={() => { setSourceFilter('rule'); setSelectedLogIndex(null) }}
activeClassName="bg-sky-600 text-white shadow-sm" activeHoverClassName="hover:bg-sky-700">Rule</FilterButton>
<FilterButton isActive={sourceFilter === 'fusion'} onClick={() => { setSourceFilter('fusion'); setSelectedLogIndex(null) }}
activeClassName="bg-teal-600 text-white shadow-sm" activeHoverClassName="hover:bg-teal-700">Fusion</FilterButton>
</div>
{lastUpdateTime && (
<span className={`ml-auto text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
Updated {lastUpdateTime.toLocaleTimeString()}
</span>
)}
</motion.div>
{/* Alert list + detail panel */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 items-stretch">
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1, duration: 0.2, ease: 'easeOut' }}
className={selectedLogIndex !== null && filteredLogs[selectedLogIndex] ? 'lg:col-span-7' : 'lg:col-span-12'}
>
<div className={`rounded-xl border overflow-hidden flex flex-col ${isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'}`}
style={{ maxHeight: 'calc(100vh - 280px)', minHeight: '400px' }}>
<div className={`px-5 py-3 border-b flex items-center justify-between flex-shrink-0 ${isDark ? 'border-slate-700/40' : 'border-slate-200'}`}>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-[#4ab5cc]/15 flex items-center justify-center">
<FontAwesomeIcon icon={faShieldAlt} className="text-[#4ab5cc] text-xs" />
</div>
<span className={`text-sm font-semibold ${isDark ? 'text-slate-200' : 'text-slate-800'}`}>Security Alerts</span>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${isDark ? 'bg-slate-700/50 text-slate-400' : 'bg-slate-100 text-slate-500'}`}>
{filteredLogs.length} alerts
</span>
</div>
<div className="flex-1 overflow-y-auto">
{filteredLogs.length === 0 ? (
<div className="py-16 text-center">
<FontAwesomeIcon icon={faShieldAlt} className={`text-4xl mb-3 ${isDark ? 'text-slate-600' : 'text-slate-300'}`} />
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-slate-400' : 'text-slate-600'}`}>
{logs.length === 0 ? 'No Alerts Detected' : 'No Matching Alerts'}
</p>
<p className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
{logs.length === 0 ? 'Threats will appear here when detected' : 'Try adjusting filter criteria'}
</p>
</div>
) : (
<div className="p-4 space-y-3">
{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 h-full"
>
<AlertDetails
log={filteredLogs[selectedLogIndex]}
onClose={handleCloseDetails}
showNotification={showNotification}
/>
</motion.div>
)}
</div>
</Layout>
</>
)
}
export default Detection

1127
src/pages/index.tsx Normal file

File diff suppressed because it is too large Load Diff

211
src/pages/login.tsx Normal file
View File

@ -0,0 +1,211 @@
import React, { useState, useCallback } from 'react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { motion } from 'framer-motion'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser, faLock, faEye, faEyeSlash, faShieldAlt } from '@fortawesome/free-solid-svg-icons'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../providers/ThemeProvider'
const LoginPage: React.FC = () => {
const { login, isAuthenticated } = useAuth()
const { actualTheme } = useTheme()
const router = useRouter()
const isDark = actualTheme === 'dark'
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
React.useEffect(() => {
if (isAuthenticated) {
router.replace('/')
}
}, [isAuthenticated, router])
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!username.trim() || !password.trim()) {
setError('Please enter username and password')
return
}
setError(null)
setIsLoading(true)
try {
await login(username.trim(), password)
router.replace('/')
} catch (err: any) {
setError(err.message || 'Login failed')
} finally {
setIsLoading(false)
}
}, [username, password, login, router])
return (
<>
<Head>
<title>Login Mantis</title>
<meta name="description" content="Mantis NIDS Login" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<div
className="min-h-screen flex items-center justify-center transition-colors duration-300"
style={{ background: isDark ? '#0b0f17' : '#f1f5f9' }}
>
{/* Background grid pattern */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: `linear-gradient(#38bdf8 1px, transparent 1px), linear-gradient(90deg, #38bdf8 1px, transparent 1px)`,
backgroundSize: '48px 48px',
}}
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="w-full max-w-sm px-4 relative z-10"
>
{/* Logo / Brand */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4"
style={{ background: 'rgba(56,189,248,0.12)', border: '1px solid rgba(56,189,248,0.2)' }}
>
<img src="/mantis-logo.png" alt="Mantis" className="w-9 h-9 object-contain" />
</div>
<h1 className={`text-2xl font-bold tracking-tight ${isDark ? 'text-white' : 'text-slate-900'}`}>
Mantis
</h1>
<p className={`text-sm mt-1 ${isDark ? 'text-slate-500' : 'text-slate-500'}`}>
Network Intrusion Detection System
</p>
</div>
{/* Card */}
<div
className="rounded-2xl overflow-hidden shadow-2xl"
style={{
background: isDark ? '#131929' : '#ffffff',
border: isDark ? '1px solid rgba(74,181,204,0.1)' : '1px solid #e2e8f0',
}}
>
<form onSubmit={handleSubmit} className="px-7 py-7 space-y-5">
{/* Username */}
<div>
<label className={`block text-xs font-semibold mb-1.5 tracking-wide uppercase ${
isDark ? 'text-slate-400' : 'text-slate-500'
}`}>
Username
</label>
<div className="relative">
<FontAwesomeIcon
icon={faUser}
className={`absolute left-3 top-1/2 -translate-y-1/2 text-xs ${
isDark ? 'text-slate-500' : 'text-slate-400'
}`}
/>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
autoComplete="username"
className={`w-full pl-9 pr-3 py-2.5 rounded-lg text-sm focus:outline-none transition-colors ${
isDark
? 'bg-slate-800/60 border border-slate-700 text-slate-200 placeholder-slate-600 focus:border-[#4ab5cc]/60 focus:bg-slate-800'
: 'bg-slate-50 border border-slate-200 text-slate-900 placeholder-slate-400 focus:border-sky-400 focus:bg-white'
}`}
/>
</div>
</div>
{/* Password */}
<div>
<label className={`block text-xs font-semibold mb-1.5 tracking-wide uppercase ${
isDark ? 'text-slate-400' : 'text-slate-500'
}`}>
Password
</label>
<div className="relative">
<FontAwesomeIcon
icon={faLock}
className={`absolute left-3 top-1/2 -translate-y-1/2 text-xs ${
isDark ? 'text-slate-500' : 'text-slate-400'
}`}
/>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
autoComplete="current-password"
className={`w-full pl-9 pr-10 py-2.5 rounded-lg text-sm focus:outline-none transition-colors ${
isDark
? 'bg-slate-800/60 border border-slate-700 text-slate-200 placeholder-slate-600 focus:border-[#4ab5cc]/60 focus:bg-slate-800'
: 'bg-slate-50 border border-slate-200 text-slate-900 placeholder-slate-400 focus:border-sky-400 focus:bg-white'
}`}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={`absolute right-3 top-1/2 -translate-y-1/2 transition-colors ${
isDark ? 'text-slate-500 hover:text-slate-300' : 'text-slate-400 hover:text-slate-600'
}`}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="text-xs" />
</button>
</div>
</div>
{/* Error */}
{error && (
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
className={`px-3.5 py-2.5 rounded-lg text-xs ${
isDark
? 'bg-red-900/20 border border-red-700/40 text-red-400'
: 'bg-red-50 border border-red-200 text-red-600'
}`}
>
{error}
</motion.div>
)}
{/* Submit */}
<button
type="submit"
disabled={isLoading}
className={`w-full py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 ${
isLoading
? 'bg-[#4ab5cc]/40 text-sky-200 cursor-not-allowed'
: 'bg-[#4ab5cc] text-white hover:bg-[#4ab5cc] active:bg-sky-600 shadow-lg shadow-[#4ab5cc]/15'
}`}
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-white" />
Signing in
</span>
) : (
'Sign In'
)}
</button>
</form>
</div>
<p className={`text-center mt-5 text-xs ${isDark ? 'text-slate-600' : 'text-slate-400'}`}>
Mantis NIDS Secure Access Required
</p>
</motion.div>
</div>
</>
)
}
export default LoginPage

281
src/pages/logs.tsx Normal file
View File

@ -0,0 +1,281 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import Head from 'next/head'
import { motion, AnimatePresence } from 'framer-motion'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faScroll,
faCircle,
faFilter,
faTrash,
faArrowDown,
faPause,
faPlay,
} from '@fortawesome/free-solid-svg-icons'
import { useTheme } from '../providers/ThemeProvider'
import { websocketUrl } from '../config'
import Layout from '../components/Layout'
interface LogRecord {
timestamp: string
level: string
message: string
}
type LevelFilter = 'ALL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE'
const LEVEL_STYLES: Record<string, { badge: string; text: string; dot: string }> = {
ERROR: { badge: 'bg-red-500/20 text-red-400 border-red-500/30', text: 'text-red-400', dot: 'text-red-500' },
WARN: { badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', text: 'text-yellow-400', dot: 'text-yellow-500' },
INFO: { badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30', text: 'text-blue-400', dot: 'text-blue-500' },
DEBUG: { badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30', text: 'text-gray-400', dot: 'text-gray-500' },
TRACE: { badge: 'bg-slate-500/20 text-slate-400 border-slate-500/30', text: 'text-slate-400', dot: 'text-slate-500' },
}
const DEFAULT_STYLE = LEVEL_STYLES.DEBUG
const MAX_LOGS = 1000
const LogsPage: React.FC = () => {
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
const [logs, setLogs] = useState<LogRecord[]>([])
const [levelFilter, setLevelFilter] = useState<LevelFilter>('ALL')
const [isPaused, setIsPaused] = useState(false)
const [isConnected, setIsConnected] = useState(false)
const [autoScroll, setAutoScroll] = useState(true)
const wsRef = useRef<WebSocket | null>(null)
const isPausedRef = useRef(isPaused)
const bottomRef = useRef<HTMLDivElement>(null)
const bufferRef = useRef<LogRecord[]>([])
useEffect(() => { isPausedRef.current = isPaused }, [isPaused])
useEffect(() => {
const connect = () => {
const ws = new WebSocket(websocketUrl.logs)
wsRef.current = ws
ws.onopen = () => setIsConnected(true)
ws.onclose = () => {
setIsConnected(false)
setTimeout(connect, 3000)
}
ws.onerror = () => ws.close()
ws.onmessage = (e) => {
try {
const record: LogRecord = JSON.parse(e.data)
if (isPausedRef.current) {
bufferRef.current.push(record)
return
}
setLogs(prev => {
const next = [...prev, record]
return next.length > MAX_LOGS ? next.slice(next.length - MAX_LOGS) : next
})
} catch {}
}
}
connect()
return () => wsRef.current?.close()
}, [])
// Flush buffer when unpaused
useEffect(() => {
if (!isPaused && bufferRef.current.length > 0) {
const buffered = bufferRef.current.splice(0)
setLogs(prev => {
const next = [...prev, ...buffered]
return next.length > MAX_LOGS ? next.slice(next.length - MAX_LOGS) : next
})
}
}, [isPaused])
// Auto-scroll to bottom
useEffect(() => {
if (autoScroll && !isPaused) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [logs, autoScroll, isPaused])
const clearLogs = useCallback(() => {
setLogs([])
bufferRef.current = []
}, [])
const filteredLogs = levelFilter === 'ALL'
? logs
: logs.filter(l => l.level === levelFilter)
const counts = logs.reduce<Record<string, number>>((acc, l) => {
acc[l.level] = (acc[l.level] ?? 0) + 1
return acc
}, {})
const formatTimestamp = (ts: string) => {
try {
const d = new Date(ts)
return d.toLocaleTimeString('en', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
} catch {
return ts
}
}
const levelKeys: LevelFilter[] = ['ALL', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE']
return (
<>
<Head>
<title>Logs - Mantis</title>
</Head>
<Layout>
{/* Compact controls + filter bar */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex flex-wrap items-center gap-3 px-4 py-2.5 rounded-xl border mb-4 ${
isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'
}`}
>
{/* Level filter tabs */}
<div className={`flex items-center gap-1 p-0.5 rounded-lg border ${
isDark ? 'bg-[#131929] border-slate-700/50' : 'bg-slate-50 border-slate-200'
}`}>
<FontAwesomeIcon icon={faFilter} className={`ml-1.5 text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`} />
{levelKeys.map(lvl => {
const style = lvl !== 'ALL' ? LEVEL_STYLES[lvl] : null
const count = lvl === 'ALL' ? logs.length : (counts[lvl] ?? 0)
return (
<button
key={lvl}
onClick={() => setLevelFilter(lvl)}
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
levelFilter === lvl
? isDark ? 'bg-[#0e1e2c] text-white shadow-sm' : 'bg-white text-slate-900 shadow-sm'
: isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'
}`}
>
{lvl !== 'ALL' && style && (
<FontAwesomeIcon icon={faCircle} className={`mr-1 text-[8px] ${style.dot}`} />
)}
{lvl}
{count > 0 && (
<span className={`ml-1 px-1 py-0.5 rounded-full text-[10px] ${
isDark ? 'bg-slate-700 text-slate-400' : 'bg-slate-200 text-slate-500'
}`}>
{count > 999 ? '999+' : count}
</span>
)}
</button>
)
})}
</div>
<div className="flex items-center gap-2 ml-auto">
{/* Live status */}
<div className="flex items-center gap-1.5">
<FontAwesomeIcon icon={faCircle} className={`text-[8px] ${isConnected ? 'text-green-400' : 'text-red-400'}`} />
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
{isConnected ? 'Live' : 'Disconnected'} · {logs.length}
</span>
</div>
<div className={`w-px h-4 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
<button
onClick={() => setAutoScroll(v => !v)}
title="Auto-scroll"
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
autoScroll
? 'bg-[#4ab5cc]/15 text-[#4ab5cc]'
: isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'
}`}
>
<FontAwesomeIcon icon={faArrowDown} className="text-xs" />
Auto-scroll
</button>
<button
onClick={() => setIsPaused(v => !v)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
isPaused
? 'bg-amber-500/10 text-amber-500'
: isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'
}`}
>
<FontAwesomeIcon icon={isPaused ? faPlay : faPause} className="text-xs" />
{isPaused ? `Resume (${bufferRef.current.length})` : 'Pause'}
</button>
<button
onClick={clearLogs}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
isDark
? 'text-slate-400 hover:text-red-400'
: 'text-slate-500 hover:text-red-500'
}`}
>
<FontAwesomeIcon icon={faTrash} className="text-xs" />
Clear
</button>
</div>
</motion.div>
{/* Log list */}
<div className={`rounded-xl border overflow-hidden ${isDark ? 'bg-[#0b0f17] border-slate-700/50' : 'bg-slate-50 border-slate-200'}`}>
<div className="h-[calc(100vh-320px)] min-h-64 overflow-y-auto font-mono text-xs">
{filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-3">
<FontAwesomeIcon icon={faScroll} className={`text-4xl ${isDark ? 'text-gray-600' : 'text-gray-300'}`} />
<p className={isDark ? 'text-gray-500' : 'text-gray-400'}>
{isConnected ? 'Waiting for logs...' : 'Connecting...'}
</p>
</div>
) : (
<div className="divide-y divide-transparent">
{filteredLogs.map((log, i) => {
const style = LEVEL_STYLES[log.level] ?? DEFAULT_STYLE
return (
<div
key={i}
className={`flex items-start gap-3 px-4 py-1.5 transition-colors ${
isDark ? 'hover:bg-[#1a2236]' : 'hover:bg-white'
}`}
>
<span className={`flex-shrink-0 w-[85px] ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
{formatTimestamp(log.timestamp)}
</span>
<span className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-bold uppercase w-[46px] justify-center ${style.badge}`}>
{log.level.slice(0, 4)}
</span>
<span className={`flex-1 break-all leading-relaxed ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
{log.message}
</span>
</div>
)
})}
<div ref={bottomRef} />
</div>
)}
</div>
</div>
{isPaused && bufferRef.current.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="mt-3 px-4 py-2 bg-yellow-500/20 border border-yellow-500/30 rounded-lg text-yellow-400 text-sm flex items-center justify-between"
>
<span>{bufferRef.current.length} new entries buffered while paused</span>
<button onClick={() => setIsPaused(false)} className="font-medium hover:underline">
Resume
</button>
</motion.div>
)}
</Layout>
</>
)
}
export default LogsPage

255
src/pages/setup.tsx Normal file
View File

@ -0,0 +1,255 @@
import React, { useState, useCallback } from 'react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { motion } from 'framer-motion'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser, faLock, faEye, faEyeSlash, faCheckCircle } from '@fortawesome/free-solid-svg-icons'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../providers/ThemeProvider'
const SetupPage: React.FC = () => {
const { register, isInitialized } = useAuth()
const { actualTheme } = useTheme()
const router = useRouter()
const isDark = actualTheme === 'dark'
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
React.useEffect(() => {
if (isInitialized) router.replace('/login')
}, [isInitialized, router])
const passwordStrength = (pw: string) => {
if (pw.length === 0) return null
if (pw.length < 8) return { label: 'Too short', color: 'bg-red-500', width: '25%' }
if (pw.length < 12) return { label: 'Fair', color: 'bg-yellow-500', width: '50%' }
if (/[A-Z]/.test(pw) && /[0-9]/.test(pw)) return { label: 'Strong', color: 'bg-green-500', width: '100%' }
return { label: 'Good', color: 'bg-[#4ab5cc]', width: '75%' }
}
const strength = passwordStrength(password)
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!username.trim()) {
setError('Username is required')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters')
return
}
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
setIsLoading(true)
try {
await register(username.trim(), password)
router.replace('/')
} catch (err: any) {
setError(err.message || 'Setup failed')
} finally {
setIsLoading(false)
}
}, [username, password, confirmPassword, register, router])
return (
<>
<Head>
<title>Setup - Mantis</title>
<meta name="description" content="Mantis NIDS First-time Setup" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<div
className="min-h-screen flex items-center justify-center transition-colors duration-300"
style={{ background: isDark ? '#0b0f17' : '#f1f5f9' }}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md px-4"
>
<div
className="rounded-2xl shadow-2xl overflow-hidden"
style={{
background: isDark ? '#131929' : '#ffffff',
border: isDark ? '1px solid rgba(74,181,204,0.1)' : '1px solid #e2e8f0',
}}
>
<div className="px-8 py-8 text-center" style={{ background: 'linear-gradient(135deg, #0a1c28 0%, #0c2130 100%)' }}>
<div className="flex justify-center mb-4">
<img src="/mantis-logo.png" alt="Mantis" className="w-16 h-16 object-contain drop-shadow-lg" />
</div>
<h1 className="text-2xl font-bold text-white mb-1">First-time Setup</h1>
<p className="text-[#4ab5cc]/70 text-sm">Create your administrator account</p>
</div>
<div className={`px-6 py-3.5 border-b ${isDark ? 'border-slate-700/50 bg-[#4ab5cc]/5' : 'border-[#4ab5cc]/20 bg-[#4ab5cc]/5'}`}>
<div className="flex items-start gap-3">
<FontAwesomeIcon icon={faCheckCircle} className="text-[#4ab5cc] mt-0.5 flex-shrink-0 text-sm" />
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#3da5bc]'}`}>
No accounts exist yet. Create the first administrator account to get started.
This setup page will be unavailable after the account is created.
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="px-8 py-8 space-y-5">
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Username
</label>
<div className="relative">
<FontAwesomeIcon
icon={faUser}
className={`absolute left-3 top-1/2 -translate-y-1/2 text-sm ${isDark ? 'text-gray-400' : 'text-gray-400'}`}
/>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose a username"
autoComplete="username"
className={`w-full pl-9 pr-3 py-2.5 border rounded-lg focus:outline-none focus:border-[#4ab5cc] focus:ring-1 focus:ring-[#4ab5cc] transition-colors ${
isDark
? 'bg-slate-800/60 border-slate-700 text-slate-200 placeholder-slate-600'
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-400'
}`}
/>
</div>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Password
</label>
<div className="relative">
<FontAwesomeIcon
icon={faLock}
className={`absolute left-3 top-1/2 -translate-y-1/2 text-sm ${isDark ? 'text-gray-400' : 'text-gray-400'}`}
/>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
autoComplete="new-password"
className={`w-full pl-9 pr-10 py-2.5 border rounded-lg focus:outline-none focus:border-[#4ab5cc] focus:ring-1 focus:ring-[#4ab5cc] transition-colors ${
isDark
? 'bg-slate-800/60 border-slate-700 text-slate-200 placeholder-slate-600'
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-400'
}`}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={`absolute right-3 top-1/2 -translate-y-1/2 transition-colors ${
isDark ? 'text-gray-400 hover:text-gray-200' : 'text-gray-400 hover:text-gray-600'
}`}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="text-sm" />
</button>
</div>
{strength && (
<div className="mt-2">
<div className={`h-1 rounded-full overflow-hidden ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`}>
<div
className={`h-full rounded-full transition-all duration-300 ${strength.color}`}
style={{ width: strength.width }}
/>
</div>
<p className={`text-xs mt-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>{strength.label}</p>
</div>
)}
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Confirm Password
</label>
<div className="relative">
<FontAwesomeIcon
icon={faLock}
className={`absolute left-3 top-1/2 -translate-y-1/2 text-sm ${isDark ? 'text-gray-400' : 'text-gray-400'}`}
/>
<input
type={showConfirm ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter password"
autoComplete="new-password"
className={`w-full pl-9 pr-10 py-2.5 border rounded-lg focus:outline-none focus:border-[#4ab5cc] focus:ring-1 focus:ring-[#4ab5cc] transition-colors ${
confirmPassword && password !== confirmPassword
? 'border-red-400 focus:border-red-400 focus:ring-red-400'
: isDark
? 'bg-slate-800/60 border-slate-700 text-slate-200 placeholder-slate-600'
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-400'
} ${isDark ? 'bg-gray-700 text-gray-200' : 'bg-white text-gray-900'}`}
/>
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className={`absolute right-3 top-1/2 -translate-y-1/2 transition-colors ${
isDark ? 'text-gray-400 hover:text-gray-200' : 'text-gray-400 hover:text-gray-600'
}`}
>
<FontAwesomeIcon icon={showConfirm ? faEyeSlash : faEye} className="text-sm" />
</button>
</div>
{confirmPassword && password !== confirmPassword && (
<p className="text-xs mt-1 text-red-400">Passwords do not match</p>
)}
</div>
{error && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="px-4 py-3 rounded-lg bg-red-100 border border-red-300 text-red-700 text-sm"
>
{error}
</motion.div>
)}
<button
type="submit"
disabled={isLoading}
className={`w-full py-2.5 rounded-lg font-semibold transition-colors ${
isLoading
? 'bg-[#4ab5cc]/40 text-sky-200 cursor-not-allowed'
: 'bg-[#4ab5cc] text-white hover:bg-[#4ab5cc] active:bg-sky-600 shadow-lg shadow-[#4ab5cc]/15'
}`}
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
Creating account...
</span>
) : (
'Create Administrator Account'
)}
</button>
</form>
</div>
<p className={`text-center mt-4 text-xs ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
Mantis NIDS First-time Setup
</p>
</motion.div>
</div>
</>
)
}
export default SetupPage

829
src/pages/statistics.tsx Normal file
View File

@ -0,0 +1,829 @@
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]}`;
};
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-[#4ab5cc] text-white"
: `${isDark ? 'bg-[#131929] text-slate-300 hover:bg-slate-700/40' : '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 items-center gap-2">
<FontAwesomeIcon icon={faClock} className="text-[#4ab5cc] text-xs" />
<select
value={updateInterval}
onChange={(e) => setUpdateInterval(Number(e.target.value))}
className={`text-xs border rounded-lg px-2 py-1 focus:outline-none focus:border-[#4ab5cc] ${
isDark ? 'bg-[#131929] border-slate-600 text-slate-300' : 'bg-white border-slate-300 text-slate-700'
}`}
>
{UPDATE_INTERVALS.map(interval => (
<option key={interval.value} value={interval.value}>{interval.label}</option>
))}
</select>
<button
onClick={() => setIsPaused(!isPaused)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
isPaused
? 'bg-green-500/10 text-green-500 hover:bg-green-500/20'
: 'bg-amber-500/10 text-amber-500 hover:bg-amber-500/20'
}`}
>
<FontAwesomeIcon icon={isPaused ? faPlay : faPause} className="mr-1" />
{isPaused ? 'Resume' : 'Pause'}
</button>
<button
onClick={() => window.location.reload()}
className="px-2.5 py-1 bg-[#4ab5cc]/10 text-[#4ab5cc] hover:bg-[#4ab5cc]/20 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5"
>
<FontAwesomeIcon icon={faRefresh} className="text-xs" />
Update
</button>
{lastUpdateTime && (
<span className={`text-xs ml-2 ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
Last Update: {lastUpdateTime.toLocaleTimeString()}
</span>
)}
</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: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex flex-wrap items-center gap-3 px-4 py-2.5 rounded-xl border mb-5 ${
isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'
}`}
>
{/* Update controls */}
<UpdateControl
updateInterval={updateInterval}
setUpdateInterval={setUpdateInterval}
isPaused={isPaused}
setIsPaused={setIsPaused}
lastUpdateTime={lastUpdateTime}
/>
<div className={`w-px h-4 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
{/* Search */}
<div className="relative flex-1 min-w-40">
<FontAwesomeIcon icon={faSearch} className={`absolute left-2.5 top-1/2 -translate-y-1/2 text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`} />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search IP Address…"
className={`w-full pl-7 pr-3 py-1 text-xs border rounded-lg focus:outline-none focus:border-[#4ab5cc] ${
isDark ? 'bg-[#131929] border-slate-600 text-slate-300 placeholder-slate-500' : 'bg-white border-slate-300 text-slate-700 placeholder-slate-400'
}`}
/>
</div>
<div className={`w-px h-4 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>IP:</span>
<ToggleButton isActive={!isIPv6} onClick={() => setIsIPv6(false)}>IPv4</ToggleButton>
<ToggleButton isActive={isIPv6} onClick={() => setIsIPv6(true)}>IPv6</ToggleButton>
</div>
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>Dir:</span>
<ToggleButton isActive={direction === 'ingress'} onClick={() => setDirection('ingress')}>Ingress</ToggleButton>
<ToggleButton isActive={direction === 'egress'} onClick={() => setDirection('egress')}>Egress</ToggleButton>
</div>
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>Traffic:</span>
<ToggleButton isActive={trafficType === 'source'} onClick={() => setTrafficType('source')}>Source</ToggleButton>
<ToggleButton isActive={trafficType === 'destination'} onClick={() => setTrafficType('destination')}>Destination</ToggleButton>
</div>
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>Time:</span>
{TIME_RANGES.map((range) => (
<ToggleButton key={range} isActive={timeRange === range} onClick={() => setTimeRange(range)}>{range}</ToggleButton>
))}
</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-xl border overflow-hidden ${isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'}`}
>
<div className="overflow-y-auto" style={{ maxHeight: 'calc(100vh - 390px)', minHeight: '200px' }}>
<table className={`min-w-full divide-y ${isDark ? 'divide-slate-700/50' : 'divide-slate-200'}`}>
<thead className={`sticky top-0 z-10 ${isDark ? 'bg-[#131929]' : 'bg-slate-50'}`}>
<tr>
{columns.map((column) => (
<TableHeader
key={column.key}
column={column}
sortConfig={sortConfig}
onSort={handleSort}
/>
))}
</tr>
</thead>
<tbody className={`divide-y ${isDark ? 'bg-[#0e1e2c] divide-slate-700/40' : 'bg-white divide-slate-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-slate-700/30' : 'hover:bg-slate-50'}`}
>
<td className={`px-4 py-1.5 whitespace-nowrap text-sm font-medium ${isDark ? 'text-slate-300' : 'text-slate-800'}`}>
{row.ip}
</td>
<td className="px-4 py-1.5 text-sm">
{formatGeoLocation(row.geo)}
</td>
<td className={`px-4 py-1.5 whitespace-nowrap text-sm ${isDark ? 'text-slate-300' : 'text-slate-800'}`}>
{formatBytes(row.bytes)}
</td>
<td className={`px-4 py-1.5 whitespace-nowrap text-sm ${isDark ? 'text-slate-300' : 'text-slate-800'}`}>
{row.packets.toLocaleString()}
</td>
<td className={`px-4 py-1.5 whitespace-nowrap text-sm ${isDark ? 'text-slate-400' : 'text-slate-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-[#131929] border-slate-700/50' : 'bg-slate-50 border-slate-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-xl border p-12 text-center ${isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'}`}
>
<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 => {
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-[#4ab5cc]'
},
{
title: 'Total Packets',
value: totalPackets.toLocaleString(),
icon: faChartBar,
color: 'bg-green-500'
},
{
title: 'Unique IPs',
value: uniqueIPs.toLocaleString(),
icon: faGlobe,
color: 'bg-slate-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-xl border p-4 ${isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'}`}
>
<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);
const latestDataRef = useRef<any>({});
const lastUpdateRef = useRef<number>(0);
const updateIntervalRef = useRef<number>(updateInterval);
const isPausedRef = useRef<boolean>(isPaused);
useEffect(() => {
updateIntervalRef.current = updateInterval;
}, [updateInterval]);
useEffect(() => {
isPausedRef.current = isPaused;
}, [isPaused]);
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 {};
}
}, []);
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;
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';
}> = [];
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
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 - Mantis</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 - Mantis</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-[#4ab5cc] text-white rounded-lg hover:bg-[#4ab5cc] transition-colors"
>
Reload
</button>
</div>
</div>
</Layout>
</>
);
}
return (
<>
<Head>
<title>Statistics - Mantis</title>
<meta name="description" content="Mantis Network Traffic Statistics" />
</Head>
<Layout>
<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>
{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;

701
src/pages/traffic-map.tsx Normal file
View File

@ -0,0 +1,701 @@
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-[#131929] text-slate-300' : 'bg-slate-100 text-slate-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: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex flex-wrap items-center gap-3 px-4 py-2.5 rounded-xl border mb-5 ${
isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'
}`}
>
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>IP:</span>
<ToggleButton isActive={!isIPv6} onClick={() => setIsIPv6(false)} isDark={isDark}>IPv4</ToggleButton>
<ToggleButton isActive={isIPv6} onClick={() => setIsIPv6(true)} isDark={isDark}>IPv6</ToggleButton>
</div>
<div className={`w-px h-4 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>Direction:</span>
<ToggleButton isActive={direction === 'ingress'} onClick={() => setDirection('ingress')} isDark={isDark}>Ingress</ToggleButton>
<ToggleButton isActive={direction === 'egress'} onClick={() => setDirection('egress')} isDark={isDark}>Egress</ToggleButton>
</div>
<div className={`w-px h-4 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>Traffic:</span>
<ToggleButton isActive={trafficType === 'source'} onClick={() => setTrafficType('source')} isDark={isDark}>Source</ToggleButton>
<ToggleButton isActive={trafficType === 'destination'} onClick={() => setTrafficType('destination')} isDark={isDark}>Destination</ToggleButton>
</div>
<div className={`w-px h-4 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
<div className="flex items-center gap-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>Time:</span>
{TIME_RANGES.map((range) => (
<ToggleButton key={range} isActive={timeRange === range} onClick={() => setTimeRange(range)} isDark={isDark}>
{range}
</ToggleButton>
))}
</div>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={() => setIsPaused(!isPaused)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
isPaused
? 'bg-green-500/10 text-green-500 hover:bg-green-500/20'
: 'bg-amber-500/10 text-amber-500 hover:bg-amber-500/20'
}`}
>
<FontAwesomeIcon icon={isPaused ? faPlay : faPause} className="mr-1" />
{isPaused ? 'Resume' : 'Pause'}
</button>
<button
onClick={onRefresh}
className="px-2.5 py-1 bg-[#4ab5cc]/10 text-[#4ab5cc] hover:bg-[#4ab5cc]/20 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5"
>
<FontAwesomeIcon icon={faRefresh} className="text-xs" />
Refresh
</button>
{lastUpdateTime && (
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
Last Update: {lastUpdateTime.toLocaleTimeString()}
</span>
)}
</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-xl border p-4 ${isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'}`}
>
<div className="flex items-center justify-between">
<div>
<p className={`text-xs font-medium mb-1 ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
{title}
</p>
<p className={`text-2xl font-bold tabular-nums ${isDark ? 'text-white' : 'text-slate-900'}`}>
{value}
</p>
</div>
<div className={`${color} w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0`}>
<FontAwesomeIcon icon={icon} className="text-white" />
</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);
useEffect(() => {
isPausedRef.current = isPaused;
}, [isPaused]);
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 {};
}
}, []);
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';
}> = [];
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
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);
}
});
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;
}
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}`;
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 - Mantis</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 - Mantis</title>
<meta name="description" content="Mantis Network Traffic Geographic Map" />
<link href='https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css' rel='stylesheet' />
</Head>
<Layout>
<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-2 lg:grid-cols-4 gap-4 mb-5">
<StatsCard title="Total Traffic" value={stats.totalBytes} icon={faNetworkWired} color="bg-[#4ab5cc]" />
<StatsCard title="Total Packets" value={stats.totalPackets} icon={faLayerGroup} color="bg-green-500" />
<StatsCard title="Unique IPs" value={stats.uniqueIPs} icon={faGlobe} color="bg-indigo-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-xl border overflow-hidden ${isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'}`}
>
<MapComponent points={mapPoints} isDark={isDark} />
</motion.div>
</Layout>
</>
);
};
export default TrafficMap;

389
src/pages/training.tsx Normal file
View File

@ -0,0 +1,389 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faServer,
faPlus,
faTrash,
faCircle,
faPlay,
faDatabase,
faSpinner,
} from '@fortawesome/free-solid-svg-icons'
import { useTheme } from '../providers/ThemeProvider'
import { urls, trainerNodeUrls, trainerNodeWsUrl } from '../config'
import { fetchData, postData, deleteData } from '../utils/connectionUtils'
import Layout from '../components/Layout'
import {
TrainerNode,
TrainerNodeListResponse,
Dataset,
DatasetListResponse,
TrainingJob,
JobListResponse,
JobSocketMessage,
} from '../types/TrainingTypes'
const STATUS_STYLES: Record<string, string> = {
pending: 'text-slate-400',
running: 'text-blue-400',
succeeded: 'text-green-400',
failed: 'text-red-400',
}
// Trainer nodes are arbitrary user-registered addresses with no auth of their
// own, so all calls here go straight to the node's host:port with a plain
// fetch -- never with the main backend's Authorization header.
function useTrainerNodeSocket(
node: TrainerNode | null,
jobId: string | null,
onMessage: (msg: JobSocketMessage) => void
) {
const onMessageRef = useRef(onMessage)
useEffect(() => { onMessageRef.current = onMessage }, [onMessage])
const [connected, setConnected] = useState(false)
useEffect(() => {
if (!node || !jobId) {
setConnected(false)
return
}
const ws = new WebSocket(trainerNodeWsUrl(node.host, node.port, jobId))
ws.onopen = () => setConnected(true)
ws.onclose = () => setConnected(false)
ws.onerror = () => ws.close()
ws.onmessage = (e) => {
try {
onMessageRef.current(JSON.parse(e.data))
} catch {}
}
return () => ws.close()
}, [node?.host, node?.port, jobId])
return connected
}
const TrainingPage: React.FC = () => {
const { actualTheme } = useTheme()
const isDark = actualTheme === 'dark'
const [nodes, setNodes] = useState<TrainerNode[]>([])
const [nodesLoading, setNodesLoading] = useState(true)
const [newNode, setNewNode] = useState({ name: '', host: '', port: '8000' })
const [selectedNode, setSelectedNode] = useState<TrainerNode | null>(null)
const [datasets, setDatasets] = useState<Dataset[]>([])
const [jobs, setJobs] = useState<TrainingJob[]>([])
const [kaggleId, setKaggleId] = useState('')
const [activeJobId, setActiveJobId] = useState<string | null>(null)
const [jobLogs, setJobLogs] = useState<string[]>([])
const [jobLive, setJobLive] = useState<{ status: string; current_stage: string | null } | null>(null)
const logsBottomRef = useRef<HTMLDivElement>(null)
const loadNodes = useCallback(() => {
setNodesLoading(true)
fetchData(
urls.training.nodes,
(raw) => {
const parsed: TrainerNodeListResponse = JSON.parse(raw)
setNodes(parsed.items)
setNodesLoading(false)
},
() => setNodesLoading(false)
)
}, [])
useEffect(() => { loadNodes() }, [loadNodes])
const addNode = useCallback(() => {
const port = parseInt(newNode.port, 10)
if (!newNode.name.trim() || !newNode.host.trim() || !port) return
postData(
urls.training.nodes,
{ name: newNode.name.trim(), host: newNode.host.trim(), port },
() => {
setNewNode({ name: '', host: '', port: '8000' })
loadNodes()
}
)
}, [newNode, loadNodes])
const removeNode = useCallback((id: string) => {
deleteData(urls.training.node(id), {}, () => {
if (selectedNode?.id === id) setSelectedNode(null)
loadNodes()
})
}, [selectedNode, loadNodes])
const loadNodeState = useCallback((node: TrainerNode) => {
const nodeUrls = trainerNodeUrls(node.host, node.port)
fetch(nodeUrls.datasets)
.then(r => r.json())
.then((parsed: DatasetListResponse) => setDatasets(parsed.items))
.catch(() => setDatasets([]))
fetch(nodeUrls.jobs)
.then(r => r.json())
.then((parsed: JobListResponse) => setJobs(parsed.items))
.catch(() => setJobs([]))
}, [])
const selectNode = useCallback((node: TrainerNode) => {
setSelectedNode(node)
setActiveJobId(null)
setJobLogs([])
setJobLive(null)
loadNodeState(node)
}, [loadNodeState])
const createDataset = useCallback(() => {
if (!selectedNode || !kaggleId.trim()) return
const form = new FormData()
form.append('name', kaggleId.trim().split('/').pop() || kaggleId.trim())
form.append('kaggle_dataset_id', kaggleId.trim())
fetch(trainerNodeUrls(selectedNode.host, selectedNode.port).datasets, {
method: 'POST',
body: form,
})
.then(() => {
setKaggleId('')
loadNodeState(selectedNode)
})
.catch(() => {})
}, [selectedNode, kaggleId, loadNodeState])
const startTraining = useCallback((datasetId: string) => {
if (!selectedNode) return
fetch(trainerNodeUrls(selectedNode.host, selectedNode.port).train(datasetId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
.then(r => r.json())
.then((job: TrainingJob) => {
setActiveJobId(job.id)
setJobLogs([])
setJobLive({ status: job.status, current_stage: job.current_stage })
loadNodeState(selectedNode)
})
.catch(() => {})
}, [selectedNode, loadNodeState])
const handleSocketMessage = useCallback((msg: JobSocketMessage) => {
if (msg.type === 'status') {
setJobLive({ status: msg.status, current_stage: msg.current_stage })
if (msg.status === 'succeeded' || msg.status === 'failed') {
if (selectedNode) loadNodeState(selectedNode)
}
} else if (msg.type === 'log') {
setJobLogs(prev => [...prev, msg.log_line])
}
}, [selectedNode, loadNodeState])
const wsConnected = useTrainerNodeSocket(selectedNode, activeJobId, handleSocketMessage)
useEffect(() => {
logsBottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [jobLogs])
const panelClass = `rounded-xl border ${isDark ? 'bg-[#0e1e2c] border-slate-700/40' : 'bg-white border-slate-200'}`
return (
<Layout title="Training - Mantis" description="Manage Mantis-Trainer nodes">
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-4">
{/* Node registry */}
<div className={`${panelClass} p-4 flex flex-col gap-3`}>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faServer} className="text-[#4ab5cc]" />
<h2 className={`text-sm font-semibold ${isDark ? 'text-slate-200' : 'text-slate-800'}`}>
Trainer Nodes
</h2>
</div>
<div className="flex flex-col gap-1.5 max-h-80 overflow-y-auto">
{nodesLoading && (
<p className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>Loading...</p>
)}
{!nodesLoading && nodes.length === 0 && (
<p className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>No nodes registered yet.</p>
)}
{nodes.map(node => (
<div
key={node.id}
onClick={() => selectNode(node)}
className={`flex items-center justify-between px-2.5 py-2 rounded-lg cursor-pointer text-xs transition-colors ${
selectedNode?.id === node.id
? 'bg-[#4ab5cc]/15 text-[#4ab5cc]'
: isDark ? 'text-slate-300 hover:bg-slate-800/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<div className="min-w-0">
<p className="font-medium truncate">{node.name}</p>
<p className={`truncate ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>{node.host}:{node.port}</p>
</div>
<button
onClick={(e) => { e.stopPropagation(); removeNode(node.id) }}
className="flex-shrink-0 ml-2 opacity-60 hover:opacity-100 hover:text-red-400"
>
<FontAwesomeIcon icon={faTrash} className="text-xs" />
</button>
</div>
))}
</div>
<div className={`pt-3 border-t flex flex-col gap-2 ${isDark ? 'border-slate-700/40' : 'border-slate-200'}`}>
<input
placeholder="Name"
value={newNode.name}
onChange={e => setNewNode(n => ({ ...n, name: e.target.value }))}
className={`px-2.5 py-1.5 rounded-lg text-xs border outline-none ${
isDark ? 'bg-[#131929] border-slate-700/50 text-slate-200' : 'bg-slate-50 border-slate-200 text-slate-700'
}`}
/>
<div className="flex gap-2">
<input
placeholder="Host"
value={newNode.host}
onChange={e => setNewNode(n => ({ ...n, host: e.target.value }))}
className={`flex-1 min-w-0 px-2.5 py-1.5 rounded-lg text-xs border outline-none ${
isDark ? 'bg-[#131929] border-slate-700/50 text-slate-200' : 'bg-slate-50 border-slate-200 text-slate-700'
}`}
/>
<input
placeholder="Port"
value={newNode.port}
onChange={e => setNewNode(n => ({ ...n, port: e.target.value }))}
className={`w-16 px-2.5 py-1.5 rounded-lg text-xs border outline-none ${
isDark ? 'bg-[#131929] border-slate-700/50 text-slate-200' : 'bg-slate-50 border-slate-200 text-slate-700'
}`}
/>
</div>
<button
onClick={addNode}
className="flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium bg-[#4ab5cc]/15 text-[#4ab5cc] hover:bg-[#4ab5cc]/25 transition-colors"
>
<FontAwesomeIcon icon={faPlus} className="text-xs" />
Register Node
</button>
</div>
</div>
{/* Node detail */}
{!selectedNode ? (
<div className={`${panelClass} flex items-center justify-center h-64`}>
<p className={`text-sm ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
Select a trainer node to view its datasets and jobs.
</p>
</div>
) : (
<div className="flex flex-col gap-4">
<div className={`${panelClass} p-4 flex flex-col gap-3`}>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faDatabase} className="text-[#4ab5cc]" />
<h2 className={`text-sm font-semibold ${isDark ? 'text-slate-200' : 'text-slate-800'}`}>
Datasets on {selectedNode.name}
</h2>
</div>
<div className="flex gap-2">
<input
placeholder="Kaggle dataset id (e.g. owner/dataset-name)"
value={kaggleId}
onChange={e => setKaggleId(e.target.value)}
className={`flex-1 min-w-0 px-2.5 py-1.5 rounded-lg text-xs border outline-none ${
isDark ? 'bg-[#131929] border-slate-700/50 text-slate-200' : 'bg-slate-50 border-slate-200 text-slate-700'
}`}
/>
<button
onClick={createDataset}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[#4ab5cc]/15 text-[#4ab5cc] hover:bg-[#4ab5cc]/25 transition-colors"
>
Add
</button>
</div>
<div className="flex flex-col gap-1.5">
{datasets.length === 0 && (
<p className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>No datasets yet.</p>
)}
{datasets.map(ds => (
<div
key={ds.id}
className={`flex items-center justify-between px-2.5 py-2 rounded-lg text-xs ${
isDark ? 'bg-[#131929]' : 'bg-slate-50'
}`}
>
<div className="min-w-0">
<p className={`font-medium truncate ${isDark ? 'text-slate-200' : 'text-slate-700'}`}>{ds.name}</p>
<p className={isDark ? 'text-slate-500' : 'text-slate-400'}>{ds.row_count} rows · {ds.source}</p>
</div>
<button
onClick={() => startTraining(ds.id)}
className="flex-shrink-0 flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-medium bg-green-500/15 text-green-400 hover:bg-green-500/25 transition-colors"
>
<FontAwesomeIcon icon={faPlay} className="text-[10px]" />
Train
</button>
</div>
))}
</div>
</div>
<div className={`${panelClass} p-4 flex flex-col gap-3`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faSpinner} className={`text-[#4ab5cc] ${jobLive?.status === 'running' ? 'animate-spin' : ''}`} />
<h2 className={`text-sm font-semibold ${isDark ? 'text-slate-200' : 'text-slate-800'}`}>
Live Job
</h2>
</div>
{activeJobId && (
<div className="flex items-center gap-1.5 text-xs">
<FontAwesomeIcon icon={faCircle} className={`text-[8px] ${wsConnected ? 'text-green-400' : 'text-red-400'}`} />
<span className={STATUS_STYLES[jobLive?.status ?? ''] ?? (isDark ? 'text-slate-400' : 'text-slate-500')}>
{jobLive?.status ?? 'connecting'}{jobLive?.current_stage ? ` (${jobLive.current_stage})` : ''}
</span>
</div>
)}
</div>
{!activeJobId ? (
<p className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
Start training on a dataset above to see live logs here.
</p>
) : (
<div className={`h-64 overflow-y-auto rounded-lg font-mono text-[11px] p-3 ${isDark ? 'bg-[#0b0f17]' : 'bg-slate-50'}`}>
{jobLogs.map((line, i) => (
<div key={i} className={isDark ? 'text-slate-300' : 'text-slate-700'}>{line}</div>
))}
<div ref={logsBottomRef} />
</div>
)}
</div>
{jobs.length > 0 && (
<div className={`${panelClass} p-4 flex flex-col gap-2`}>
<h2 className={`text-sm font-semibold ${isDark ? 'text-slate-200' : 'text-slate-800'}`}>Past Jobs</h2>
{jobs.map(job => (
<div
key={job.id}
onClick={() => { setActiveJobId(job.id); setJobLogs([]); setJobLive({ status: job.status, current_stage: job.current_stage }) }}
className={`flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs cursor-pointer ${
isDark ? 'hover:bg-slate-800/60' : 'hover:bg-slate-100'
}`}
>
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>{job.id.slice(0, 8)}</span>
<span className={STATUS_STYLES[job.status] ?? ''}>{job.status}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</Layout>
)
}
export default TrainingPage

View File

@ -0,0 +1,248 @@
import React, { createContext, useState, useCallback, useMemo, useRef } from 'react'
import { fetchData } from '../utils/connectionUtils'
import { urls, NicType, FlowType, ListType, IpVersion } 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: ListType, nic: NicType, flow: FlowType) => void;
refreshData: (isIPv6?: boolean, listType?: ListType, nic?: NicType, flow?: FlowType) => 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
}
const CACHE_EXPIRE_TIME = 10 * 60 * 1000;
const getCacheKey = (isIPv6: boolean, listType: string, nic: string, flow: string): string => {
return `${nic}_${isIPv6 ? 'ipv6' : 'ipv4'}_${flow}_${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>('')
const pendingRequests = useRef<Set<string>>(new Set())
const fetchCachedData = useCallback(async (
isIPv6: boolean,
listType: ListType,
nic: NicType,
flow: FlowType,
forceRefresh: boolean = false
): Promise<AccessControlItem[]> => {
const key = getCacheKey(isIPv6, listType, nic, flow);
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 ipVersion: IpVersion = isIPv6 ? 'ipv6' : 'ipv4';
const url = urls.access_control(nic, ipVersion, flow, listType);
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]);
const switchTo = useCallback(async (isIPv6: boolean, listType: ListType, nic: NicType, flow: FlowType) => {
const key = getCacheKey(isIPv6, listType, nic, flow);
setCurrentKey(key);
try {
const data = await fetchCachedData(isIPv6, listType, nic, flow);
setFilteredData(data);
} catch (error) {
console.error(`Failed to switch to ${key}:`, error);
setFilteredData([]);
}
}, [fetchCachedData]);
const refreshData = useCallback(async (
isIPv6?: boolean,
listType?: ListType,
nic?: NicType,
flow?: FlowType
) => {
if (isIPv6 === undefined || listType === undefined || nic === undefined || flow === undefined) {
if (!currentKey) return;
const parts = currentKey.split('_');
const resolvedNic = parts[0] as NicType;
const isV6 = parts[1] === 'ipv6';
const resolvedFlow = parts[2] as FlowType;
const resolvedList = parts[3] as ListType;
await switchTo(isV6, resolvedList, resolvedNic, resolvedFlow);
return;
}
try {
const data = await fetchCachedData(isIPv6, listType, nic, flow, true);
const key = getCacheKey(isIPv6, listType, nic, flow);
if (key === currentKey) {
setFilteredData(data);
}
} catch (error) {
console.error(`Failed to refresh data:`, 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]);
React.useEffect(() => {
const preloadKey = getCacheKey(false, 'black_list', 'ingress', 'source');
if (!cache[preloadKey]) {
fetchCachedData(false, 'black_list', 'ingress', 'source').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]) => {
if (now - item.timestamp < 30 * 60 * 1000) {
cleaned[key] = item;
}
});
return cleaned;
});
}, 5 * 60 * 1000);
return () => clearInterval(cleanupInterval);
}, []);
const contextValue = useMemo((): AccessControlContextType => ({
currentData,
filteredData,
isLoading,
setFilteredData,
switchTo,
refreshData,
getCacheStatus,
}), [currentData, filteredData, isLoading, switchTo, refreshData, getCacheStatus]);
return (
<AccessControlContext.Provider value={contextValue}>
{children}
</AccessControlContext.Provider>
);
};

View File

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

View File

@ -0,0 +1,198 @@
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;
wsConnectedCount: number;
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,
wsConnectedCount: 0,
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 [wsConnectedCount, setWsConnectedCount] = useState(0)
const connectedCountRef = useRef(0)
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]
connectedCountRef.current = Math.max(0, connectedCountRef.current - 1)
setWsConnectedCount(connectedCountRef.current)
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
connectedCountRef.current++
setWsConnectedCount(connectedCountRef.current)
}
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,
wsConnectedCount,
getDetectionAlertStream,
getIPv4FlowStream,
getIPv6FlowStream,
getSystemHealthStream,
getLatestFlowData,
getLatestSystemHealth,
}}>
{children}
</WebsocketContext.Provider>
)
}

67
src/styles/globals.css Normal file
View File

@ -0,0 +1,67 @@
@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;
}
/* Light mode: html/body background + scrollbar color-scheme */
html, body {
color-scheme: light;
background-color: #f1f5f9;
scrollbar-color: rgba(74, 181, 204, 0.30) transparent;
}
/* Dark mode driven by .dark class set by ThemeProvider */
html.dark, html.dark body {
color-scheme: dark;
background-color: #0c2130;
scrollbar-color: rgba(74, 181, 204, 0.20) transparent;
}
/* Scrollbar — slim, slate-sky palette */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(74, 181, 204, 0.30);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(74, 181, 204, 0.55);
}
html.dark ::-webkit-scrollbar-thumb {
background: rgba(74, 181, 204, 0.20);
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(74, 181, 204, 0.45);
}

View File

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

View File

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

View File

@ -0,0 +1,35 @@
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
}

85
src/types/HomeTypes.tsx Normal file
View File

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

View File

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

View File

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

View File

@ -0,0 +1,61 @@
export interface TrainerNode {
id: string
name: string
host: string
port: number
created_at: number
}
export interface TrainerNodeListResponse {
items: TrainerNode[]
}
export type DatasetSource = 'kaggle' | 'upload'
export interface Dataset {
id: string
name: string
source: DatasetSource
row_count: number
created_at: string
}
export interface DatasetListResponse {
items: Dataset[]
}
export type JobStatus = 'pending' | 'running' | 'succeeded' | 'failed'
export interface TrainingJob {
id: string
dataset_id: string
stages: string[]
status: JobStatus
current_stage: string | null
error: string | null
created_at: string
started_at: string | null
finished_at: string | null
plots: string[]
}
export interface JobListResponse {
items: TrainingJob[]
}
export interface JobStatusMessage {
type: 'status'
job_id: string
status: JobStatus
current_stage: string | null
timestamp: number
}
export interface JobLogMessage {
type: 'log'
job_id: string
log_line: string
timestamp: number
}
export type JobSocketMessage = JobStatusMessage | JobLogMessage

12
src/utils/authStore.ts Normal file
View File

@ -0,0 +1,12 @@
let _token: string | null = null;
export const setAuthToken = (token: string | null): void => {
_token = token;
};
export const getAuthToken = (): string | null => _token;
export const getAuthHeaders = (): Record<string, string> => {
if (!_token) return {};
return { Authorization: `Bearer ${_token}` };
};

View File

@ -0,0 +1,125 @@
import { getAuthHeaders } from './authStore'
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, { headers: getAuthHeaders() })
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 postData = async (
url: string,
payload: any,
onSuccess?: (data?: any) => void,
onError?: (error: Error) => void
): Promise<void> => {
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
},
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 POST request to URL: ${url}`, error)
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",
...getAuthHeaders(),
},
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",
...getAuthHeaders(),
},
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
start.bat Normal file
View File

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

83
start.sh Normal file
View 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
tailwind.config.js Normal file
View 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
tsconfig.json Normal file
View 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"
]
}