mirror of
https://github.com/ParrotXray/Mantis-Frontend.git
synced 2026-08-24 15:50:29 +09:00
feat: revert main
This commit is contained in:
commit
a58a150986
15
.claude/commands/research.md
Normal file
15
.claude/commands/research.md
Normal 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
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
57
CLAUDE.md
Normal 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
101
README.md
Normal 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
6
next-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
29
next.config.js
Normal file
29
next.config.js
Normal file
@ -0,0 +1,29 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
trailingSlash: false,
|
||||
output: 'export',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
// {
|
||||
// protocol: 'https',
|
||||
// hostname: 'cdn.discordapp.com',
|
||||
// pathname: '/avatars/**',
|
||||
// },
|
||||
],
|
||||
},
|
||||
|
||||
// i18n: {
|
||||
// locales: ['tw', 'cn', 'ja', 'en'],
|
||||
// defaultLocale: 'tw',
|
||||
// localeDetection: true,
|
||||
// },
|
||||
//
|
||||
// allowedDevOrigins: [
|
||||
// '192.168.1.118',
|
||||
// 'localhost',
|
||||
// '127.0.0.1',
|
||||
// ],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
4902
package-lock.json
generated
Normal file
4902
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/mantis-logo.png
Normal file
BIN
public/mantis-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
104
server.js
Normal file
104
server.js
Normal file
@ -0,0 +1,104 @@
|
||||
// server.js - HTTPS server with English logs
|
||||
import { createServer } from 'https';
|
||||
import { parse } from 'url';
|
||||
import next from 'next';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// ES Module 中取得 __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Determine environment based on npm script
|
||||
const scriptName = process.env.npm_lifecycle_event;
|
||||
let dev;
|
||||
|
||||
if (scriptName === 'dev:https') {
|
||||
dev = true;
|
||||
console.log('Development mode');
|
||||
} else if (scriptName === 'start:https') {
|
||||
dev = false;
|
||||
console.log('Production mode');
|
||||
} else {
|
||||
// Default to production mode
|
||||
dev = false;
|
||||
console.log('Unknown script, using production mode');
|
||||
}
|
||||
|
||||
const hostname = '0.0.0.0';
|
||||
const port = 3000;
|
||||
|
||||
const app = next({ dev, hostname, port });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
// SSL certificate file paths
|
||||
const sslFiles = {
|
||||
ca: path.join(__dirname, 'ssl/root.ca-bundle'),
|
||||
cert: path.join(__dirname, 'ssl/server.crt'),
|
||||
key: path.join(__dirname, 'ssl/server.key')
|
||||
};
|
||||
|
||||
// Check and load SSL certificates
|
||||
function loadSSLCertificates() {
|
||||
try {
|
||||
// Check if all certificate files exist
|
||||
for (const [type, filePath] of Object.entries(sslFiles)) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`SSL ${type} file not found: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All SSL certificate files found');
|
||||
|
||||
return {
|
||||
ca: fs.readFileSync(sslFiles.ca),
|
||||
cert: fs.readFileSync(sslFiles.cert),
|
||||
key: fs.readFileSync(sslFiles.key)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load SSL certificates:', error.message);
|
||||
console.error('Please ensure the following files exist:');
|
||||
console.error(' - ssl/*.ca-bundle');
|
||||
console.error(' - ssl/*.crt');
|
||||
console.error(' - ssl/*.key');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the application
|
||||
app.prepare().then(() => {
|
||||
const httpsOptions = loadSSLCertificates();
|
||||
|
||||
createServer(httpsOptions, (req, res) => {
|
||||
const parsedUrl = parse(req.url, true);
|
||||
handle(req, res, parsedUrl);
|
||||
}).listen(port, hostname, (err) => {
|
||||
if (err) {
|
||||
console.error('Failed to start HTTPS server:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`HTTPS Server ready on https://${hostname}:${port}`);
|
||||
|
||||
if (dev) {
|
||||
console.log('Development mode started with hot reload support');
|
||||
} else {
|
||||
console.log('Production mode started with optimized performance');
|
||||
}
|
||||
});
|
||||
|
||||
}).catch((ex) => {
|
||||
console.error('Failed to start Next.js application:', ex.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('Shutting down server...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Shutting down server...');
|
||||
process.exit(0);
|
||||
});
|
||||
55
src/components/ErrorDialog.tsx
Normal file
55
src/components/ErrorDialog.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface ErrorDialogProps {
|
||||
error: Error
|
||||
resetErrorBoundary: () => void
|
||||
}
|
||||
|
||||
const ErrorDialog: React.FC<ErrorDialogProps> = ({ error, resetErrorBoundary }) => {
|
||||
const handleClose = () => {
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content max-w-md">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className="text-red-500 text-2xl" />
|
||||
<h2 className="text-xl font-semibold text-gray-800">Oops! Something went wrong</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-600 mb-3">
|
||||
We encountered an unexpected error. Here are the details:
|
||||
</p>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<code className="text-red-700 text-sm break-all">
|
||||
{error ? error.toString() : 'Something went wrong'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Go Home
|
||||
</button>
|
||||
<button
|
||||
onClick={resetErrorBoundary}
|
||||
className="btn-primary"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorDialog
|
||||
49
src/components/Layout.tsx
Normal file
49
src/components/Layout.tsx
Normal 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
|
||||
11
src/components/LoadingSpinner.tsx
Normal file
11
src/components/LoadingSpinner.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
const LoadingSpinner: React.FC = () => {
|
||||
return (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#4ab5cc]"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingSpinner
|
||||
467
src/components/Sidebar.tsx
Normal file
467
src/components/Sidebar.tsx
Normal 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
|
||||
167
src/components/ThemeToggle.tsx
Normal file
167
src/components/ThemeToggle.tsx
Normal 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
70
src/config.ts
Normal 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;
|
||||
192
src/contexts/AuthContext.tsx
Normal file
192
src/contexts/AuthContext.tsx
Normal 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
211
src/pages/404.tsx
Normal 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
26
src/pages/_app.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
712
src/pages/access-control.tsx
Normal file
712
src/pages/access-control.tsx
Normal 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
1061
src/pages/dashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
799
src/pages/detection.tsx
Normal file
799
src/pages/detection.tsx
Normal 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
1127
src/pages/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
211
src/pages/login.tsx
Normal file
211
src/pages/login.tsx
Normal 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
281
src/pages/logs.tsx
Normal 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
255
src/pages/setup.tsx
Normal 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
829
src/pages/statistics.tsx
Normal 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
701
src/pages/traffic-map.tsx
Normal 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
389
src/pages/training.tsx
Normal 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
|
||||
248
src/providers/AccessControlProvider.tsx
Normal file
248
src/providers/AccessControlProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
114
src/providers/ThemeProvider.tsx
Normal file
114
src/providers/ThemeProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
198
src/providers/WebSocketProvider.tsx
Normal file
198
src/providers/WebSocketProvider.tsx
Normal 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
67
src/styles/globals.css
Normal 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);
|
||||
}
|
||||
49
src/types/AIDetectionTypes.tsx
Normal file
49
src/types/AIDetectionTypes.tsx
Normal 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
|
||||
}
|
||||
33
src/types/AccessControlTypes.tsx
Normal file
33
src/types/AccessControlTypes.tsx
Normal 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
|
||||
}
|
||||
35
src/types/DashboardTypes.tsx
Normal file
35
src/types/DashboardTypes.tsx
Normal 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
85
src/types/HomeTypes.tsx
Normal 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']
|
||||
}
|
||||
57
src/types/StatisticsTypes.tsx
Normal file
57
src/types/StatisticsTypes.tsx
Normal 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;
|
||||
}
|
||||
53
src/types/TrafficMapTypes.tsx
Normal file
53
src/types/TrafficMapTypes.tsx
Normal 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;
|
||||
}
|
||||
61
src/types/TrainingTypes.tsx
Normal file
61
src/types/TrainingTypes.tsx
Normal 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
12
src/utils/authStore.ts
Normal 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}` };
|
||||
};
|
||||
125
src/utils/connectionUtils.ts
Normal file
125
src/utils/connectionUtils.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
83
start.sh
Normal file
83
start.sh
Normal file
@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 切換到工作目錄
|
||||
cd /home
|
||||
|
||||
# 檢查是否為首次安裝(通過檢查是否存在特定標記文件)
|
||||
if [ ! -f ".setup_complete" ]; then
|
||||
echo "First installation detected, starting system initialization..."
|
||||
|
||||
# 更新系統套件
|
||||
apt-get update
|
||||
apt-get upgrade -y
|
||||
|
||||
# 安裝必要的系統套件
|
||||
apt install tzdata -y
|
||||
apt install -y ca-certificates curl gnupg
|
||||
|
||||
echo "Installing Node.js LTS (v22.x)..."
|
||||
|
||||
# 創建 keyrings 目錄
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
|
||||
# 下載並添加 NodeSource GPG key
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||
|
||||
# 設定 Node.js 主要版本號
|
||||
NODE_MAJOR=22
|
||||
|
||||
# 添加 NodeSource repository
|
||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
|
||||
|
||||
# 更新套件索引
|
||||
apt update
|
||||
|
||||
# 安裝 Node.js (包含 npm)
|
||||
apt install nodejs -y
|
||||
|
||||
# 驗證安裝
|
||||
echo "Node.js installation completed!"
|
||||
echo "Node.js version: $(node --version)"
|
||||
echo "npm version: $(npm --version)"
|
||||
|
||||
# 創建安裝完成標記
|
||||
touch .setup_complete
|
||||
echo "System initialization completed!"
|
||||
else
|
||||
echo "System initialization already completed, skipping installation steps..."
|
||||
echo "Current Node.js version: $(node --version)"
|
||||
echo "Current npm version: $(npm --version)"
|
||||
fi
|
||||
|
||||
# 安裝/更新 npm 依賴套件
|
||||
echo "Installing/updating npm dependencies..."
|
||||
if [ -f "package.json" ]; then
|
||||
npm install
|
||||
else
|
||||
echo "No package.json found, skipping npm install..."
|
||||
fi
|
||||
|
||||
# 運行應用程式
|
||||
echo "Starting application..."
|
||||
if [ -f "package.json" ]; then
|
||||
# 檢查是否有 build script
|
||||
if grep -q '"build"' package.json 2>/dev/null; then
|
||||
echo "Running build script..."
|
||||
npm run build
|
||||
else
|
||||
echo "No build script found, skipping build step..."
|
||||
fi
|
||||
|
||||
# 檢查是否有 start script
|
||||
if grep -q '"start:https"' package.json 2>/dev/null; then
|
||||
echo "Running start script for https..."
|
||||
npm run start:https
|
||||
elif grep -q '"start"' package.json 2>/dev/null; then
|
||||
echo "Running start script..."
|
||||
npm run start
|
||||
else
|
||||
echo "No start script found, please configure your package.json start script..."
|
||||
fi
|
||||
else
|
||||
echo "No package.json found, please initialize your Node.js project first with 'npm init'"
|
||||
fi
|
||||
14
tailwind.config.js
Normal file
14
tailwind.config.js
Normal file
@ -0,0 +1,14 @@
|
||||
import typography from '@tailwindcss/typography';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [typography],
|
||||
}
|
||||
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2024",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"module": "esnext",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user