diff --git a/src/backend/app/api/endpoints.py b/src/backend/app/api/endpoints.py
index 20ed723..697e687 100644
--- a/src/backend/app/api/endpoints.py
+++ b/src/backend/app/api/endpoints.py
@@ -11,6 +11,7 @@ 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
@@ -55,22 +56,6 @@ class BatchConfigRequest(BaseModel):
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"}
@@ -96,27 +81,29 @@ async def list_devices():
}
+class DeviceItem(BaseModel):
+ name: str
+ ip: str
+ vendor: str
+
class CommandRequest(BaseModel):
command: str
- vendor: str = "huawei"
-
-
-class ConfigRequest(BaseModel):
- config: dict
- switch_ip: str
- username: str = None
- password: str = None
- timeout: int = None
- vendor: str = "huawei"
-
+ devices: List[DeviceItem]
@router.post("/parse_command", response_model=dict)
async def parse_command(request: CommandRequest):
- """解析中文命令并返回JSON配置"""
+ """解析中文命令并返回每台设备的配置 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, request.vendor)
- return {"success": True, "config": config}
+ 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,
@@ -141,44 +128,16 @@ async def apply_config(request: ConfigRequest):
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命令"""
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(
@@ -188,7 +147,6 @@ async def execute_cli_commands(request: CLICommandRequest):
return {
"success": True,
"output": result,
- "mode": "eNSP" if request.is_ensp else "SSH"
}
except Exception as e:
raise HTTPException(500, detail=str(e))
diff --git a/src/backend/app/api/network_config.py b/src/backend/app/api/network_config.py
index bd0c225..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,253 +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:
if cmd.startswith("!"):
logger.debug(f"跳过特殊命令: {cmd}")
continue
- logger.info(f"发送命令: {cmd}")
+ 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/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 2987bd0..74e48c9 100644
--- a/src/backend/app/services/ai_services.py
+++ b/src/backend/app/services/ai_services.py
@@ -1,54 +1,47 @@
-from typing import Any
+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 = AsyncOpenAI(
- 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, vendor: str = "huawei") -> Any | None:
+ async def parse_command(self, command: str, devices: List[Dict]) -> Dict[str, Any]:
"""
- 调用硅基流动API解析中文命令
+ 针对一组设备和一条自然语言命令,生成每台设备的配置 JSON
"""
- vendor_prompts = {
- "huawei": "华为交换机配置命令",
- "cisco": "思科交换机配置命令",
- "h3c": "H3C交换机配置命令",
- "ruijie": "锐捷交换机配置命令",
- "zte": "中兴交换机配置命令"
- }
+ 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"""
- 你是一个网络设备配置专家,精通各种类型的路由器的配置,请将以下用户的中文命令转换为{vendor_prompts.get(vendor, '网络设备')}配置JSON。
- 但是请注意,由于贪婪的人们追求极高的效率,所以你必须严格按照 JSON 格式返回数据,不要包含任何额外文本或 Markdown 代码块。
- 返回格式要求:
- 1. 必须包含'type'字段指明配置类型(vlan/interface/acl/route等)
- 2. 必须包含'commands'字段,包含可直接执行的命令列表
- 3. 其他参数根据配置类型动态添加
- 4. 不要包含解释性文本、步骤说明或注释
- 5. 要包含使用ssh连接交换机后的完整命令包括但不完全包括system-view,退出,保存等完整操作,注意保存还需要输入Y
+你是一个网络设备配置专家。现在有以下设备:
+{devices_str}
- 根据厂商{vendor}的不同,命令格式如下:
- - 华为: 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
+用户输入了一条命令:{command}
- 示例命令:'创建VLAN 100,名称为TEST'
- 华为示例返回:{{"type": "vlan", "vlan_id": 100, "name": "TEST", "commands": ["system-view","vlan 100", "name TEST","quit","quit","save","Y"]}}
- 思科示例返回:{{"type": "vlan", "vlan_id": 100, "name": "TEST", "commands": ["enable","configure terminal","vlan 100", "name TEST","exit","exit","write memory"]}}
- """
+你的任务:
+- 为每台设备分别生成配置
+- 输出一个 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),
@@ -59,29 +52,18 @@ class AIService:
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/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