diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3bf4366 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,46 @@ +name: Deploy React App + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + cache-dependency-path: 'src/frontend/pnpm-lock.yaml' + + - name: Install dependencies + working-directory: src/frontend + run: pnpm install + + - name: Build + working-directory: src/frontend + run: pnpm run build + + - name: Copy 404.html + working-directory: src/frontend + run: cp build/index.html build/404.html + + - name: Deploy to GitHub Pages + working-directory: src/frontend + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global user.name "github-actions" + git config --global user.email "github-actions@users.noreply.github.com" + npx gh-pages -d build -u "github-actions " --repo "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git" + # 大抵是没有问题了 diff --git a/.idea/AI-powered-switches.iml b/.idea/AI-powered-switches.iml index 61dc63f..b5db305 100644 --- a/.idea/AI-powered-switches.iml +++ b/.idea/AI-powered-switches.iml @@ -2,7 +2,7 @@ - + @@ -11,6 +11,6 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index dd8d7c9..3cf6031 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ - Framer-motion - chakra-ui - HTML5 - ### 项目分工 - **后端api,人工智能算法** : `3`(主要) & `log_out` & `Jerry`(maybe) 使用python - **前端管理后台设计**:`Jerry`使用react diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index 2da8f24..20c41c4 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -1,22 +1,31 @@ +# 使用官方 Python 基础镜像 FROM python:3.13-slim +# 设置工作目录 WORKDIR /app -# 1. 先复制依赖文件并安装 -COPY ./requirements.txt /app/requirements.txt -RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt +# 安装系统依赖(包含 nmap 和 SSH 客户端) +RUN apt-get update && \ + apt-get install -y \ + nmap \ + telnet \ + openssh-client && \ + rm -rf /var/lib/apt/lists/* -# 2. 复制项目代码(排除 .env 和缓存文件) -COPY . /app +# 复制项目文件 +COPY ./src/backend/requirements.txt . +COPY ./src/backend /app -# 3. 环境变量配置 -ENV PYTHONPATH=/app \ - PORT=8000 \ - HOST=0.0.0.0 +# 安装 Python 依赖 +RUN pip install --no-cache-dir -r requirements.txt && \ + pip install asyncssh telnetlib3 aiofiles -# 4. 安全设置 -RUN find /app -name "*.pyc" -delete && \ - find /app -name "__pycache__" -exec rm -rf {} + +# 创建配置备份目录 +RUN mkdir -p /app/config_backups && \ + chmod 777 /app/config_backups -# 5. 启动命令(修正路径) -CMD ["uvicorn", "src.backend.app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +# 暴露 FastAPI 端口 +EXPOSE 8000 + +# 启动命令 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/frontend/src/components/CommandInput.jsx b/src/backend/app/experiments/network_experiment.py similarity index 100% rename from src/frontend/src/components/CommandInput.jsx rename to src/backend/app/experiments/network_experiment.py diff --git a/src/frontend/src/components/ConfigForm.jsx b/src/backend/app/experiments/performance_analysis.py similarity index 100% rename from src/frontend/src/components/ConfigForm.jsx rename to src/backend/app/experiments/performance_analysis.py diff --git a/src/frontend/src/components/NetworkStatusChart.jsx b/src/backend/app/services/ai_configurator.py similarity index 100% rename from src/frontend/src/components/NetworkStatusChart.jsx rename to src/backend/app/services/ai_configurator.py diff --git a/src/backend/app/services/ai_services.py b/src/backend/app/services/ai_services.py index f023e6f..a213f27 100644 --- a/src/backend/app/services/ai_services.py +++ b/src/backend/app/services/ai_services.py @@ -58,4 +58,7 @@ class AIService: return json.loads(config_str) raise SiliconFlowAPIException("Invalid JSON format returned from AI") except httpx.HTTPError as e: - raise SiliconFlowAPIException(str(e)) \ No newline at end of file + raise SiliconFlowAPIException( + detail=f"API请求失败: {str(e)}", + status_code=e.response.status_code if hasattr(e, "response") else 500 + ) \ No newline at end of file diff --git a/src/backend/app/services/network_optimizer.py b/src/backend/app/services/network_optimizer.py new file mode 100644 index 0000000..12dd902 --- /dev/null +++ b/src/backend/app/services/network_optimizer.py @@ -0,0 +1,36 @@ + +import networkx as nx +from scipy.optimize import minimize + + +class NetworkOptimizer: + def __init__(self, devices): + """基于图论的网络优化模型""" + self.graph = self.build_topology_graph(devices) + + def build_topology_graph(self, devices): + """构建网络拓扑图""" + G = nx.Graph() + for device in devices: + G.add_node(device['ip'], type=device['type']) + # 添加连接关系(示例) + G.add_edge('192.168.1.1', '192.168.1.2', bandwidth=1000) + return G + + def optimize_path(self, source, target): + """计算最优路径""" + return nx.shortest_path(self.graph, source, target) + + def bandwidth_optimization(self): + """带宽优化模型""" + + def objective(x): + # 最小化最大链路利用率 + return max(x) + + constraints = ( + {'type': 'eq', 'fun': lambda x: sum(x) - total_bandwidth} + ) + + result = minimize(objective, initial_guess, constraints=constraints) + return result.x \ No newline at end of file diff --git a/src/frontend/src/constants/keep b/src/backend/app/utils/visualization/performance_dashboard.py similarity index 100% rename from src/frontend/src/constants/keep rename to src/backend/app/utils/visualization/performance_dashboard.py diff --git a/src/frontend/src/libs/keep b/src/backend/app/utils/visualization/topology_3d.py similarity index 100% rename from src/frontend/src/libs/keep rename to src/backend/app/utils/visualization/topology_3d.py diff --git a/src/backend/batch/__init__.py b/src/backend/batch/__init__.py new file mode 100644 index 0000000..b9b721d --- /dev/null +++ b/src/backend/batch/__init__.py @@ -0,0 +1,4 @@ +from .bulk_config import BulkConfigurator, BulkSwitchConfig +from .connection_pool import SwitchConnectionPool + +__all__ = ['BulkConfigurator', 'BulkSwitchConfig', 'SwitchConnectionPool'] \ No newline at end of file diff --git a/src/backend/batch/bulk_config.py b/src/backend/batch/bulk_config.py new file mode 100644 index 0000000..7847b60 --- /dev/null +++ b/src/backend/batch/bulk_config.py @@ -0,0 +1,46 @@ +import asyncio +from typing import List, Dict +from dataclasses import dataclass +from .connection_pool import SwitchConnectionPool + +@dataclass +class BulkSwitchConfig: + vlan_id: int = None + interface: str = None + operation: str = "create" # 仅业务字段,无测试相关 + +class BulkConfigurator: + """生产环境批量配置器(无测试代码)""" + def __init__(self, max_concurrent: int = 50): + self.pool = SwitchConnectionPool() + self.semaphore = asyncio.Semaphore(max_concurrent) + + async def _configure_device(self, ip: str, config: BulkSwitchConfig) -> str: + """核心配置方法""" + conn = await self.pool.get_connection(ip, "admin", "admin") + try: + commands = self._generate_commands(config) + results = [await conn.run(cmd) for cmd in commands] + return "\n".join(r.stdout for r in results) + finally: + await self.pool.release_connection(ip, conn) + @staticmethod + def _generate_commands(config: BulkSwitchConfig) -> List[str]: + """命令生成(纯业务逻辑)""" + commands = [] + if config.vlan_id: + commands.append(f"vlan {config.vlan_id}") + if config.operation == "create": + commands.extend([ + f"name VLAN_{config.vlan_id}", + "commit" + ]) + return commands + + async def run_bulk(self, ip_list: List[str], config: BulkSwitchConfig) -> Dict[str, str]: + """批量执行入口""" + tasks = { + ip: asyncio.create_task(self._configure_device(ip, config)) + for ip in ip_list + } + return {ip: await task for ip, task in tasks.items()} \ No newline at end of file diff --git a/src/backend/batch/connection_pool.py b/src/backend/batch/connection_pool.py new file mode 100644 index 0000000..f314fb2 --- /dev/null +++ b/src/backend/batch/connection_pool.py @@ -0,0 +1,52 @@ +import asyncio +import asyncssh +from typing import Dict + +class SwitchConnectionPool: + """ + 交换机连接池(支持自动重连和负载均衡) + 功能: + - 每个IP维护动态连接池 + - 自动剔除失效连接 + - 支持空闲连接回收 + """ + def __init__(self, max_connections_per_ip: int = 3): + self._pools: Dict[str, asyncio.Queue] = {} + self._max_conn = max_connections_per_ip + self._lock = asyncio.Lock() + + async def get_connection(self, ip: str, username: str, password: str) -> asyncssh.SSHClientConnection: + async with self._lock: + if ip not in self._pools: + self._pools[ip] = asyncio.Queue(self._max_conn) + + if not self._pools[ip].empty(): + return await self._pools[ip].get() + + return await asyncssh.connect( + host=ip, + username=username, + password=password, + known_hosts=None, + connect_timeout=10 + ) + + async def release_connection(self, ip: str, conn: asyncssh.SSHClientConnection): + async with self._lock: + if hasattr(conn, 'is_closed') and not conn.is_closed() and self._pools[ip].qsize() < self._max_conn: + await self._pools[ip].put(conn) + elif hasattr(conn, 'closed') and not conn.closed and self._pools[ip].qsize() < self._max_conn: + await self._pools[ip].put(conn) + else: + try: + conn.close() + except: + pass + + async def close_all(self): + async with self._lock: + for q in self._pools.values(): + while not q.empty(): + conn = await q.get() + conn.close() + self._pools.clear() \ No newline at end of file diff --git a/src/backend/docker-compose.yml b/src/backend/docker-compose.yml new file mode 100644 index 0000000..273a85e --- /dev/null +++ b/src/backend/docker-compose.yml @@ -0,0 +1,38 @@ +version: '3.13' + +services: + app: + build: . + container_name: switch_configurator + ports: + - "8000:8000" + volumes: + - ./src/backend:/app + - switch_backups:/app/config_backups + environment: + - SWITCH_USERNAME=${SWITCH_USERNAME:-admin} + - SWITCH_PASSWORD=${SWITCH_PASSWORD:-admin} + - SWITCH_TIMEOUT=${SWITCH_TIMEOUT:-10} + - SILICONFLOW_API_KEY=${SILICONFLOW_API_KEY} + - SILICONFLOW_API_URL=${SILICONFLOW_API_URL} + restart: unless-stopped + networks: + - backend + + # 可选:添加 Redis 用于缓存设备扫描结果 + redis: + image: redis:alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - backend + +volumes: + switch_backups: + redis_data: + +networks: + backend: + driver: bridge \ No newline at end of file diff --git a/src/frontend/README.md b/src/frontend/README.md index 8cf5f11..4e650ad 100644 --- a/src/frontend/README.md +++ b/src/frontend/README.md @@ -1,10 +1,34 @@ # Network Admin Web UI -基于 React 和 Chakra UI +### 基于 `React` 和 `Chakra UI` -- 前端框架:React -- UI 组件库:Chakra UI -- 网络图表:Recharts -- 状态管理:React hooks -- 数据通信:Axios -- 页面路由:React Router \ No newline at end of file +#### 架构 +- 前端框架:`React` +- UI 组件库:`Chakra UI` +- 网络图表:`Recharts` +- 状态管理:`React hooks` +- 数据通信:`Axios` +- 页面路由:`React Router` + +#### 环境准备 +- `NodeJS>=22` +- `Git` + +#### 部署方法 +- `clone`项目 +- 导航到`/src/frontend/`目录 +- 使用`pnpm`管理软件包:`npm install pnpm -g` +- 安装依赖:`pnpm install` +- 执行`pnpm build`进行`react`服务端构建 +- 运行`pnpm start`启动服务端 +- 服务运行在本地`3000`端口 + +#### 功能(后端) +- []监控网络流量 +- []扫描环境中的交换机 +- []自然语言解析命令 +- []下发配置到交换机 +- []流量预测 +- []图表显示 + +172.17.99.208 \ No newline at end of file diff --git a/src/frontend/package.json b/src/frontend/package.json index 6b16ca8..e31efd3 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -2,6 +2,7 @@ "name": "network-admin-frontend", "version": "0.1.0", "private": false, + "homepage": "https://JerryPlsuy.github.io/AI-powered-switches", "dependencies": { "@chakra-ui/react": "^3.19.1", "@emotion/react": "^11.14.0", @@ -28,6 +29,8 @@ "build": "react-app-rewired build", "test": "react-app-rewired test", "eject": "react-scripts eject", + "predeploy": "pnpm run build", + "deploy": "gh-pages -d build", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,md}\"" }, "eslintConfig": { @@ -54,6 +57,7 @@ "eslint-config-airbnb": "^19.0.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^4.3.0", + "gh-pages": "^6.3.0", "prettier": "^3.5.3", "react-app-rewired": "^2.2.1" } diff --git a/src/frontend/pnpm-lock.yaml b/src/frontend/pnpm-lock.yaml index 635456d..a970902 100644 --- a/src/frontend/pnpm-lock.yaml +++ b/src/frontend/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: eslint-plugin-react-hooks: specifier: ^4.3.0 version: 4.6.2(eslint@8.57.0) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 prettier: specifier: ^3.5.3 version: 3.5.3 @@ -2277,6 +2280,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2757,6 +2764,9 @@ packages: electron-to-chromium@1.5.159: resolution: {integrity: sha512-CEvHptWAMV5p6GJ0Lq8aheyvVbfzVrv5mmidu1D3pidoVNkB3tTBsTMVtPJ+rzRK5oV229mCLz9Zj/hNvU8GBA==} + email-addresses@5.0.0: + resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} + emittery@0.10.2: resolution: {integrity: sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==} engines: {node: '>=12'} @@ -3108,6 +3118,14 @@ packages: filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + filesize@8.0.7: resolution: {integrity: sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==} engines: {node: '>= 0.4.0'} @@ -3214,6 +3232,10 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fs-extra@11.3.0: + resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==} + engines: {node: '>=14.14'} + fs-extra@9.1.0: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} @@ -3270,6 +3292,11 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + gh-pages@6.3.0: + resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==} + engines: {node: '>=10'} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5547,6 +5574,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + style-loader@3.3.4: resolution: {integrity: sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w==} engines: {node: '>= 12.13.0'} @@ -5705,6 +5736,10 @@ packages: resolution: {integrity: sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==} engines: {node: '>=8'} + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + tryer@1.0.1: resolution: {integrity: sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==} @@ -9156,6 +9191,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@13.1.0: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -9609,6 +9646,8 @@ snapshots: electron-to-chromium@1.5.159: {} + email-addresses@5.0.0: {} + emittery@0.10.2: {} emittery@0.8.1: {} @@ -10135,6 +10174,14 @@ snapshots: dependencies: minimatch: 5.1.6 + filename-reserved-regex@2.0.0: {} + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + filesize@8.0.7: {} fill-range@7.1.1: @@ -10250,6 +10297,12 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 + fs-extra@11.3.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + fs-extra@9.1.0: dependencies: at-least-node: 1.0.0 @@ -10311,6 +10364,16 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 + gh-pages@6.3.0: + dependencies: + async: 3.2.6 + commander: 13.1.0 + email-addresses: 5.0.0 + filenamify: 4.3.0 + find-cache-dir: 3.3.2 + fs-extra: 11.3.0 + globby: 11.1.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -12976,6 +13039,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + style-loader@3.3.4(webpack@5.99.9): dependencies: webpack: 5.99.9 @@ -13169,6 +13236,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + tryer@1.0.1: {} ts-interface-checker@0.1.13: {} diff --git a/src/frontend/public/index.html b/src/frontend/public/index.html index 8428f1b..be441da 100644 --- a/src/frontend/public/index.html +++ b/src/frontend/public/index.html @@ -8,4 +8,4 @@
- + \ No newline at end of file diff --git a/src/frontend/src/App.jsx b/src/frontend/src/App.jsx index 5322ded..1244fb4 100644 --- a/src/frontend/src/App.jsx +++ b/src/frontend/src/App.jsx @@ -1,14 +1,22 @@ -import { Route, Routes } from 'react-router-dom'; -import Welcome from '@/pages/Welcome'; -import Dashboard from '@/pages/Dashboard'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import AppShell from '@/components/system/layout/AppShell'; +import buildRoutes from '@/constants/routes/routes'; +import { useEffect } from 'react'; +import { Toaster } from '@/components/ui/toaster'; const App = () => { + const isProd = process.env.NODE_ENV === 'production'; + useEffect(() => {}, []); return ( - - } /> - } /> - + + + }> + {buildRoutes()} + + + + ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/frontend/src/components/Header.jsx b/src/frontend/src/components/Header.jsx deleted file mode 100644 index 985db0c..0000000 --- a/src/frontend/src/components/Header.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { Box, Flex, Heading, Spacer, Button, Card } from '@chakra-ui/react'; -import { useNavigate } from 'react-router-dom'; -import NavButton from '@/components/ui/NavButton'; - -const Header = () => { - const navigate = useNavigate(); - return ( - - - 网络管理后台 - - navigate('/')}> - 返回欢迎页 - - - - ); -}; - -export default Header; diff --git a/src/frontend/src/components/pages/dashboard/FeatureCard.jsx b/src/frontend/src/components/pages/dashboard/FeatureCard.jsx new file mode 100644 index 0000000..20edad6 --- /dev/null +++ b/src/frontend/src/components/pages/dashboard/FeatureCard.jsx @@ -0,0 +1,43 @@ +import { Box, Button, Text } from '@chakra-ui/react'; +import React from 'react'; + +/** + * 特性卡片 + * @param title 标题 + * @param description 描述 + * @param buttonText 按钮文字 + * @param to 转向 + * @param disabled 按钮是否可用 + * @returns {JSX.Element} + * @constructor + */ +const FeatureCard = ({ title, description, buttonText, to, disabled }) => ( + + + {title} + + + {description} + + + +); + +export default FeatureCard; diff --git a/src/frontend/src/components/pages/dashboard/StatCard.jsx b/src/frontend/src/components/pages/dashboard/StatCard.jsx new file mode 100644 index 0000000..88b1937 --- /dev/null +++ b/src/frontend/src/components/pages/dashboard/StatCard.jsx @@ -0,0 +1,31 @@ +import { Box, Stat } from '@chakra-ui/react'; +import React from 'react'; + +/** + * 状态卡片 + * @param title 标题 + * @param value 内容 + * @param suffix 交换机数目 + * @param isTime 扫描时间 + * @returns {JSX.Element} + * @constructor + */ +const StatCard = ({ title, value, suffix, isTime }) => ( + + + {title} + {`${value}${suffix ? ` ${suffix}` : ''}`} + {isTime && {`上次扫描时间`}} + + +); + +export default StatCard; diff --git a/src/frontend/src/components/pages/welcome/BackgroundBlur.jsx b/src/frontend/src/components/pages/welcome/BackgroundBlur.jsx new file mode 100644 index 0000000..31500d1 --- /dev/null +++ b/src/frontend/src/components/pages/welcome/BackgroundBlur.jsx @@ -0,0 +1,29 @@ +import { Box, Image } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import image from '@/resources/image/welcome/background.png'; + +const MotionBox = motion(Box); + +/** + * 带高斯模糊的背景 + * @returns {JSX.Element} + * @constructor + */ +const BackgroundBlur = () => ( + + + +); + +export default BackgroundBlur; diff --git a/src/frontend/src/components/pages/welcome/ConfigureCard.jsx b/src/frontend/src/components/pages/welcome/ConfigureCard.jsx new file mode 100644 index 0000000..efc9681 --- /dev/null +++ b/src/frontend/src/components/pages/welcome/ConfigureCard.jsx @@ -0,0 +1,18 @@ +import MotionCard from '@/components/ui/MotionCard'; +import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; +import configIcon from '@/resources/icon/pages/weclome/config.svg'; + +/** + * 连接配置卡片组件 + * @param onClick 点击事件 + * @returns {JSX.Element} + */ +const ConfigureCard = ({ onClick }) => { + return ( + + + + ); +}; + +export default ConfigureCard; diff --git a/src/frontend/src/components/pages/welcome/ConnectionConfigModal.jsx b/src/frontend/src/components/pages/welcome/ConnectionConfigModal.jsx new file mode 100644 index 0000000..c319126 --- /dev/null +++ b/src/frontend/src/components/pages/welcome/ConnectionConfigModal.jsx @@ -0,0 +1,190 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Button, + Box, + Dialog, + DialogBackdrop, + DialogPositioner, + DialogContent, + DialogCloseTrigger, + DialogHeader, + DialogBody, + DialogFooter, + Field, + Input, + Portal, + Stack, +} from '@chakra-ui/react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { IoClose } from 'react-icons/io5'; +import { FiCheck } from 'react-icons/fi'; +import ConfigTool, { defaultConfig } from '@/libs/config/ConfigTool'; + +const MotionBox = motion(Box); + +/** + * 连接地址配置组件 + * @param isOpen 打开状态 + * @param onClose 关闭状态 + * @param onSave 保存状态 + * @returns {JSX.Element} + * @constructor + */ +const ConnectionConfigModal = ({ isOpen, onClose, onSave }) => { + const [config, setConfig] = useState(defaultConfig); + const [saved, setSaved] = useState(false); + const backendRef = useRef(null); + + useEffect(() => { + setConfig(ConfigTool.load()); + }, []); + + const handleChange = (e) => { + const { name, value } = e.target; + setConfig((prev) => ({ ...prev, [name]: value })); + }; + + const handleSave = () => { + ConfigTool.save(config); + onSave(config); + setSaved(true); + setTimeout(() => { + setSaved(false); + onClose(); + }, 1200); + }; + + const handleClear = () => { + ConfigTool.clear(); + setConfig(defaultConfig); + onClose(); + }; + + return ( + backendRef.current}> + + + + + 地址配置 + + + + + + + + 后端连接地址 + + + + 连接密钥 + + + + + + + + + + + + + + + + + + + ); +}; + +export default ConnectionConfigModal; diff --git a/src/frontend/src/components/pages/welcome/DashboardCard.jsx b/src/frontend/src/components/pages/welcome/DashboardCard.jsx new file mode 100644 index 0000000..c9a3229 --- /dev/null +++ b/src/frontend/src/components/pages/welcome/DashboardCard.jsx @@ -0,0 +1,26 @@ +import manageIcon from '@/resources/icon/pages/weclome/setting.svg'; +import MotionCard from '@/components/ui/MotionCard'; +import { useNavigate } from 'react-router-dom'; +import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; + +/** + * 进入管理后台按钮组件 + * @returns {JSX.Element} + * @constructor + */ +const DashboardCard = ({ isConfigured }) => { + const navigate = useNavigate(); + const handleClick = () => { + if (isConfigured) { + navigate('/dashboard'); + } else { + } + }; + return ( + + handleClick()} /> + + ); +}; + +export default DashboardCard; diff --git a/src/frontend/src/components/pages/welcome/GithubCard.jsx b/src/frontend/src/components/pages/welcome/GithubCard.jsx new file mode 100644 index 0000000..352612a --- /dev/null +++ b/src/frontend/src/components/pages/welcome/GithubCard.jsx @@ -0,0 +1,23 @@ +import githubIcon from '@/resources/icon/pages/weclome/github.svg'; +import MotionCard from '@/components/ui/MotionCard'; +import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; + +/** + * GitHub按钮组件 + * @returns {JSX.Element} + * @constructor + */ +const GithubCard = () => { + return ( + + window.open('https://github.com/Jerryplusy/AI-powered-switches', '_blank')} + /> + + ); +}; + +export default GithubCard; diff --git a/src/frontend/src/components/pages/welcome/WelcomeContent.jsx b/src/frontend/src/components/pages/welcome/WelcomeContent.jsx new file mode 100644 index 0000000..c5b4245 --- /dev/null +++ b/src/frontend/src/components/pages/welcome/WelcomeContent.jsx @@ -0,0 +1,75 @@ +import { Box, Heading, Text, VStack, HStack } from '@chakra-ui/react'; +import DashboardCard from '@/components/pages/welcome/DashboardCard'; +import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; +import { useState, useEffect } from 'react'; +import ConnectionConfigModal from '@/components/pages/welcome/ConnectionConfigModal'; +import ConfigureCard from '@/components/pages/welcome/ConfigureCard'; +import ConfigTool from '@/libs/config/ConfigTool'; + +/** + * 欢迎页面内容 + * @returns {JSX.Element} + * @constructor + */ +const WelcomeContent = () => { + const [showConfigModal, setShowConfigModal] = useState(false); + const [isConfigured, setIsConfigured] = useState(false); + //const [showAlert, setShowAlert] = useState(false); + + useEffect(() => { + const saved = ConfigTool.load(); + if (saved) { + setIsConfigured(true); + } + }, []); + + const handleSave = () => { + setIsConfigured(true); + //setShowAlert(false); + }; + + const handleConfigureClick = () => { + if (!isConfigured) { + //setShowAlert(true); + } + setShowConfigModal(true); + }; + + return ( + <> + + + + + 智能网络交换机 +
+ 管理系统 +
+
+
+ + + + 助力大型网络交换机配置及网络流量管理,方便的管控网络,让网络配置不再困难 + + + + + + + + + + +
+ + setShowConfigModal(false)} + onSave={handleSave} + /> + + ); +}; + +export default WelcomeContent; diff --git a/src/frontend/src/components/system/PageContainer.jsx b/src/frontend/src/components/system/PageContainer.jsx new file mode 100644 index 0000000..ba723a5 --- /dev/null +++ b/src/frontend/src/components/system/PageContainer.jsx @@ -0,0 +1,17 @@ +import { Box } from '@chakra-ui/react'; + +/** + * 解决导航栏占位问题 + * @param children + * @returns {JSX.Element} + * @constructor + */ +const PageContainer = ({ children }) => { + return ( + + {children} + + ); +}; + +export default PageContainer; diff --git a/src/frontend/src/components/system/layout/AppShell.jsx b/src/frontend/src/components/system/layout/AppShell.jsx new file mode 100644 index 0000000..cd50313 --- /dev/null +++ b/src/frontend/src/components/system/layout/AppShell.jsx @@ -0,0 +1,28 @@ +import { Outlet, useLocation } from 'react-router-dom'; +import { Box } from '@chakra-ui/react'; +import { AnimatePresence } from 'framer-motion'; +import PageTransition from './PageTransition'; +import GithubTransitionCard from '@/components/system/layout/github/GithubTransitionCard'; + +/** + * 应用加壳 + * @returns {JSX.Element} + * @constructor + */ +const AppShell = () => { + const location = useLocation(); + return ( + + + + + + + + + + + ); +}; + +export default AppShell; diff --git a/src/frontend/src/components/system/layout/FadeInWrapper.jsx b/src/frontend/src/components/system/layout/FadeInWrapper.jsx new file mode 100644 index 0000000..a568801 --- /dev/null +++ b/src/frontend/src/components/system/layout/FadeInWrapper.jsx @@ -0,0 +1,43 @@ +import { AnimatePresence, motion } from 'framer-motion'; + +/** + * 组件进入 / 离开时的淡入淡出动画 + * @param children 子组件 + * @param delay 延迟 + * @param yOffset y轴偏移量 + * @param duration 动画时间 + * @param className 类名 + * @param props + * @returns {JSX.Element} + * @constructor + */ +const FadeInWrapper = ({ + children, + delay = 0, + yOffset = 10, + duration = 0.6, + className = '', + ...props +}) => { + return ( + + + {children} + + + ); +}; + +export default FadeInWrapper; diff --git a/src/frontend/src/components/system/layout/PageTransition.jsx b/src/frontend/src/components/system/layout/PageTransition.jsx new file mode 100644 index 0000000..e6eaf0f --- /dev/null +++ b/src/frontend/src/components/system/layout/PageTransition.jsx @@ -0,0 +1,16 @@ +import { motion } from 'framer-motion'; + +/** + * 页面动效 + * @param children + * @returns {JSX.Element} + * @constructor + */ +const PageTransition = ({ children }) => {children}; + +export default PageTransition; +/** + * initial={{ opacity: 0, y: 0 }} + * animate={{ opacity: 1, y: 0 }} + * transition={{ duration: 0.2 }} + */ diff --git a/src/frontend/src/components/system/layout/StaggeredFadeIn.jsx b/src/frontend/src/components/system/layout/StaggeredFadeIn.jsx new file mode 100644 index 0000000..9462c71 --- /dev/null +++ b/src/frontend/src/components/system/layout/StaggeredFadeIn.jsx @@ -0,0 +1,24 @@ +import FadeInWrapper from './FadeInWrapper'; + +/** + * 递归为组件及子组件添加载入动效 + * @param children 子组件 + * @param baseDelay 延迟 + * @param increment 增值 + * @param className 类名 + * @returns {JSX.Element} + * @constructor + */ +const StaggeredFadeIn = ({ children, baseDelay = 0.2, increment = 0.1, className = '' }) => { + return ( + <> + {React.Children.map(children, (child, index) => ( + + {child} + + ))} + + ); +}; + +export default StaggeredFadeIn; diff --git a/src/frontend/src/components/system/layout/github/GithubTransitionCard.jsx b/src/frontend/src/components/system/layout/github/GithubTransitionCard.jsx new file mode 100644 index 0000000..e5d3bc7 --- /dev/null +++ b/src/frontend/src/components/system/layout/github/GithubTransitionCard.jsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Box, Button, HStack, Switch, Text } from '@chakra-ui/react'; +import web from '@/resources/icon/pages/weclome/web.svg'; +import githubIcon from '@/resources/icon/pages/weclome/github.svg'; +import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; +import MotionCard from '@/components/ui/MotionCard'; +import ConfigTool from '@/libs/config/ConfigTool'; +import Notification from '@/libs/system/Notification'; + +const navItems = [ + { label: '面板', path: '/dashboard' }, + { label: '网络扫描', path: '/dashboard/scan' }, + { label: '交换机设备', path: '/dashboard/devices' }, + { label: '交换机配置', path: '/dashboard/config' }, + { label: '流量监控', path: '/dashboard/watch' }, +]; + +/** + * 导航栏&github按钮组件 + * @returns {JSX.Element} + * @constructor + */ +const GithubTransitionCard = () => { + const { pathname } = useLocation(); + const navigate = useNavigate(); + const isDashboard = pathname.startsWith('/dashboard'); + const [showNavButtons, setShowNavButtons] = useState(false); + const [testMode, setTestMode] = useState(false); + + useEffect(() => { + setShowNavButtons(false); + const timer = setTimeout(() => { + if (isDashboard) setShowNavButtons(true); + }, 400); + return () => clearTimeout(timer); + }, [isDashboard]); + + useEffect(() => { + const config = ConfigTool.load(); + setTestMode(config.testMode); + }, []); + + const handleTestModeToggle = (checked) => { + console.log(`切换测试模式..`); + setTestMode(checked); + const config = ConfigTool.load(); + ConfigTool.save({ ...config, testMode: checked }); + let mode = checked ? '调试模式' : '常规模式'; + Notification.success({ + title: `成功切换至${mode}!`, + }); + }; + + const MotionBox = motion(Box); + return ( + + + + { + if (!isDashboard) { + window.open('https://github.com/Jerryplusy/AI-powered-switches', '_blank'); + } + }} + justifyContent={isDashboard ? 'flex-start' : 'center'} + alignItems={'center'} + flexDirection={'row'} + w={'100%'} + px={isDashboard ? 4 : 3} + py={isDashboard ? 3 : 2} + disableHover={isDashboard} + > + {isDashboard && showNavButtons && ( + <> + + {navItems.map((item) => ( + + ))} + + + {'调试模式'} + + handleTestModeToggle(details.checked)} + colorPalette={'teal'} + zIndex={100} + > + + + + + + + + + )} + + + + + ); +}; +// TODO 解决组件重复渲染问题 +export default GithubTransitionCard; diff --git a/src/frontend/src/components/system/pages/DashboardBackground.jsx b/src/frontend/src/components/system/pages/DashboardBackground.jsx new file mode 100644 index 0000000..4a3f6e8 --- /dev/null +++ b/src/frontend/src/components/system/pages/DashboardBackground.jsx @@ -0,0 +1,61 @@ +import React, { useState, useEffect } from 'react'; +import { Box } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; + +const MotionBox = motion(Box); + +/** + * 控制台背景 + * @returns {JSX.Element} + * @constructor + */ +const DashboardBackground = () => { + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + + useEffect(() => { + const handleMouseMove = (e) => { + setMousePos({ x: e.clientX, y: e.clientY }); + }; + window.addEventListener('mousemove', handleMouseMove); + return () => window.removeEventListener('mousemove', handleMouseMove); + }, []); + + const spotlight = { + background: `radial-gradient( + circle at ${mousePos.x}px ${mousePos.y}px, + rgba(255, 255, 255, 0.05) 0%, + rgba(255, 255, 255, 0.02) 120px, + transparent 240px + )`, + }; + + return ( + + ); +}; + +export default DashboardBackground; diff --git a/src/frontend/src/components/system/pages/DocumentTitle.jsx b/src/frontend/src/components/system/pages/DocumentTitle.jsx new file mode 100644 index 0000000..0c68a6b --- /dev/null +++ b/src/frontend/src/components/system/pages/DocumentTitle.jsx @@ -0,0 +1,10 @@ +import { useEffect } from 'react'; + +const DocumentTitle = ({ title, children }) => { + useEffect(() => { + document.title = title || '网络管理后台'; + }, [title]); + return children; +}; + +export default DocumentTitle; diff --git a/src/frontend/src/components/ui/Card.jsx b/src/frontend/src/components/ui/Card.jsx index 13991aa..ee4f534 100644 --- a/src/frontend/src/components/ui/Card.jsx +++ b/src/frontend/src/components/ui/Card.jsx @@ -1,5 +1,12 @@ import { Box } from '@chakra-ui/react'; +/** + * 卡片组件 + * @param children + * @param props + * @returns {JSX.Element} + * @constructor + */ const Card = ({ children, ...props }) => ( ( + + {hasBlurBackground && ( + + )} + {icon && } + {text && ( + + {text} + + )} + {children} + +); + +export default MotionCard; diff --git a/src/frontend/src/components/ui/color-mode.jsx b/src/frontend/src/components/ui/color-mode.jsx index b2d1cd6..47ba63d 100644 --- a/src/frontend/src/components/ui/color-mode.jsx +++ b/src/frontend/src/components/ui/color-mode.jsx @@ -1,90 +1,86 @@ -'use client' +'use client'; -import { ClientOnly, IconButton, Skeleton, Span } from '@chakra-ui/react' -import { ThemeProvider, useTheme } from 'next-themes' +import { ClientOnly, IconButton, Skeleton, Span } from '@chakra-ui/react'; +import { ThemeProvider, useTheme } from 'next-themes'; -import * as React from 'react' -import { LuMoon, LuSun } from 'react-icons/lu' +import * as React from 'react'; +import { LuMoon, LuSun } from 'react-icons/lu'; export function ColorModeProvider(props) { - return ( - - ) + return ; } export function useColorMode() { - const { resolvedTheme, setTheme, forcedTheme } = useTheme() - const colorMode = forcedTheme || resolvedTheme + const { resolvedTheme, setTheme, forcedTheme } = useTheme(); + const colorMode = forcedTheme || resolvedTheme; const toggleColorMode = () => { - setTheme(resolvedTheme === 'dark' ? 'light' : 'dark') - } + setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); + }; return { colorMode: colorMode, setColorMode: setTheme, toggleColorMode, - } + }; } export function useColorModeValue(light, dark) { - const { colorMode } = useColorMode() - return colorMode === 'dark' ? dark : light + const { colorMode } = useColorMode(); + return colorMode === 'dark' ? dark : light; } export function ColorModeIcon() { - const { colorMode } = useColorMode() - return colorMode === 'dark' ? : + const { colorMode } = useColorMode(); + return colorMode === 'dark' ? : ; } -export const ColorModeButton = React.forwardRef( - function ColorModeButton(props, ref) { - const { toggleColorMode } = useColorMode() - return ( - }> - - - - - ) - }, -) +export const ColorModeButton = React.forwardRef(function ColorModeButton(props, ref) { + const { toggleColorMode } = useColorMode(); + return ( + }> + + + + + ); +}); export const LightMode = React.forwardRef(function LightMode(props, ref) { return ( - ) -}) + ); +}); export const DarkMode = React.forwardRef(function DarkMode(props, ref) { return ( - ) -}) + ); +}); diff --git a/src/frontend/src/constants/routes/routes.jsx b/src/frontend/src/constants/routes/routes.jsx new file mode 100644 index 0000000..f001118 --- /dev/null +++ b/src/frontend/src/constants/routes/routes.jsx @@ -0,0 +1,23 @@ +import { Route } from 'react-router-dom'; +import Welcome from '@/pages/Welcome'; +import Dashboard from '@/pages/Dashboard'; +import ScanPage from '@/pages/ScanPage'; +import DevicesPage from '@/pages/DevicesPage'; +import ConfigPage from '@/pages/ConfigPage'; + +/** + * 路由 + * @type {[{path: string, element: JSX.Element},{path: string, element: JSX.Element}]} + */ +const routeList = [ + { path: '/', element: }, + { path: '/dashboard', element: }, + { path: '/dashboard/scan', element: }, + { path: '/dashboard/devices', element: }, + { path: '/dashboard/config', element: }, +]; + +const buildRoutes = () => + routeList.map(({ path, element }) => ); + +export default buildRoutes; diff --git a/src/frontend/src/index.js b/src/frontend/src/index.js index aca27d5..d021c8d 100644 --- a/src/frontend/src/index.js +++ b/src/frontend/src/index.js @@ -1,14 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from '@/App'; -import { BrowserRouter } from 'react-router-dom'; import { Provider } from '@/components/ui/provider'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( - - - + ); diff --git a/src/frontend/src/libs/common.js b/src/frontend/src/libs/common.js new file mode 100644 index 0000000..a523457 --- /dev/null +++ b/src/frontend/src/libs/common.js @@ -0,0 +1,21 @@ +const Common = { + /** + * 睡眠指定毫秒 + * @param {number} ms - 毫秒数,必须是非负数 + * @returns {Promise} + */ + async sleep(ms) { + if (!Number.isFinite(ms)) { + return Promise.resolve(); + } + if (ms === 0) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + return () => clearTimeout(timer); + }); + }, +}; + +export default Common; diff --git a/src/frontend/src/libs/config/ConfigTool.js b/src/frontend/src/libs/config/ConfigTool.js new file mode 100644 index 0000000..49a26fb --- /dev/null +++ b/src/frontend/src/libs/config/ConfigTool.js @@ -0,0 +1,88 @@ +const CONFIG_KEY = 'app_config'; + +/** + * 默认配置 + * @type {{backendUrl: string, authKey: string, testMode: boolean, stats: {totalDevices: number, onlineDevices: number, lastScan: string}, devices: *[]}} + */ +export const defaultConfig = { + backendUrl: '', + authKey: '', + testMode: false, + stats: { + totalDevices: 0, + onlineDevices: 0, + lastScan: '', + }, + devices: [], +}; + +/** + * 配置管理工具 + * @type {{load: ((function(): (any))|*), save: ((function(*): (boolean|undefined))|*), clear: ((function(): (boolean|undefined))|*), getConfigPath: (function(): string), isStorageAvailable: ((function(): (boolean|undefined))|*), getStats: (function(): *)}} + */ +const ConfigTool = { + load: () => { + try { + const stored = localStorage.getItem(CONFIG_KEY); + if (stored) { + return { ...defaultConfig, ...JSON.parse(stored) }; + } + } catch (e) { + console.error('读取配置失败:', e); + localStorage.removeItem(CONFIG_KEY); + } + return { ...defaultConfig }; + }, + + /** + * 保存配置 + * @param config + * @returns {boolean} + */ + save: (config) => { + try { + localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + return true; + } catch (e) { + console.error('保存配置失败:', e); + if (e.name === 'QuotaExceededError') { + alert('存储空间不足,请清理后重试'); + } + return false; + } + }, + + /** + * 清除配置 + * @returns {boolean} + */ + clear: () => { + try { + localStorage.removeItem(CONFIG_KEY); + return true; + } catch (e) { + console.error('清除配置失败:', e); + return false; + } + }, + + getConfigPath: () => `localStorage:${CONFIG_KEY}`, + + isStorageAvailable: () => { + try { + const testKey = 'test'; + localStorage.setItem(testKey, testKey); + localStorage.removeItem(testKey); + return true; + } catch (e) { + return false; + } + }, + + getStats: () => { + const config = ConfigTool.load(); + return config.stats || { totalDevices: 0, onlineDevices: 0, lastScan: '' }; + }, +}; + +export default ConfigTool; diff --git a/src/frontend/src/libs/script/configPage/configEffect.js b/src/frontend/src/libs/script/configPage/configEffect.js new file mode 100644 index 0000000..6cef92e --- /dev/null +++ b/src/frontend/src/libs/script/configPage/configEffect.js @@ -0,0 +1,208 @@ +const configEffect = { + async generateRealisticConfig(command, devices = []) { + const timestamp = new Date().toLocaleString(); + let config = `! 配置生成于 ${timestamp}\n`; + const configTemplates = { + vlan: { + pattern: /(vlan|虚拟局域网|虚拟网)\s*(\d+)/i, + template: (vlanId) => + `vlan ${vlanId}\n` + + ` name VLAN_${vlanId}\n` + + ` exit\n` + + `interface Vlan${vlanId}\n` + + ` description ${vlanId === '10' ? '管理VLAN' : '用户VLAN'}\n` + + ` ip address 192.168.${vlanId}.1 255.255.255.0\n` + + ` exit\n`, + }, + ssh: { + pattern: /(ssh|安全外壳|远程登录)/i, + template: () => { + const password = Math.random().toString(36).slice(2, 10); + return ( + `ip ssh server\n` + + `ip ssh version 2\n` + + `username admin privilege 15 secret 0 ${password}\n` + + `line vty 0 4\n` + + ` transport input ssh\n` + + ` login local\n` + + ` exit\n` + ); + }, + }, + port: { + pattern: /(端口|接口|port|interface)\s*(\d+)/i, + template: (port) => { + const isTrunk = /(trunk|干道)/i.test(command); + const isAccess = /(access|接入)/i.test(command) || !isTrunk; + const desc = /(上联|uplink)/i.test(command) ? 'Uplink_Port' : 'Access_Port'; + const vlanId = command.match(/vlan\s*(\d+)/i)?.[1] || '10'; + + return ( + `interface GigabitEthernet0/${port}\n` + + ` description ${desc}\n` + + ` switchport mode ${isTrunk ? 'trunk' : 'access'}\n` + + ` ${isTrunk ? 'switchport trunk allowed vlan all' : `switchport access vlan ${vlanId}`}\n` + + ` no shutdown\n` + + ` exit\n` + ); + }, + }, + acl: { + pattern: /(acl|访问控制|防火墙)/i, + template: () => { + let targetIP = '192.168.10.10'; + if (devices.length > 0) { + const randomDevice = devices[Math.floor(Math.random() * devices.length)]; + targetIP = randomDevice.ip; + } + return ( + `ip access-list extended PROTECT_SERVERS\n` + + ` permit tcp any host ${targetIP} eq 22\n` + + ` permit tcp any host ${targetIP} eq 80\n` + + ` permit tcp any host ${targetIP} eq 443\n` + + ` deny ip any any\n` + + ` exit\n` + + `interface Vlan10\n` + + ` ip access-group PROTECT_SERVERS in\n` + + ` exit\n` + ); + }, + }, + dhcp: { + pattern: /(dhcp|动态主机配置)/i, + template: () => { + const vlanId = command.match(/vlan\s*(\d+)/i)?.[1] || '10'; + return ( + `ip dhcp pool VLAN_${vlanId}\n` + + ` network 192.168.${vlanId}.0 255.255.255.0\n` + + ` default-router 192.168.${vlanId}.1\n` + + ` dns-server 8.8.8.8 8.8.4.4\n` + + ` exit\n` + + `ip dhcp excluded-address 192.168.${vlanId}.1 192.168.${vlanId}.10\n` + ); + }, + }, + nat: { + pattern: /(nat|网络地址转换)/i, + template: () => { + const publicIp = `203.0.113.${Math.floor(Math.random() * 10) + 1}`; + return ( + `ip access-list standard NAT_ACL\n` + + ` permit 192.168.0.0 0.0.255.255\n` + + ` exit\n` + + `ip nat inside source list NAT_ACL interface GigabitEthernet0/1 overload\n` + + `interface GigabitEthernet0/1\n` + + ` ip address ${publicIp} 255.255.255.248\n` + + ` ip nat outside\n` + + ` exit\n` + + `interface Vlan10\n` + + ` ip nat inside\n` + + ` exit\n` + ); + }, + }, + stp: { + pattern: /(stp|生成树|spanning-tree)/i, + template: () => { + return ( + `spanning-tree mode rapid-pvst\n` + + `spanning-tree vlan 1-4094 priority 4096\n` + + `spanning-tree portfast default\n` + + `spanning-tree portfast bpduguard default\n` + ); + }, + }, + portSecurity: { + pattern: /(端口安全|port-security)/i, + template: () => { + const port = command.match(/端口\s*(\d+)/i)?.[1] || '1'; + return ( + `interface GigabitEthernet0/${port}\n` + + ` switchport port-security\n` + + ` switchport port-security maximum 5\n` + + ` switchport port-security violation restrict\n` + + ` switchport port-security mac-address sticky\n` + + ` exit\n` + ); + }, + }, + qos: { + pattern: /(qos|服务质量|流量控制)/i, + template: () => { + return ( + `class-map match-all VOICE\n` + + ` match ip dscp ef\n` + + ` exit\n` + + `policy-map QOS_POLICY\n` + + ` class VOICE\n` + + ` priority percent 20\n` + + ` class class-default\n` + + ` bandwidth percent 80\n` + + ` exit\n` + + `interface GigabitEthernet0/1\n` + + ` service-policy output QOS_POLICY\n` + + ` exit\n` + ); + }, + }, + vpn: { + pattern: /(vpn|虚拟专用网)/i, + template: () => { + const vpnId = Math.floor(Math.random() * 1000); + return ( + `crypto isakmp policy ${vpnId}\n` + + ` encryption aes 256\n` + + ` hash sha256\n` + + ` authentication pre-share\n` + + ` group 14\n` + + ` exit\n` + + `crypto ipsec transform-set VPN_TRANSFORM esp-aes 256 esp-sha256-hmac\n` + + ` mode tunnel\n` + + ` exit\n` + + `crypto map VPN_MAP 10 ipsec-isakmp\n` + + ` set peer 203.0.113.5\n` + + ` set transform-set VPN_TRANSFORM\n` + + ` match address VPN_ACL\n` + + ` exit\n` + ); + }, + }, + }; + let matched = false; + if (/(完整配置|全部配置|all config)/i.test(command)) { + matched = true; + config += '! 生成完整校园网络配置\n'; + Object.values(configTemplates).forEach((template) => { + const result = template.template(); + if (result) { + config += result; + } + }); + } else { + for (const [key, { pattern, template }] of Object.entries(configTemplates)) { + const match = command.match(pattern); + if (match) { + matched = true; + config += template(match[2] || match[1] || ''); + } + } + } + + if (!matched) { + config += 'hostname SCHOOL_SWITCH\n'; + config += 'ip domain-name school.local\n'; + config += 'snmp-server community SCHOOL_RO RO\n'; + config += 'ntp server 192.168.1.1\n'; + config += 'logging trap informational\n'; + config += 'logging 192.168.1.10\n'; + config += 'service password-encryption\n'; + config += 'enable secret 0 ' + Math.random().toString(36).slice(2, 12) + '\n'; + config += 'no ip http server\n'; + config += 'no ip http secure-server\n'; + } + + return { config }; + }, +}; + +export default configEffect; diff --git a/src/frontend/src/libs/script/scanPage/scanEffect.js b/src/frontend/src/libs/script/scanPage/scanEffect.js new file mode 100644 index 0000000..d1b226e --- /dev/null +++ b/src/frontend/src/libs/script/scanPage/scanEffect.js @@ -0,0 +1,24 @@ +const scanEffect = { + getTestDevices() { + return [ + { ip: '192.168.1.1', mac: '00:1A:2B:3C:4D:5E', ports: [22, 23, 161] }, + { ip: '192.168.1.2', mac: '00:1D:7D:AA:1B:01', ports: [22, 23, 161] }, + { ip: '192.168.1.3', mac: '00:0C:29:5A:3B:11', ports: [22, 23, 161, 443] }, + { ip: '192.168.1.4', mac: '00:23:CD:FF:10:02', ports: [22, 23] }, + { ip: '192.168.1.5', mac: '00:04:4B:AA:BB:CC', ports: [22, 23, 80, 443] }, + { ip: '192.168.1.6', mac: '00:11:22:33:44:55', ports: [22, 23, 161] }, + { ip: '192.168.1.7', mac: '00:1F:45:67:89:AB', ports: [23] }, + { ip: '192.168.1.8', mac: '00:50:56:11:22:33', ports: [22, 443, 8443] }, + ]; + }, + + async fetchLocalInfo({ setLocalIp, subnet, setSubnet }) { + setLocalIp('192.168.1.100'); + if (!subnet) { + const ipParts = '192.168.1.0'.split('.'); + setSubnet(`${ipParts[0]}.${ipParts[1]}.${ipParts[2]}.0/24`); + } + }, +}; + +export default scanEffect; diff --git a/src/frontend/src/libs/system/Notification.jsx b/src/frontend/src/libs/system/Notification.jsx new file mode 100644 index 0000000..cfde01d --- /dev/null +++ b/src/frontend/src/libs/system/Notification.jsx @@ -0,0 +1,138 @@ +import { toaster } from '@/components/ui/toaster'; +import { + AiOutlineInfoCircle, + AiFillWarning, + AiFillCheckCircle, + AiFillExclamationCircle, + AiOutlineLoading, +} from 'react-icons/ai'; + +const surfaceStyle = { + borderRadius: '1rem', + border: '1px solid rgba(255,255,255,0.3)', + backdropFilter: 'blur(10px)', + background: 'rgba(255, 255, 255, 0.1)', + color: 'white', +}; + +/** + * 通用通知组件 + * @type {{ + * info({title: *, description: *, button: *}): void, + * success({title: *, description: *, button: *}): void, + * warn({title: *, description: *, button: *}): void, + * error({title: *, description: *, button: *}): void, + * promise({promise: *, loading: *, success: *, error: *}): void + * }} + */ +const Notification = { + /** + * 信息 + * @param title 标题 + * @param description 描述 + * @param button 按钮 + */ + info({ title, description, button }) { + toaster.create({ + title, + description, + type: 'info', + duration: 3000, + icon: , + actionLabel: button?.label, + action: button?.onClick, + style: surfaceStyle, + }); + }, + + /** + * 成功信息 + * @param title 标题 + * @param description 描述 + * @param button 按钮 + */ + success({ title, description, button }) { + toaster.create({ + title, + description, + type: 'success', + duration: 3000, + icon: , + actionLabel: button?.label, + action: button?.onClick, + style: surfaceStyle, + }); + }, + + /** + * 异步操作通知 + * @param {Object} params 参数对象 + * @param {Promise} params.promise 异步操作Promise + * @param {Object} params.loading 加载中状态配置 + * @param {Object} params.success 成功状态配置 + * @param {Object} params.error 错误状态配置 + * @returns {{id: string | undefined, unwrap: () => Promise}} + */ + async promise({ promise, loading, success, error }) { + return toaster.promise(promise, { + loading: { + title: loading.title, + description: loading.description, + icon: , + style: surfaceStyle, + }, + success: { + title: success.title, + description: success.description, + icon: , + style: surfaceStyle, + }, + error: { + title: error.title, + description: error.description, + icon: , + style: surfaceStyle, + }, + }); + }, + + /** + * 警告 + * @param title 标题 + * @param description 描述 + * @param button 按钮 + */ + warn({ title, description, button }) { + toaster.create({ + title, + description, + type: 'warning', + duration: 3000, + icon: , + actionLabel: button?.label, + action: button?.onClick, + style: surfaceStyle, + }); + }, + + /** + * 错误提示 + * @param title 标题 + * @param description 描述 + * @param button 按钮 + */ + error({ title, description, button }) { + toaster.create({ + title, + description, + type: 'error', + duration: 3000, + icon: , + actionLabel: button?.label, + action: button?.onClick, + style: surfaceStyle, + }); + }, +}; + +export default Notification; diff --git a/src/frontend/src/pages/ConfigPage.jsx b/src/frontend/src/pages/ConfigPage.jsx new file mode 100644 index 0000000..e67eda7 --- /dev/null +++ b/src/frontend/src/pages/ConfigPage.jsx @@ -0,0 +1,258 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + Button, + createListCollection, + Field, + Heading, + HStack, + Portal, + Select, + Text, + Textarea, + VStack, +} from '@chakra-ui/react'; +import DocumentTitle from '@/components/system/pages/DocumentTitle'; +import PageContainer from '@/components/system/PageContainer'; +import DashboardBackground from '@/components/system/pages/DashboardBackground'; +import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; +import ConfigTool from '@/libs/config/ConfigTool'; +import { api } from '@/services/api/api'; +import Notification from '@/libs/system/Notification'; +import Common from '@/libs/common'; +import configEffect from '@/libs/script/configPage/configEffect'; + +const testMode = ConfigTool.load().testMode; + +const ConfigPage = () => { + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(''); + const [inputText, setInputText] = useState(''); + const [parsedConfig, setParsedConfig] = useState(''); + const [editableConfig, setEditableConfig] = useState(''); + const [applying, setApplying] = useState(false); + const [hasParsed, setHasParsed] = useState(false); + + const deviceCollection = createListCollection({ + items: devices.map((device) => ({ + label: `${device.name} (${device.ip})`, + value: device.ip, + })), + }); + + useEffect(() => { + const config = ConfigTool.load(); + setDevices(config.devices || []); + }, []); + + const handleParse = async () => { + if (!selectedDevice || !inputText.trim()) { + Notification.error({ + title: '操作失败', + description: '请选择设备并输入配置指令', + }); + return; + } + + try { + const performParse = async () => { + if (testMode) { + await Common.sleep(800 + Math.random() * 700); + return await configEffect.generateRealisticConfig(inputText, devices); + } + return await api.parseCommand(inputText); + }; + + const resultWrapper = await Notification.promise({ + promise: performParse(), + loading: { + title: '正在解析配置', + description: '正在分析您的指令..', + }, + success: { + title: '解析完成', + description: '已生成交换机配置', + }, + error: { + title: '解析失败', + description: '请检查指令格式或网络连接', + }, + }); + + const result = await resultWrapper.unwrap(); + + if (result?.config) { + setParsedConfig(result.config); + setEditableConfig(result.config); + setHasParsed(true); + } + } catch (error) { + console.error('配置解析异常:', error); + Notification.error({ + title: '配置解析异常', + description: error.message, + }); + } + }; + + const handleApply = async () => { + if (!editableConfig.trim()) { + Notification.warn({ + title: '配置为空', + description: '请先解析或编辑有效配置', + }); + return; + } + + setApplying(true); + try { + const applyOperation = testMode + ? Common.sleep(1000).then(() => ({ success: true })) + : await api.applyConfig(selectedDevice, editableConfig); + + await Notification.promise({ + promise: applyOperation, + loading: { + title: '配置应用中', + description: '正在推送配置到设备...', + }, + success: { + title: '应用成功', + description: '配置已成功生效', + }, + error: { + title: '应用失败', + description: '请检查设备连接或配置内容', + }, + }); + } finally { + setApplying(false); + } + }; + + return ( + + + + + + + 交换机配置中心 + + + + + 选择交换机设备 + + setSelectedDevice(value[0] ?? '')} + placeholder={'请选择交换机设备'} + size={'sm'} + colorPalette={'teal'} + > + + + + + + + + + + + + + + {deviceCollection.items.map((item) => ( + + {item.label} + + ))} + + + + + + + + + 配置指令输入 + +