diff --git a/README.md b/README.md
index 3cf6031..59082e7 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,9 @@
# 基于人工智能实现的交换机自动或半自动配置
+这只是一个用于应付实验论文的奇葩储存库
+
+猎奇卡顿 · 龟速更新 · 随时跑路
+
### 技术栈
- **Python3**
- Flask
@@ -11,10 +15,6 @@
- Framer-motion
- chakra-ui
- HTML5
-### 项目分工
-- **后端api,人工智能算法** : `3`(主要) & `log_out` & `Jerry`(maybe) 使用python
-- **前端管理后台设计**:`Jerry`使用react
-- **论文撰写**:`log_out`
### 各部分说明
@@ -22,18 +22,3 @@
[逻辑处理后端](https://github.com/Jerryplusy/AI-powered-switches/blob/main/src/backend/README.md)
-### 贡献流程
-
-- **后端api**:
- - 对于`3`:直接推送到`main`分支
- - 对于`Jerry`&`log_out`:新建额外的`feat`或`fix`分支,提交推送到自己的分支,然后提交`pullrequest`到`main`分支并指定`3`审核
-
-- **前端管理后台**:
- - 对于`Jerry`:直接推送更新到`main`分支
- - 对于`3`&`log_out`:新建额外的`feat`或`fix`分支,提交推送到自己的分支,然后提交`pullrequest`到`main`分支并指定`Jerry`审核
-
-- **论文(thesis)**:
- - 提交`pullrequest`并指定`log_out`审核
-### 项目活动时间
-2025 6 - 8月
-
diff --git a/src/backend/README.md b/src/backend/README.md
index 0a34790..d0a536e 100644
--- a/src/backend/README.md
+++ b/src/backend/README.md
@@ -10,7 +10,6 @@ src/backend/
│ ├── __init__.py # 创建 Flask 应用实例
│ ├── api/ # API 路由模块
│ │ ├—── __init__.py # 注册 API 蓝图
-│ │ ├── command_parser.py # /api/parse_command 接口
│ │ └── network_config.py # /api/apply_config 接口
│ └── services/ # 核心服务逻辑
│ └── ai_services.py # 调用外部 AI 服务生成配置
diff --git a/src/backend/app/api/command_parser.py b/src/backend/app/api/command_parser.py
deleted file mode 100644
index f198d10..0000000
--- a/src/backend/app/api/command_parser.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from typing import Dict, Any
-from src.backend.app.services.ai_services import AIService
-from src.backend.config import settings
-
-
-class CommandParser:
- def __init__(self):
- self.ai_service = AIService(settings.SILICONFLOW_API_KEY, settings.SILICONFLOW_API_URL)
-
- async def parse(self, command: str) -> Dict[str, Any]:
- """
- 解析中文命令并返回配置
- """
- return await self.ai_service.parse_command(command)
diff --git a/src/backend/app/api/endpoints.py b/src/backend/app/api/endpoints.py
index 8bbda72..697e687 100644
--- a/src/backend/app/api/endpoints.py
+++ b/src/backend/app/api/endpoints.py
@@ -4,13 +4,14 @@ from fastapi import (APIRouter, HTTPException, Response, WebSocket, WebSocketDis
from typing import List
from pydantic import BaseModel
import asyncio
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, JSONResponse
import matplotlib.pyplot as plt
import io
import base64
import psutil
import ipaddress
+from ..models.requests import CLICommandRequest, ConfigRequest
from ..services.switch_traffic_monitor import get_switch_monitor
from ..utils import logger
from ...app.services.ai_services import AIService
@@ -21,217 +22,9 @@ from ...app.services.traffic_monitor import traffic_monitor
from ...app.models.traffic_models import TrafficRecord, SwitchTrafficRecord
from src.backend.app.api.database import SessionLocal
-
-
-
router = APIRouter(prefix="", tags=["API"])
scanner = NetworkScanner()
-@router.get("/", include_in_schema=False)
-async def root():
- return {
- "message": "欢迎使用AI交换机配置系统",
- "docs": f"{settings.API_PREFIX}/docs",
- "redoc": f"{settings.API_PREFIX}/redoc",
- "endpoints": [
- "/parse_command",
- "/apply_config",
- "/scan_network",
- "/list_devices",
- "/batch_apply_config"
- "/traffic/switch/current",
- "/traffic/switch/history"
- ]
- }
-
-@router.get("/favicon.ico", include_in_schema=False)
-async def favicon():
- return Response(status_code=204)
-
-class BatchConfigRequest(BaseModel):
- config: dict
- switch_ips: List[str]
- username: str = None
- password: str = None
- timeout: int = None
-
-@router.post("/batch_apply_config")
-async def batch_apply_config(request: BatchConfigRequest):
- results = {}
- for ip in request.switch_ips:
- try:
- configurator = SwitchConfigurator(
- username=request.username,
- password=request.password,
- timeout=request.timeout )
- results[ip] = await configurator.apply_config(ip, request.config)
- except Exception as e:
- results[ip] = str(e)
- return {"results": results}
-
-
-
-@router.get("/test")
-async def test_endpoint():
- return {"message": "Hello World"}
-
-@router.get("/scan_network", summary="扫描网络中的交换机")
-async def scan_network(subnet: str = "192.168.1.0/24"):
- try:
- devices = scanner.scan_subnet(subnet)
- return {
- "success": True,
- "devices": devices,
- "count": len(devices)
- }
- except Exception as e:
- raise HTTPException(500, f"扫描失败: {str(e)}")
-
-@router.get("/list_devices", summary="列出已发现的交换机")
-async def list_devices():
- return {
- "devices": scanner.load_cached_devices()
- }
-
-class CommandRequest(BaseModel):
- command: str
-
-class ConfigRequest(BaseModel):
- config: dict
- switch_ip: str
- username: str = None
- password: str = None
- timeout: int = None
-
-@router.post("/parse_command", response_model=dict)
-async def parse_command(request: CommandRequest):
- """
- 解析中文命令并返回JSON配置
- """
- try:
- ai_service = AIService(settings.SILICONFLOW_API_KEY, settings.SILICONFLOW_API_URL)
- config = await ai_service.parse_command(request.command)
- return {"success": True, "config": config}
- except Exception as e:
- raise HTTPException(
- status_code=400,
- detail=f"Failed to parse command: {str(e)}"
- )
-
-@router.post("/apply_config", response_model=dict)
-async def apply_config(request: ConfigRequest):
- """
- 应用配置到交换机
- """
- try:
- configurator = SwitchConfigurator(
- username=request.username,
- password=request.password,
- timeout=request.timeout
- )
- result = await configurator.safe_apply(request.switch_ip, request.config)
- return {"success": True, "result": result}
- except Exception as e:
- raise HTTPException(
- status_code=500,
- detail=f"Failed to apply config: {str(e)}"
- )
-
-
-class CLICommandRequest(BaseModel):
- switch_ip: str
- commands: List[str]
- is_ensp: bool = False
-
- def extract_credentials(self) -> tuple:
- """从commands中提取用户名和密码"""
- username = None
- password = None
-
- for cmd in self.commands:
- if cmd.startswith("!username="):
- username = cmd.split("=")[1]
- elif cmd.startswith("!password="):
- password = cmd.split("=")[1]
-
- return username, password
-
- def get_clean_commands(self) -> List[str]:
- """获取去除凭据后的实际命令"""
- return [cmd for cmd in self.commands
- if not (cmd.startswith("!username=") or cmd.startswith("!password="))]
-
-@router.post("/execute_cli_commands", response_model=dict)
-async def execute_cli_commands(request: CLICommandRequest):
- """
- 执行前端生成的CLI命令
- 支持在commands中嵌入凭据:
- !username=admin
- !password=cisco123
- """
- try:
- username, password = request.extract_credentials()
- clean_commands = request.get_clean_commands()
-
- configurator = SwitchConfigurator(
- username=username,
- password=password,
- timeout=settings.SWITCH_TIMEOUT,
- ensp_mode=request.is_ensp
- )
-
- result = await configurator.execute_raw_commands(
- ip=request.switch_ip,
- commands=request.commands
- )
- return {
- "success": True,
- "output": result,
- "mode": "eNSP" if request.is_ensp else "SSH"
- }
- except Exception as e:
- raise HTTPException(500, detail=str(e))
-
-@router.get("/traffic/interfaces", summary="获取所有网络接口")
-async def get_network_interfaces():
- return {
- "interfaces": traffic_monitor.get_interfaces()
- }
-
-@router.get("/traffic/current", summary="获取当前流量数据")
-async def get_current_traffic(interface: str = None):
- return traffic_monitor.get_current_traffic(interface)
-
-@router.get("/traffic/history", summary="获取流量历史数据")
-async def get_traffic_history(interface: str = None, limit: int = 100):
- history = traffic_monitor.get_traffic_history(interface)
- return {
- "sent": history["sent"][-limit:],
- "recv": history["recv"][-limit:],
- "time": [t.isoformat() for t in history["time"]][-limit:]
- }
-
-@router.get("/traffic/records", summary="获取流量记录")
-async def get_traffic_records(interface: str = None, limit: int = 100):
- with SessionLocal() as session:
- query = session.query(TrafficRecord)
- if interface:
- query = query.filter(TrafficRecord.interface == interface)
- records = query.order_by(TrafficRecord.timestamp.desc()).limit(limit).all()
- return [record.to_dict() for record in records]
-
-@router.websocket("/ws/traffic")
-async def websocket_traffic(websocket: WebSocket):
- """实时流量WebSocket"""
- await websocket.accept()
- try:
- while True:
- traffic_data = traffic_monitor.get_current_traffic()
- await websocket.send_json(traffic_data)
- await asyncio.sleep(1)
- except WebSocketDisconnect:
- print("客户端断开连接")
-
@router.get("/", include_in_schema=False)
async def root():
@@ -251,11 +44,163 @@ async def root():
}
+@router.get("/favicon.ico", include_in_schema=False)
+async def favicon():
+ return Response(status_code=204)
+
+
+class BatchConfigRequest(BaseModel):
+ config: dict
+ switch_ips: List[str]
+ username: str = None
+ password: str = None
+ timeout: int = None
+
+@router.get("/test")
+async def test_endpoint():
+ return {"message": "Hello World"}
+
+
+@router.get("/scan_network", summary="扫描网络中的交换机")
+async def scan_network(subnet: str = "192.168.1.0/24"):
+ try:
+ devices = await asyncio.to_thread(scanner.scan_subnet, subnet)
+ return {
+ "success": True,
+ "devices": devices,
+ "count": len(devices)
+ }
+ except Exception as e:
+ raise HTTPException(500, f"扫描失败: {str(e)}")
+
+
+@router.get("/list_devices", summary="列出已发现的交换机")
+async def list_devices():
+ return {
+ "devices": await asyncio.to_thread(scanner.load_cached_devices)
+ }
+
+
+class DeviceItem(BaseModel):
+ name: str
+ ip: str
+ vendor: str
+
+class CommandRequest(BaseModel):
+ command: str
+ devices: List[DeviceItem]
+
+@router.post("/parse_command", response_model=dict)
+async def parse_command(request: CommandRequest):
+ """解析中文命令并返回每台设备的配置 JSON"""
+ missing_vendor = [d for d in request.devices if not d.vendor or d.vendor.strip() == ""]
+ if missing_vendor:
+ names = ", ".join([d.name for d in missing_vendor])
+ raise HTTPException(
+ status_code=400,
+ detail=f"以下设备未配置厂商: {names}"
+ )
+ try:
+ ai_service = AIService(settings.SILICONFLOW_API_KEY, settings.SILICONFLOW_API_URL)
+ config = await ai_service.parse_command(request.command, [d.dict() for d in request.devices])
+ return {"success": True, "config": config.get("results", [])}
+ except Exception as e:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Failed to parse command: {str(e)}"
+ )
+
+
+@router.post("/apply_config", response_model=dict)
+async def apply_config(request: ConfigRequest):
+ """应用配置到交换机"""
+ try:
+ configurator = SwitchConfigurator(
+ username=request.username,
+ password=request.password,
+ timeout=request.timeout,
+ vendor=request.vendor
+ )
+ result = await configurator.safe_apply(request.switch_ip, request.config)
+ return {"success": True, "result": result}
+ except Exception as e:
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to apply config: {str(e)}"
+ )
+@router.post("/execute_cli_commands", response_model=dict)
+async def execute_cli_commands(request: CLICommandRequest):
+ """执行前端生成的CLI命令"""
+ try:
+ username, password = request.extract_credentials()
+
+ configurator = SwitchConfigurator(
+ username=username,
+ password=password,
+ timeout=settings.SWITCH_TIMEOUT,
+ )
+
+ result = await configurator.execute_raw_commands(
+ ip=request.switch_ip,
+ commands=request.commands
+ )
+ return {
+ "success": True,
+ "output": result,
+ }
+ except Exception as e:
+ raise HTTPException(500, detail=str(e))
+
+
+@router.get("/traffic/interfaces", summary="获取所有网络接口")
+async def get_network_interfaces():
+ return {
+ "interfaces": await asyncio.to_thread(traffic_monitor.get_interfaces)
+ }
+
+
+@router.get("/traffic/current", summary="获取当前流量数据")
+async def get_current_traffic(interface: str = None):
+ return await asyncio.to_thread(traffic_monitor.get_current_traffic, interface)
+
+
+@router.get("/traffic/history", summary="获取流量历史数据")
+async def get_traffic_history(interface: str = None, limit: int = 100):
+ history = await asyncio.to_thread(traffic_monitor.get_traffic_history, interface)
+ return {
+ "sent": history["sent"][-limit:],
+ "recv": history["recv"][-limit:],
+ "time": [t.isoformat() for t in history["time"]][-limit:]
+ }
+
+
+@router.get("/traffic/records", summary="获取流量记录")
+async def get_traffic_records(interface: str = None, limit: int = 100):
+ def sync_get_records():
+ with SessionLocal() as session:
+ query = session.query(TrafficRecord)
+ if interface:
+ query = query.filter(TrafficRecord.interface == interface)
+ records = query.order_by(TrafficRecord.timestamp.desc()).limit(limit).all()
+ return [record.to_dict() for record in records]
+
+ return await asyncio.to_thread(sync_get_records)
+
+
+@router.websocket("/ws/traffic")
+async def websocket_traffic(websocket: WebSocket):
+ await websocket.accept()
+ try:
+ while True:
+ traffic_data = await asyncio.to_thread(traffic_monitor.get_current_traffic)
+ await websocket.send_json(traffic_data)
+ await asyncio.sleep(1)
+ except WebSocketDisconnect:
+ print("客户端断开连接")
@router.get("/traffic/switch/interfaces", summary="获取交换机的网络接口")
async def get_switch_interfaces(switch_ip: str):
- """获取指定交换机的所有接口"""
try:
monitor = get_switch_monitor(switch_ip)
interfaces = list(monitor.interface_oids.keys())
@@ -268,13 +213,45 @@ async def get_switch_interfaces(switch_ip: str):
raise HTTPException(500, f"获取接口失败: {str(e)}")
+async def get_interface_current_traffic(switch_ip: str, interface: str) -> dict:
+ """获取指定交换机接口的当前流量数据"""
+ try:
+ def sync_get_record():
+ with SessionLocal() as session:
+ record = session.query(SwitchTrafficRecord).filter(
+ SwitchTrafficRecord.switch_ip == switch_ip,
+ SwitchTrafficRecord.interface == interface
+ ).order_by(SwitchTrafficRecord.timestamp.desc()).first()
+
+ if not record:
+ return {
+ "switch_ip": switch_ip,
+ "interface": interface,
+ "rate_in": 0.0,
+ "rate_out": 0.0,
+ "bytes_in": 0,
+ "bytes_out": 0
+ }
+
+ return {
+ "switch_ip": switch_ip,
+ "interface": interface,
+ "rate_in": record.rate_in,
+ "rate_out": record.rate_out,
+ "bytes_in": record.bytes_in,
+ "bytes_out": record.bytes_out
+ }
+
+ return await asyncio.to_thread(sync_get_record)
+ except Exception as e:
+ logger.error(f"获取接口流量失败: {str(e)}")
+ raise HTTPException(500, f"获取接口流量失败: {str(e)}")
+
+
@router.get("/traffic/switch/current", summary="获取交换机的当前流量数据")
async def get_switch_current_traffic(switch_ip: str, interface: str = None):
- """获取交换机的当前流量数据"""
try:
monitor = get_switch_monitor(switch_ip)
-
-
if not interface:
traffic_data = {}
for iface in monitor.interface_oids:
@@ -283,78 +260,44 @@ async def get_switch_current_traffic(switch_ip: str, interface: str = None):
"switch_ip": switch_ip,
"traffic": traffic_data
}
-
return await get_interface_current_traffic(switch_ip, interface)
except Exception as e:
logger.error(f"获取交换机流量失败: {str(e)}")
raise HTTPException(500, f"获取流量失败: {str(e)}")
-async def get_interface_current_traffic(switch_ip: str, interface: str) -> dict:
- """获取指定交换机接口的当前流量数据"""
- try:
- with SessionLocal() as session:
-
- record = session.query(SwitchTrafficRecord).filter(
- SwitchTrafficRecord.switch_ip == switch_ip,
- SwitchTrafficRecord.interface == interface
- ).order_by(SwitchTrafficRecord.timestamp.desc()).first()
-
- if not record:
- return {
- "switch_ip": switch_ip,
- "interface": interface,
- "rate_in": 0.0,
- "rate_out": 0.0,
- "bytes_in": 0,
- "bytes_out": 0
- }
-
- return {
- "switch_ip": switch_ip,
- "interface": interface,
- "rate_in": record.rate_in,
- "rate_out": record.rate_out,
- "bytes_in": record.bytes_in,
- "bytes_out": record.bytes_out
- }
- except Exception as e:
- logger.error(f"获取接口流量失败: {str(e)}")
- raise HTTPException(500, f"获取接口流量失败: {str(e)}")
-
-
@router.get("/traffic/switch/history", summary="获取交换机的流量历史数据")
async def get_switch_traffic_history(switch_ip: str, interface: str = None, minutes: int = 10):
- """获取交换机的流量历史数据"""
try:
monitor = get_switch_monitor(switch_ip)
-
if not interface:
return {
"switch_ip": switch_ip,
- "history": monitor.get_traffic_history()
+ "history": await asyncio.to_thread(monitor.get_traffic_history)
}
- with SessionLocal() as session:
- time_threshold = datetime.now() - timedelta(minutes=minutes)
+ def sync_get_history():
+ with SessionLocal() as session:
+ time_threshold = datetime.now() - timedelta(minutes=minutes)
+ records = session.query(SwitchTrafficRecord).filter(
+ SwitchTrafficRecord.switch_ip == switch_ip,
+ SwitchTrafficRecord.interface == interface,
+ SwitchTrafficRecord.timestamp >= time_threshold
+ ).order_by(SwitchTrafficRecord.timestamp.asc()).all()
- records = session.query(SwitchTrafficRecord).filter(
- SwitchTrafficRecord.switch_ip == switch_ip,
- SwitchTrafficRecord.interface == interface,
- SwitchTrafficRecord.timestamp >= time_threshold
- ).order_by(SwitchTrafficRecord.timestamp.asc()).all()
+ history_data = {
+ "in": [record.rate_in for record in records],
+ "out": [record.rate_out for record in records],
+ "time": [record.timestamp.isoformat() for record in records]
+ }
+ return history_data
- history_data = {
- "in": [record.rate_in for record in records],
- "out": [record.rate_out for record in records],
- "time": [record.timestamp.isoformat() for record in records]
- }
-
- return {
- "switch_ip": switch_ip,
- "interface": interface,
- "history": history_data
- }
+ history_data = await asyncio.to_thread(sync_get_history)
+ return {
+ "switch_ip": switch_ip,
+ "interface": interface,
+ "history": history_data
+ }
except Exception as e:
logger.error(f"获取历史流量失败: {str(e)}")
raise HTTPException(500, f"获取历史流量失败: {str(e)}")
@@ -362,11 +305,9 @@ async def get_switch_traffic_history(switch_ip: str, interface: str = None, minu
@router.websocket("/ws/traffic/switch")
async def websocket_switch_traffic(websocket: WebSocket, switch_ip: str, interface: str = None):
- """交换机实时流量WebSocket"""
await websocket.accept()
try:
monitor = get_switch_monitor(switch_ip)
-
while True:
if interface:
traffic_data = await get_interface_current_traffic(switch_ip, interface)
@@ -375,12 +316,10 @@ async def websocket_switch_traffic(websocket: WebSocket, switch_ip: str, interfa
traffic_data = {}
for iface in monitor.interface_oids:
traffic_data[iface] = await get_interface_current_traffic(switch_ip, iface)
-
await websocket.send_json({
"switch_ip": switch_ip,
"traffic": traffic_data
})
-
await asyncio.sleep(1)
except WebSocketDisconnect:
logger.info(f"客户端断开交换机流量连接: {switch_ip}")
@@ -388,33 +327,35 @@ async def websocket_switch_traffic(websocket: WebSocket, switch_ip: str, interfa
logger.error(f"交换机流量WebSocket错误: {str(e)}")
await websocket.close(code=1011, reason=str(e))
+
@router.get("/traffic/switch/plot", response_class=HTMLResponse, summary="交换机流量可视化")
async def plot_switch_traffic(switch_ip: str, interface: str, minutes: int = 10):
- """生成交换机流量图表"""
try:
history = await get_switch_traffic_history(switch_ip, interface, minutes)
history_data = history["history"]
-
time_points = [datetime.fromisoformat(t) for t in history_data["time"]]
in_rates = history_data["in"]
out_rates = history_data["out"]
- plt.figure(figsize=(12, 6))
- plt.plot(time_points, in_rates, label="流入流量 (B/s)")
- plt.plot(time_points, out_rates, label="流出流量 (B/s)")
- plt.title(f"交换机 {switch_ip} 接口 {interface} 流量监控 - 最近 {minutes} 分钟")
- plt.xlabel("时间")
- plt.ylabel("流量 (字节/秒)")
- plt.legend()
- plt.grid(True)
- plt.xticks(rotation=45)
- plt.tight_layout()
- buf = io.BytesIO()
- plt.savefig(buf, format="png")
- buf.seek(0)
- image_base64 = base64.b64encode(buf.read()).decode("utf-8")
- plt.close()
+ def generate_plot():
+ plt.figure(figsize=(12, 6))
+ plt.plot(time_points, in_rates, label="流入流量 (B/s)")
+ plt.plot(time_points, out_rates, label="流出流量 (B/s)")
+ plt.title(f"交换机 {switch_ip} 接口 {interface} 流量监控 - 最近 {minutes} 分钟")
+ plt.xlabel("时间")
+ plt.ylabel("流量 (字节/秒)")
+ plt.legend()
+ plt.grid(True)
+ plt.xticks(rotation=45)
+ plt.tight_layout()
+ buf = io.BytesIO()
+ plt.savefig(buf, format="png")
+ buf.seek(0)
+ image_base64 = base64.b64encode(buf.read()).decode("utf-8")
+ plt.close()
+ return image_base64
+ image_base64 = await asyncio.to_thread(generate_plot)
return f"""
@@ -441,24 +382,24 @@ async def plot_switch_traffic(switch_ip: str, interface: str, minutes: int = 10)
@router.get("/network_adapters", summary="获取网络适配器网段")
async def get_network_adapters():
try:
- net_if_addrs = psutil.net_if_addrs()
-
- networks = []
- for interface, addrs in net_if_addrs.items():
- for addr in addrs:
- if addr.family == socket.AF_INET:
- ip = addr.address
- netmask = addr.netmask
-
- network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
- networks.append({
- "adapter": interface,
- "network": str(network),
- "ip": ip,
- "subnet_mask": netmask
- })
+ def sync_get_adapters():
+ net_if_addrs = psutil.net_if_addrs()
+ networks = []
+ for interface, addrs in net_if_addrs.items():
+ for addr in addrs:
+ if addr.family == socket.AF_INET:
+ ip = addr.address
+ netmask = addr.netmask
+ network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
+ networks.append({
+ "adapter": interface,
+ "network": str(network),
+ "ip": ip,
+ "subnet_mask": netmask
+ })
+ return networks
+ networks = await asyncio.to_thread(sync_get_adapters)
return {"networks": networks}
-
except Exception as e:
return {"error": f"获取网络适配器信息失败: {str(e)}"}
\ No newline at end of file
diff --git a/src/backend/app/api/network_config.py b/src/backend/app/api/network_config.py
index 3a84bae..b512273 100644
--- a/src/backend/app/api/network_config.py
+++ b/src/backend/app/api/network_config.py
@@ -1,19 +1,14 @@
import asyncio
import logging
import telnetlib3
-from datetime import datetime
from pathlib import Path
-from typing import Dict, List, Optional, Union
+from typing import Dict, List, Optional
-import aiofiles
-import asyncssh
from pydantic import BaseModel
-from tenacity import retry, stop_after_attempt, wait_exponential
from src.backend.app.utils.logger import logger
from src.backend.config import settings
-
# ----------------------
# 数据模型
# ----------------------
@@ -25,36 +20,31 @@ class SwitchConfig(BaseModel):
ip_address: Optional[str] = None
vlan: Optional[int] = None
-
# ----------------------
# 异常类
# ----------------------
class SwitchConfigException(Exception):
pass
-
class EnspConnectionException(SwitchConfigException):
pass
-
-class SSHConnectionException(SwitchConfigException):
- pass
-
-
# ----------------------
-# 核心配置器(完整双模式)
+# 核心配置器
# ----------------------
class SwitchConfigurator:
+ connection_pool: Dict[str, tuple] = {}
+
def __init__(
- self,
- username: str = None,
- password: str = None,
- timeout: int = None,
- max_workers: int = 5,
- ensp_mode: bool = False,
- ensp_port: int = 2000,
- ensp_command_delay: float = 0.5,
- **ssh_options
+ self,
+ username: str = None,
+ password: str = None,
+ timeout: int = None,
+ max_workers: int = 5,
+ ensp_mode: bool = False,
+ ensp_port: int = 2000,
+ ensp_command_delay: float = 0.5,
+ **ssh_options
):
self.username = username if username is not None else settings.SWITCH_USERNAME
self.password = password if password is not None else settings.SWITCH_PASSWORD
@@ -67,250 +57,66 @@ class SwitchConfigurator:
self.ensp_delay = ensp_command_delay
self.ssh_options = ssh_options
- async def apply_config(self, ip: str, config: Union[Dict, SwitchConfig]) -> str:
- """实际配置逻辑"""
- if isinstance(config, dict):
- config = SwitchConfig(**config)
-
- commands = (
- self._generate_ensp_commands(config)
- if self.ensp_mode
- else self._generate_standard_commands(config)
- )
- return await self._send_commands(ip, commands)
-
- async def _send_commands(self, ip: str, commands: List[str]) -> str:
- """双模式命令发送"""
- return (
- await self._send_ensp_commands(ip, commands)
- )
-
- async def _send_ensp_commands(self, ip: str, commands: List[str]) -> str:
+ async def _get_or_create_connection(self, ip: str):
"""
- 通过 Telnet 协议连接 eNSP 设备
+ 从连接池获取连接,如果没有则新建 Telnet 连接
+ """
+ if ip in self.connection_pool:
+ logger.debug(f"复用已有连接: {ip}")
+ return self.connection_pool[ip]
+
+ logger.info(f"建立新连接: {ip}")
+ reader, writer = await telnetlib3.open_connection(host=ip, port=23)
+
+ try:
+ if self.username != 'NONE' :
+ await asyncio.wait_for(reader.readuntil(b"Username:"), timeout=self.timeout)
+ writer.write(f"{self.username}\n")
+
+ await asyncio.wait_for(reader.readuntil(b"Password:"), timeout=self.timeout)
+ writer.write(f"{self.password}\n")
+
+ await asyncio.sleep(1)
+ except asyncio.TimeoutError:
+ writer.close()
+ raise EnspConnectionException("登录超时,未收到用户名或密码提示")
+ except Exception as e:
+ writer.close()
+ raise EnspConnectionException(f"登录异常: {e}")
+
+ self.connection_pool[ip] = (reader, writer)
+ return reader, writer
+
+ async def _send_ensp_commands(self, ip: str, commands: List[str]) -> bool:
+ """
+ 通过 Telnet 协议发送命令
"""
try:
- logger.info(f"连接设备 {ip},端口23")
- reader, writer = await telnetlib3.open_connection(host=ip, port=23)
- logger.debug("连接成功,开始登录流程")
+ reader, writer = await self._get_or_create_connection(ip)
- try:
- if self.username != 'NONE':
- await asyncio.wait_for(reader.readuntil(b"Username:"), timeout=self.timeout)
- logger.debug("收到 'Username:' 提示,发送用户名")
- writer.write(f"{self.username}\n")
-
- await asyncio.wait_for(reader.readuntil(b"Password:"), timeout=self.timeout)
- logger.debug("收到 'Password:' 提示,发送密码")
- writer.write(f"{self.password}\n")
-
- await asyncio.sleep(1)
- except asyncio.TimeoutError:
- raise EnspConnectionException("登录超时,未收到用户名或密码提示")
-
- output = ""
for cmd in commands:
- logger.info(f"发送命令: {cmd}")
+ if cmd.startswith("!"):
+ logger.debug(f"跳过特殊命令: {cmd}")
+ continue
+ logger.info(f"[{ip}] 发送命令: {cmd}")
writer.write(f"{cmd}\n")
await writer.drain()
+ await asyncio.sleep(self.ensp_delay)
- command_output = ""
- try:
- while True:
- data = await asyncio.wait_for(reader.read(1024), timeout=1)
- if not data:
- logger.debug("读取到空数据,结束当前命令读取")
- break
- command_output += data
- logger.debug(f"收到数据: {repr(data)}")
- except asyncio.TimeoutError:
- logger.debug("命令输出读取超时,继续执行下一条命令")
-
- output += f"\n[命令: {cmd} 输出开始]\n{command_output}\n[命令: {cmd} 输出结束]\n"
-
- logger.info("所有命令执行完毕,关闭连接")
- writer.close()
-
- return output
+ logger.info(f"[{ip}] 所有命令发送完成")
+ return True
except asyncio.TimeoutError as e:
- logger.error(f"连接或读取超时: {e}")
- raise EnspConnectionException(f"eNSP连接超时: {e}")
+ logger.error(f"[{ip}] 连接或读取超时: {e}")
+ return False
except Exception as e:
- logger.error(f"连接或执行异常: {e}", exc_info=True)
- raise EnspConnectionException(f"eNSP连接失败: {e}")
-
- @staticmethod
- def _generate_ensp_commands(config: SwitchConfig) -> List[str]:
- """生成eNSP命令序列"""
- commands = ["system-view"]
- if config.type == "vlan":
- commands.extend([
- f"vlan {config.vlan_id}",
- f"description {config.name or ''}"
- ])
- elif config.type == "interface":
- commands.extend([
- f"interface {config.interface}",
- "port link-type access",
- f"port default vlan {config.vlan}" if config.vlan else "",
- f"ip address {config.ip_address}" if config.ip_address else ""
- ])
- commands.append("return")
- return [c for c in commands if c.strip()]
-
- async def _send_ssh_commands(self, ip: str, commands: List[str]) -> str:
- """AsyncSSH执行命令"""
- async with self.semaphore:
- try:
- async with asyncssh.connect(
- host=ip,
- username=self.username,
- password=self.password,
- connect_timeout=self.timeout,
- **self.ssh_options
- ) as conn:
- results = []
- for cmd in commands:
- result = await conn.run(cmd, check=True)
- results.append(result.stdout)
- return "\n".join(results)
- except asyncssh.Error as e:
- raise SSHConnectionException(f"SSH操作失败: {str(e)}")
- except Exception as e:
- raise SSHConnectionException(f"连接异常: {str(e)}")
-
- async def execute_raw_commands(self, ip: str, commands: List[str]) -> str:
- """
- 执行原始CLI命令
- """
- return await self._send_commands(ip, commands)
-
-
- @staticmethod
- def _generate_standard_commands(config: SwitchConfig) -> List[str]:
- """生成标准CLI命令"""
- commands = []
- if config.type == "vlan":
- commands.extend([
- f"vlan {config.vlan_id}",
- f"name {config.name or ''}"
- ])
- elif config.type == "interface":
- commands.extend([
- f"interface {config.interface}",
- f"switchport access vlan {config.vlan}" if config.vlan else "",
- f"ip address {config.ip_address}" if config.ip_address else ""
- ])
- return commands
-
- async def _validate_config(self, ip: str, config: SwitchConfig) -> bool:
- """验证配置是否生效"""
- current = await self._get_current_config(ip)
- if config.type == "vlan":
- return f"vlan {config.vlan_id}" in current
- elif config.type == "interface" and config.vlan:
- return f"switchport access vlan {config.vlan}" in current
- return True
-
- async def _get_current_config(self, ip: str) -> str:
- """获取当前配置"""
- commands = (
- ["display current-configuration"]
- if self.ensp_mode
- else ["show running-config"]
- )
- try:
- return await self._send_commands(ip, commands)
- except (EnspConnectionException, SSHConnectionException) as e:
- raise SwitchConfigException(f"配置获取失败: {str(e)}")
-
- async def _backup_config(self, ip: str) -> Path:
- """备份配置到文件"""
- backup_path = self.backup_dir / f"{ip}_{datetime.now().isoformat()}.cfg"
- config = await self._get_current_config(ip)
- async with aiofiles.open(backup_path, "w") as f:
- await f.write(config)
- return backup_path
-
- async def _restore_config(self, ip: str, backup_path: Path) -> bool:
- """从备份恢复配置"""
- try:
- async with aiofiles.open(backup_path) as f:
- config = await f.read()
- commands = (
- ["system-view", config, "return"]
- if self.ensp_mode
- else [f"configure terminal\n{config}\nend"]
- )
- await self._send_commands(ip, commands)
- return True
- except Exception as e:
- logging.error(f"恢复失败: {str(e)}")
+ logger.error(f"[{ip}] 命令发送异常: {e}", exc_info=True)
return False
-
- @retry(
- stop=stop_after_attempt(2),
- wait=wait_exponential(multiplier=1, min=4, max=10)
- )
- async def safe_apply(
- self,
- ip: str,
- config: Union[Dict, SwitchConfig]
- ) -> Dict[str, Union[str, bool, Path]]:
- """安全配置应用(自动回滚)"""
- backup_path = await self._backup_config(ip)
- try:
- result = await self.apply_config(ip, config)
- if not await self._validate_config(ip, config):
- raise SwitchConfigException("配置验证失败")
- return {
- "status": "success",
- "output": result,
- "backup_path": str(backup_path)
- }
- except (EnspConnectionException, SSHConnectionException, SwitchConfigException) as e:
- restore_status = await self._restore_config(ip, backup_path)
- return {
- "status": "failed",
- "error": str(e),
- "backup_path": str(backup_path),
- "restore_success": restore_status
- }
-
-
-# ----------------------
-# 使用示例
-# ----------------------
-async def demo():
- ensp_configurator = SwitchConfigurator(
- ensp_mode=True,
- ensp_port=2000,
- username="admin",
- password="admin",
- timeout=15
- )
- ensp_result = await ensp_configurator.safe_apply("127.0.0.1", {
- "type": "interface",
- "interface": "GigabitEthernet0/0/1",
- "vlan": 100,
- "ip_address": "192.168.1.2 255.255.255.0"
- })
- print("eNSP配置结果:", ensp_result)
-
- ssh_configurator = SwitchConfigurator(
- username="cisco",
- password="cisco123",
- timeout=15
- )
- ssh_result = await ssh_configurator.safe_apply("192.168.1.1", {
- "type": "vlan",
- "vlan_id": 200,
- "name": "Production"
- })
- print("SSH配置结果:", ssh_result)
-
-
-if __name__ == "__main__":
- asyncio.run(demo())
+ async def execute_raw_commands(self, ip: str, commands: List[str]) -> bool:
+ """
+ 对外接口:单台交换机执行命令
+ """
+ async with self.semaphore:
+ success = await self._send_ensp_commands(ip, commands)
+ return success
diff --git a/src/backend/app/models/__init__.py b/src/backend/app/models/__init__.py
index 689d764..141a465 100644
--- a/src/backend/app/models/__init__.py
+++ b/src/backend/app/models/__init__.py
@@ -1,5 +1,6 @@
from pydantic import BaseModel
-from typing import Optional
+from typing import Optional, List
+
class BaseResponse(BaseModel):
success: bool
@@ -19,5 +20,21 @@ class ConfigHistory(BaseModel):
timestamp: float
status: str # success/failed
error: Optional[str] = None
+class TrafficReport(BaseModel):
+ report_type: str
+ period: str
+ interface_stats: List[dict]
+ trend_chart: Optional[str] = None # base64 encoded image
+
+class TopologyVisualization(BaseModel):
+ nodes: List[dict]
+ edges: List[dict]
+ image: Optional[str] = None # base64 encoded image
+
+class ConfigValidationResult(BaseModel):
+ valid: bool
+ errors: List[str]
+ has_security_risks: bool
+ warnings: Optional[List[str]] = None
__all__ = ["BaseResponse", "SwitchInfo", "ConfigHistory"]
diff --git a/src/backend/app/models/requests.py b/src/backend/app/models/requests.py
new file mode 100644
index 0000000..4c696f1
--- /dev/null
+++ b/src/backend/app/models/requests.py
@@ -0,0 +1,26 @@
+from typing import List, Optional
+from pydantic import BaseModel
+
+class BatchConfigRequest(BaseModel):
+ config: dict
+ switch_ips: List[str]
+ username: Optional[str] = None
+ password: Optional[str] = None
+ timeout: Optional[int] = None
+
+class ConfigRequest(BaseModel):
+ config: dict
+ switch_ip: str
+ username: Optional[str] = None
+ password: Optional[str] = None
+ timeout: Optional[int] = None
+ vendor: str = "huawei"
+
+class CLICommandRequest(BaseModel):
+ switch_ip: str
+ commands: List[str]
+ username: Optional[str] = None
+ password: Optional[str] = None
+
+ def extract_credentials(self):
+ return self.username or "NONE", self.password or "NONE"
diff --git a/src/backend/app/services/ai_services.py b/src/backend/app/services/ai_services.py
index d315477..74e48c9 100644
--- a/src/backend/app/services/ai_services.py
+++ b/src/backend/app/services/ai_services.py
@@ -1,40 +1,47 @@
-from typing import Dict, Any, Coroutine
-
-import httpx
-from openai import OpenAI
+from typing import Any, List, Dict
+from openai import AsyncOpenAI
import json
from src.backend.app.utils.exceptions import SiliconFlowAPIException
from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam
-from src.backend.app.utils.logger import logger
-
class AIService:
def __init__(self, api_key: str, api_url: str):
- self.api_key = api_key
- self.api_url = api_url
- self.client = OpenAI(
- api_key=self.api_key,
- base_url=self.api_url,
- # timeout=httpx.Timeout(30.0)
- )
+ self.client = AsyncOpenAI(api_key=api_key, base_url=api_url)
- async def parse_command(self, command: str) -> Any | None:
+ async def parse_command(self, command: str, devices: List[Dict]) -> Dict[str, Any]:
"""
- 调用硅基流动API解析中文命令
- """
- prompt = """
- 你是一个网络设备配置专家,精通各种类型的路由器的配置,请将以下用户的中文命令转换为网络设备配置JSON
- 但是请注意,由于贪婪的人们追求极高的效率,所以你必须严格按照 JSON 格式返回数据,不要包含任何额外文本或 Markdown 代码块
- 返回格式要求:
- 1. 必须包含'type'字段指明配置类型(vlan/interface/acl/route等)
- 2. 必须包含'commands'字段,包含可直接执行的命令列表
- 3. 其他参数根据配置类型动态添加
- 4. 不要包含解释性文本、步骤说明或注释
- 5.要包含使用ssh连接交换机后的完整命令包括但不完全包括system-view,退出,保存等完整操作,注意保存还需要输入Y
- 示例命令:'创建VLAN 100,名称为TEST'
- 示例返回:{"type": "vlan", "vlan_id": 100, "name": "TEST", "commands": ["system-view","vlan 100", "name TEST","quit","quit","save","Y"]}
- 注意:这里生成的commands中需包含登录交换机和保存等所有操作命令,我们使ssh连接交换机,你不需要给出连接ssh的命令,你只需要给出使用ssh连接到交换机后所输入的全部命令,并且注意在system-view状态下是不能save的,需要再quit到用户视图
+ 针对一组设备和一条自然语言命令,生成每台设备的配置 JSON
"""
+ devices_str = json.dumps(devices, ensure_ascii=False, indent=2)
+
+ example = """[{"device": {"name": "sw1","ip": "192.168.1.10","vendor": "huawei","username": "NONE", "password": "Huawei"},"config": {"type": "vlan","vlan_id": 300,"name": "Sales","commands": ["system-view","vlan 300","name Sales","quit","quit","save","Y"]}}]"""
+
+ prompt = f"""
+你是一个网络设备配置专家。现在有以下设备:
+{devices_str}
+
+用户输入了一条命令:{command}
+
+你的任务:
+- 为每台设备分别生成配置
+- 输出一个 JSON 数组,每个元素对应一台设备
+- 每个对象必须包含:
+ - device: 原始设备信息 (name, ip, vendor,username,password)
+ - config: 配置详情
+ - type: 配置类型 (如 vlan/interface/acl/route)
+ - commands: 可直接执行的命令数组 (必须包含进入配置、退出、保存命令)
+ - 其他字段: 根据配置类型动态添加
+- 严格返回 JSON,不要包含解释说明或 markdown
+
+各厂商保存命令规则:
+- 华为: system-view → quit → save Y
+- 思科: enable → configure terminal → exit → write memory
+- H3C: system-view → quit → save
+- 锐捷: enable → configure terminal → exit → write
+- 中兴: enable → configure terminal → exit → write memory
+
+返回示例(仅作为格式参考,不要照抄 VLAN ID 和命令内容,请根据实际命令生成):{example}
+"""
messages = [
ChatCompletionSystemMessageParam(role="system", content=prompt),
@@ -42,32 +49,21 @@ class AIService:
]
try:
- response = self.client.chat.completions.create(
+ response = await self.client.chat.completions.create(
model="deepseek-ai/DeepSeek-V3",
messages=messages,
- temperature=0.3,
- max_tokens=1000,
+ temperature=0.2,
+ max_tokens=1500,
response_format={"type": "json_object"}
)
- logger.debug(response)
-
config_str = response.choices[0].message.content.strip()
+ configs = json.loads(config_str)
- try:
- config = json.loads(config_str)
- return config
- except json.JSONDecodeError:
- if config_str.startswith("```json"):
- config_str = config_str[7:-3].strip()
- return json.loads(config_str)
- raise SiliconFlowAPIException("Invalid JSON format returned from AI")
- except KeyError:
- logger.error(KeyError)
- raise SiliconFlowAPIException("errrrrrrro")
+ return {"success": True, "results": configs}
except Exception as e:
raise SiliconFlowAPIException(
- detail=f"API请求失败: {str(e)}",
+ detail=f"AI 解析配置失败: {str(e)}",
status_code=getattr(e, "status_code", 500)
)
diff --git a/src/backend/app/services/network_optimizer.py b/src/backend/app/services/network_optimizer.py
deleted file mode 100644
index e58bd5c..0000000
--- a/src/backend/app/services/network_optimizer.py
+++ /dev/null
@@ -1,34 +0,0 @@
-
-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/backend/app/services/network_scanner.py b/src/backend/app/services/network_scanner.py
index 26ea2c8..bdb7f0b 100644
--- a/src/backend/app/services/network_scanner.py
+++ b/src/backend/app/services/network_scanner.py
@@ -48,4 +48,4 @@ class NetworkScanner:
return []
with open(self.cache_path) as f:
- return json.load(f)
+ return json.load(f)
\ No newline at end of file
diff --git a/src/backend/app/services/switch_traffic_monitor.py b/src/backend/app/services/switch_traffic_monitor.py
index 80a45fd..7c20f3d 100644
--- a/src/backend/app/services/switch_traffic_monitor.py
+++ b/src/backend/app/services/switch_traffic_monitor.py
@@ -7,7 +7,7 @@ from ..models.traffic_models import SwitchTrafficRecord
from src.backend.app.api.database import SessionLocal
from ..utils.logger import logger
-
+#V=ΔQ'-ΔQ/Δt (B/s)
class SwitchTrafficMonitor:
def __init__(
self,
diff --git a/src/backend/app/services/test.py b/src/backend/app/services/test.py
new file mode 100644
index 0000000..29db72e
--- /dev/null
+++ b/src/backend/app/services/test.py
@@ -0,0 +1,9 @@
+# test_linprog.py
+import numpy as np
+from scipy.optimize import linprog
+
+c = np.array([-1, -2])
+A_ub = np.array([[1, 1]])
+b_ub = np.array([3])
+res = linprog(c, A_ub=A_ub, b_ub=b_ub, method='highs')
+print(res)
\ No newline at end of file
diff --git a/src/backend/app/services/traffic_monitor.py b/src/backend/app/services/traffic_monitor.py
index 54adb11..1c498b3 100644
--- a/src/backend/app/services/traffic_monitor.py
+++ b/src/backend/app/services/traffic_monitor.py
@@ -8,6 +8,7 @@ from typing import Dict, Optional, List
from ..models.traffic_models import TrafficRecord
from src.backend.app.api.database import SessionLocal
+from ..utils.logger import logger
class TrafficMonitor:
@@ -33,7 +34,7 @@ class TrafficMonitor:
if not self.running:
self.running = True
self.task = asyncio.create_task(self._monitor_loop())
- print("流量监控已启动")
+ logger.info("流量监控已启动")
async def stop_monitoring(self):
"""停止流量监控"""
@@ -44,7 +45,7 @@ class TrafficMonitor:
await self.task
except asyncio.CancelledError:
pass
- print("流量监控已停止")
+ logger.info("流量监控已停止")
async def _monitor_loop(self):
"""监控主循环"""
diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt
index 37910c5..3fbede2 100644
--- a/src/backend/requirements.txt
+++ b/src/backend/requirements.txt
@@ -10,13 +10,14 @@ httpx==0.27.0
python-nmap==0.7.1
pysnmp==7.1.21
aiofiles==23.2.1
-
+pandas==2.3.1
loguru==0.7.2
tenacity==8.2.3
-
+networkx==3.5
asyncio==3.4.3
typing_extensions==4.10.0
-scapy
+scipy==1.16.1
+scapy==2.6.1
psutil==5.9.8
matplotlib==3.8.3
sqlalchemy==2.0.28
diff --git a/src/frontend/src/components/pages/config/DeviceConfigModal.jsx b/src/frontend/src/components/pages/config/DeviceConfigModal.jsx
index 744ad81..cd495d0 100644
--- a/src/frontend/src/components/pages/config/DeviceConfigModal.jsx
+++ b/src/frontend/src/components/pages/config/DeviceConfigModal.jsx
@@ -12,29 +12,29 @@ import {
Field,
Input,
Stack,
+ Portal,
+ Select,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import { FiCheck } from 'react-icons/fi';
-import Notification from '@/libs/system/Notification';
+import { createListCollection } from '@chakra-ui/react';
const MotionBox = motion(Box);
-/**
- * 设备配置弹窗
- * @param isOpen 是否打开
- * @param onClose 关闭弹窗
- * @param onSave 保存修改
- * @param device 当前设备
- * @returns {JSX.Element}
- * @constructor
- */
+const vendors = ['huawei', 'cisco', 'h3c', 'ruijie', 'zte'];
+
const DeviceConfigModal = ({ isOpen, onClose, onSave, device }) => {
const [username, setUsername] = useState(device.username || '');
const [password, setPassword] = useState(device.password || '');
+ const [vendor, setVendor] = useState(device.vendor || '');
const [saved, setSaved] = useState(false);
+ const vendorCollection = createListCollection({
+ items: vendors.map((v) => ({ label: v.toUpperCase(), value: v })),
+ });
+
const handleSave = () => {
- const updatedDevice = { ...device, username, password };
+ const updatedDevice = { ...device, username, password, vendor };
onSave(updatedDevice);
setSaved(true);
setTimeout(() => {
@@ -82,6 +82,40 @@ const DeviceConfigModal = ({ isOpen, onClose, onSave, device }) => {
type={'password'}
/>
+
+
+ 交换机厂商
+ setVendor(value[0] || '')}
+ placeholder={'请选择厂商'}
+ size={'sm'}
+ colorPalette={'teal'}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {vendorCollection.items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+
+
diff --git a/src/frontend/src/pages/ConfigPage.jsx b/src/frontend/src/pages/ConfigPage.jsx
index 8591ce9..4a0ff67 100644
--- a/src/frontend/src/pages/ConfigPage.jsx
+++ b/src/frontend/src/pages/ConfigPage.jsx
@@ -22,22 +22,16 @@ 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 [selectedDeviceConfig, setSelectedDeviceConfig] = useState('');
+ const [selectedDevices, setSelectedDevices] = useState([]);
+ const [deviceConfigs, setDeviceConfigs] = useState({});
const [inputText, setInputText] = useState('');
- const [parsedConfig, setParsedConfig] = useState('');
- const [editableConfig, setEditableConfig] = useState('');
const [applying, setApplying] = useState(false);
const [hasParsed, setHasParsed] = useState(false);
- const [isPeizhi, setisPeizhi] = useState(false);
- const [isApplying, setIsApplying] = useState(false);
- const [applyStatus, setApplyStatus] = useState([]);
const deviceCollection = createListCollection({
items: devices.map((device) => ({
@@ -52,18 +46,30 @@ const ConfigPage = () => {
}, []);
const handleParse = async () => {
- if (!selectedDevice || !inputText.trim()) {
+ if (selectedDevices.length === 0 || !inputText.trim()) {
Notification.error({
title: '操作失败',
- description: '请选择设备并输入配置指令',
+ description: '请选择至少一个设备并输入配置指令',
+ });
+ return;
+ }
+
+ const selectedConfigs = devices.filter((device) => selectedDevices.includes(device.ip));
+ const deviceWithoutVendor = selectedConfigs.find((d) => !d.vendor || d.vendor.trim() === '');
+ if (deviceWithoutVendor) {
+ Notification.error({
+ title: '操作失败',
+ description: `设备 ${deviceWithoutVendor.name} 暂未配置厂商,请先配置厂商`,
});
return;
}
try {
- const performParse = async () => {
- return await api.parseCommand(inputText);
- };
+ const performParse = async () =>
+ await api.parseCommand({
+ command: inputText,
+ devices: selectedConfigs,
+ });
const resultWrapper = await Notification.promise({
promise: performParse(),
@@ -82,11 +88,15 @@ const ConfigPage = () => {
});
let result = await resultWrapper.unwrap();
- if (result?.data) {
- setParsedConfig(JSON.stringify(result.data));
- setEditableConfig(JSON.stringify(result.data));
+ if (result?.data?.config) {
+ const configMap = {};
+ result.data.config.forEach((item) => {
+ if (item.device?.ip) {
+ configMap[item.device.ip] = item;
+ }
+ });
+ setDeviceConfigs(configMap);
setHasParsed(true);
- setisPeizhi(true);
}
} catch (error) {
console.error('配置解析异常:', error);
@@ -98,62 +108,80 @@ const ConfigPage = () => {
};
const handleApply = async () => {
- if (!editableConfig) {
+ if (!hasParsed) {
Notification.warn({
- title: '配置为空',
- description: '请先解析或编辑有效配置',
+ title: '未解析配置',
+ description: '请先解析配置再应用',
});
return;
}
setApplying(true);
- setIsApplying(true);
try {
const applyOperation = async () => {
if (testMode) {
- Common.sleep(1000).then(() => ({ success: true }));
- } else {
- let commands = JSON.parse(editableConfig)?.config?.commands;
- console.log(`commands:${JSON.stringify(commands)}`);
- const deviceConfig = JSON.parse(selectedDeviceConfig);
- console.log(`deviceConfig:${JSON.stringify(deviceConfig)}`);
- if (!deviceConfig.password) {
+ await Common.sleep(1000);
+ Notification.success({
+ title: '测试模式成功',
+ description: '配置已模拟应用',
+ });
+ return;
+ }
+ const applyPromises = selectedDevices.map(async (ip) => {
+ const deviceItem = deviceConfigs[ip];
+ if (!deviceItem) return;
+
+ const deviceConfig = deviceItem.config;
+
+ if (!deviceItem.device.password) {
Notification.warn({
- title: '所选交换机暂未配置用户名(可选)和密码',
- description: '请前往交换机设备处配置username和password',
+ title: `交换机 ${deviceItem.device.name} 暂未配置密码`,
+ description: '请前往交换机设备处配置用户名和密码',
});
- return false;
+ console.log(JSON.stringify(deviceItem));
+ return;
}
- if (deviceConfig.username || deviceConfig.username.toString() !== '') {
- commands.push(`!username=${deviceConfig.username.toString()}`);
- } else {
- commands.push(`!username=NONE`);
+
+ if (!deviceItem.device.username) {
+ Notification.warn({
+ title: `交换机 ${deviceItem.device.name} 暂未配置用户名,将使用NONE作为用户名`,
+ });
+ deviceItem.device.username = 'NONE';
}
- commands.push(`!password=${deviceConfig.password.toString()}`);
- const res = await api.applyConfig(selectedDevice, commands);
- if (res) {
+
+ const commands = [...deviceConfig.commands];
+
+ try {
+ const res = await api.applyConfig(
+ ip,
+ commands,
+ deviceItem.device.username,
+ deviceItem.device.password
+ );
Notification.success({
- title: '配置完毕',
+ title: `配置完毕 - ${deviceItem.device.name}`,
description: JSON.stringify(res),
});
- } else {
+ } catch (err) {
Notification.error({
- title: '配置过程出现错误',
- description: '请检查API提示',
+ title: `配置过程出现错误 - ${deviceItem.device.name}`,
+ description: err.message || '请检查API提示',
});
}
- }
+ });
+
+ await Promise.all(applyPromises);
};
await Notification.promise({
- promise: applyOperation,
+ promise: applyOperation(),
loading: {
title: '配置应用中',
description: '正在推送配置到设备...',
},
success: {
- title: '应用成功',
- description: '配置已成功生效',
+ title: '应用完成',
+ description: '所有设备配置已推送',
},
error: {
title: '应用失败',
@@ -174,19 +202,16 @@ const ConfigPage = () => {
交换机配置中心
+
选择交换机设备
{
- const selectedIp = value[0] ?? '';
- setSelectedDevice(selectedIp);
- const fullDeviceConfig = devices.find((device) => device.ip === selectedIp);
- setSelectedDeviceConfig(JSON.stringify(fullDeviceConfig));
- }}
+ value={selectedDevices}
+ onValueChange={({ value }) => setSelectedDevices(value)}
placeholder={'请选择交换机设备'}
size={'sm'}
colorPalette={'teal'}
@@ -202,7 +227,7 @@ const ConfigPage = () => {
-
+
{deviceCollection.items.map((item) => (
@@ -214,13 +239,14 @@ const ConfigPage = () => {
+
配置指令输入
+
- {isPeizhi && parsedConfig && (
+
+ {hasParsed && selectedDevices.length > 0 && (
- {(() => {
- let parsed;
- try {
- parsed = JSON.parse(editableConfig);
- } catch (e) {
- return 配置 JSON 格式错误,无法解析;
- }
-
- const config = parsed.config ? [parsed.config] : parsed;
- return config.map((cfg, idx) => (
+ {selectedDevices.map((ip) => {
+ const item = deviceConfigs[ip];
+ if (!item) return null;
+ const cfg = item.config;
+ return (
{
borderColor={'whiteAlpha.300'}
>
- 配置类型: {cfg.type}
+ 设备: {item.device.name} ({ip}) - 配置类型: {cfg.type}
{Object.entries(cfg).map(([key, value]) => {
@@ -278,9 +301,16 @@ const ConfigPage = () => {
value={value}
onChange={(e) => {
const newVal = e.target.value;
- const updated = JSON.parse(editableConfig);
- updated.config[key] = newVal;
- setEditableConfig(JSON.stringify(updated, null, 2));
+ setDeviceConfigs((prev) => ({
+ ...prev,
+ [ip]: {
+ ...prev[ip],
+ config: {
+ ...prev[ip].config,
+ [key]: newVal,
+ },
+ },
+ }));
}}
/>
@@ -299,20 +329,26 @@ const ConfigPage = () => {
value={cmd}
onChange={(e) => {
const newCmd = e.target.value;
- const updated = JSON.parse(editableConfig);
- updated.config.commands[i] = newCmd;
- setEditableConfig(JSON.stringify(updated, null, 2));
+ setDeviceConfigs((prev) => {
+ const updated = { ...prev };
+ updated[ip].config.commands[i] = newCmd;
+ return updated;
+ });
}}
/>
))}
+
- ));
- })()}
-
- {
-
-
-
-
- 应用配置命令
-
-
- {JSON.parse(editableConfig).config?.commands.map((command, index) => (
-
-
- {command}
-
-
-
- {applyStatus[index] === 'success'
- ? '成功'
- : applyStatus[index] === 'failed'
- ? '失败'
- : applyStatus[index] === 'in-progress'
- ? '正在应用'
- : ''}
-
-
- ))}
-
-
-
-
- }
+ );
+ })}
)}
diff --git a/src/frontend/src/services/api/api.js b/src/frontend/src/services/api/api.js
index 3a8d3c1..c4718eb 100644
--- a/src/frontend/src/services/api/api.js
+++ b/src/frontend/src/services/api/api.js
@@ -25,7 +25,7 @@ export const api = {
/**
* 扫描网络
- * @param subnet 子网地址
+ * @param {string} subnet 子网地址
* @returns {Promise>}
*/
scan: (subnet) => axios.get(buildUrl('/api/scan_network'), { params: { subnet } }),
@@ -38,19 +38,25 @@ export const api = {
/**
* 解析命令
- * @param text 文本
+ * @param {Object} payload
+ * @param {string} payload.command - 自然语言命令
+ * @param {Array