Compare commits

..

No commits in common. "9a6fe59bc9915e69a8fd947cbe8f4d311ed9c857" and "7fee04bc0a43694310274549093bb298a1c1e5e8" have entirely different histories.

7 changed files with 518 additions and 314 deletions

View File

@ -11,7 +11,6 @@ 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
@ -56,6 +55,22 @@ 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"}
@ -81,29 +96,27 @@ async def list_devices():
}
class DeviceItem(BaseModel):
name: str
ip: str
vendor: str
class CommandRequest(BaseModel):
command: str
devices: List[DeviceItem]
vendor: str = "huawei"
class ConfigRequest(BaseModel):
config: dict
switch_ip: str
username: str = None
password: str = None
timeout: int = None
vendor: str = "huawei"
@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}"
)
"""解析中文命令并返回JSON配置"""
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", [])}
config = await ai_service.parse_command(request.command, request.vendor)
return {"success": True, "config": config}
except Exception as e:
raise HTTPException(
status_code=400,
@ -128,16 +141,44 @@ 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(
@ -147,6 +188,7 @@ 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))

View File

@ -1,14 +1,19 @@
import asyncio
import logging
import telnetlib3
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Union
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
# ----------------------
# 数据模型
# ----------------------
@ -20,31 +25,36 @@ 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
@ -57,66 +67,253 @@ class SwitchConfigurator:
self.ensp_delay = ensp_command_delay
self.ssh_options = ssh_options
async def _get_or_create_connection(self, ip: str):
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:
"""
从连接池获取连接如果没有则新建 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 协议发送命令
通过 Telnet 协议连接 eNSP 设备
"""
try:
reader, writer = await self._get_or_create_connection(ip)
logger.info(f"连接设备 {ip}端口23")
reader, writer = await telnetlib3.open_connection(host=ip, port=23)
logger.debug("连接成功,开始登录流程")
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"[{ip}] 发送命令: {cmd}")
logger.info(f"发送命令: {cmd}")
writer.write(f"{cmd}\n")
await writer.drain()
await asyncio.sleep(self.ensp_delay)
logger.info(f"[{ip}] 所有命令发送完成")
return True
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
except asyncio.TimeoutError as e:
logger.error(f"[{ip}] 连接或读取超时: {e}")
return False
logger.error(f"连接或读取超时: {e}")
raise EnspConnectionException(f"eNSP连接超时: {e}")
except Exception as e:
logger.error(f"[{ip}] 命令发送异常: {e}", exc_info=True)
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)}")
return False
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
@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())

View File

@ -1,26 +0,0 @@
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"

View File

@ -1,47 +1,54 @@
from typing import Any, List, Dict
from typing import Any
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.client = AsyncOpenAI(api_key=api_key, base_url=api_url)
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)
)
async def parse_command(self, command: str, devices: List[Dict]) -> Dict[str, Any]:
async def parse_command(self, command: str, vendor: str = "huawei") -> Any | None:
"""
针对一组设备和一条自然语言命令生成每台设备的配置 JSON
调用硅基流动API解析中文命令
"""
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"]}}]"""
vendor_prompts = {
"huawei": "华为交换机配置命令",
"cisco": "思科交换机配置命令",
"h3c": "H3C交换机配置命令",
"ruijie": "锐捷交换机配置命令",
"zte": "中兴交换机配置命令"
}
prompt = f"""
你是一个网络设备配置专家现在有以下设备
{devices_str}
你是一个网络设备配置专家精通各种类型的路由器的配置请将以下用户的中文命令转换为{vendor_prompts.get(vendor, '网络设备')}配置JSON
但是请注意由于贪婪的人们追求极高的效率所以你必须严格按照 JSON 格式返回数据不要包含任何额外文本或 Markdown 代码块
返回格式要求
1. 必须包含'type'字段指明配置类型(vlan/interface/acl/route等)
2. 必须包含'commands'字段包含可直接执行的命令列表
3. 其他参数根据配置类型动态添加
4. 不要包含解释性文本步骤说明或注释
5. 要包含使用ssh连接交换机后的完整命令包括但不完全包括system-view退出保存等完整操作注意保存还需要输入Y
用户输入了一条命令{command}
根据厂商{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
你的任务
- 为每台设备分别生成配置
- 输出一个 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}
"""
示例命令'创建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"]}}
"""
messages = [
ChatCompletionSystemMessageParam(role="system", content=prompt),
@ -52,18 +59,29 @@ class AIService:
response = await self.client.chat.completions.create(
model="deepseek-ai/DeepSeek-V3",
messages=messages,
temperature=0.2,
max_tokens=1500,
temperature=0.3,
max_tokens=1000,
response_format={"type": "json_object"}
)
config_str = response.choices[0].message.content.strip()
configs = json.loads(config_str)
logger.debug(response)
return {"success": True, "results": configs}
config_str = response.choices[0].message.content.strip()
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")
except Exception as e:
raise SiliconFlowAPIException(
detail=f"AI 解析配置失败: {str(e)}",
detail=f"API请求失败: {str(e)}",
status_code=getattr(e, "status_code", 500)
)

View File

@ -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 { createListCollection } from '@chakra-ui/react';
import Notification from '@/libs/system/Notification';
const MotionBox = motion(Box);
const vendors = ['huawei', 'cisco', 'h3c', 'ruijie', 'zte'];
/**
* 设备配置弹窗
* @param isOpen 是否打开
* @param onClose 关闭弹窗
* @param onSave 保存修改
* @param device 当前设备
* @returns {JSX.Element}
* @constructor
*/
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, vendor };
const updatedDevice = { ...device, username, password };
onSave(updatedDevice);
setSaved(true);
setTimeout(() => {
@ -82,40 +82,6 @@ const DeviceConfigModal = ({ isOpen, onClose, onSave, device }) => {
type={'password'}
/>
</Field.Root>
<Field.Root>
<Field.Label>交换机厂商</Field.Label>
<Select.Root
collection={vendorCollection}
value={vendor ? [vendor] : []}
onValueChange={({ value }) => setVendor(value[0] || '')}
placeholder={'请选择厂商'}
size={'sm'}
colorPalette={'teal'}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
<Select.ClearTrigger />
</Select.IndicatorGroup>
</Select.Control>
<Portal>
<Select.Positioner style={{ zIndex: 1500 }}>
<Select.Content>
{vendorCollection.items.map((item) => (
<Select.Item key={item.value} item={item}>
{item.label}
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Portal>
</Select.Root>
</Field.Root>
</Stack>
</DialogBody>

View File

@ -22,16 +22,22 @@ 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 [selectedDevices, setSelectedDevices] = useState([]);
const [deviceConfigs, setDeviceConfigs] = useState({});
const [selectedDevice, setSelectedDevice] = useState('');
const [selectedDeviceConfig, setSelectedDeviceConfig] = 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) => ({
@ -46,30 +52,18 @@ const ConfigPage = () => {
}, []);
const handleParse = async () => {
if (selectedDevices.length === 0 || !inputText.trim()) {
if (!selectedDevice || !inputText.trim()) {
Notification.error({
title: '操作失败',
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} 暂未配置厂商,请先配置厂商`,
description: '请选择设备并输入配置指令',
});
return;
}
try {
const performParse = async () =>
await api.parseCommand({
command: inputText,
devices: selectedConfigs,
});
const performParse = async () => {
return await api.parseCommand(inputText);
};
const resultWrapper = await Notification.promise({
promise: performParse(),
@ -88,15 +82,11 @@ const ConfigPage = () => {
});
let result = await resultWrapper.unwrap();
if (result?.data?.config) {
const configMap = {};
result.data.config.forEach((item) => {
if (item.device?.ip) {
configMap[item.device.ip] = item;
}
});
setDeviceConfigs(configMap);
if (result?.data) {
setParsedConfig(JSON.stringify(result.data));
setEditableConfig(JSON.stringify(result.data));
setHasParsed(true);
setisPeizhi(true);
}
} catch (error) {
console.error('配置解析异常:', error);
@ -108,80 +98,62 @@ const ConfigPage = () => {
};
const handleApply = async () => {
if (!hasParsed) {
if (!editableConfig) {
Notification.warn({
title: '未解析配置',
description: '请先解析配置再应用',
title: '配置为空',
description: '请先解析或编辑有效配置',
});
return;
}
setApplying(true);
setIsApplying(true);
try {
const applyOperation = async () => {
if (testMode) {
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) {
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) {
Notification.warn({
title: `交换机 ${deviceItem.device.name} 暂未配置密码`,
description: '请前往交换机设备处配置用户名和密码',
title: '所选交换机暂未配置用户名(可选)和密码',
description: '请前往交换机设备处配置username和password',
});
console.log(JSON.stringify(deviceItem));
return;
return false;
}
if (!deviceItem.device.username) {
Notification.warn({
title: `交换机 ${deviceItem.device.name} 暂未配置用户名,将使用NONE作为用户名`,
});
deviceItem.device.username = 'NONE';
if (deviceConfig.username || deviceConfig.username.toString() !== '') {
commands.push(`!username=${deviceConfig.username.toString()}`);
} else {
commands.push(`!username=NONE`);
}
const commands = [...deviceConfig.commands];
try {
const res = await api.applyConfig(
ip,
commands,
deviceItem.device.username,
deviceItem.device.password
);
commands.push(`!password=${deviceConfig.password.toString()}`);
const res = await api.applyConfig(selectedDevice, commands);
if (res) {
Notification.success({
title: `配置完毕 - ${deviceItem.device.name}`,
title: '配置完毕',
description: JSON.stringify(res),
});
} catch (err) {
} else {
Notification.error({
title: `配置过程出现错误 - ${deviceItem.device.name}`,
description: err.message || '请检查API提示',
title: '配置过程出现错误',
description: '请检查API提示',
});
}
});
await Promise.all(applyPromises);
}
};
await Notification.promise({
promise: applyOperation(),
promise: applyOperation,
loading: {
title: '配置应用中',
description: '正在推送配置到设备...',
},
success: {
title: '应用成',
description: '所有设备配置已推送',
title: '应用',
description: '配置已成功生效',
},
error: {
title: '应用失败',
@ -202,16 +174,19 @@ const ConfigPage = () => {
<Heading fontSize={'xl'} color={'teal.300'}>
交换机配置中心
</Heading>
<Field.Root>
<Field.Label fontWeight={'bold'} mb={1} fontSize="sm">
选择交换机设备
</Field.Label>
<Select.Root
multiple
collection={deviceCollection}
value={selectedDevices}
onValueChange={({ value }) => setSelectedDevices(value)}
value={selectedDevice ? [selectedDevice] : []}
onValueChange={({ value }) => {
const selectedIp = value[0] ?? '';
setSelectedDevice(selectedIp);
const fullDeviceConfig = devices.find((device) => device.ip === selectedIp);
setSelectedDeviceConfig(JSON.stringify(fullDeviceConfig));
}}
placeholder={'请选择交换机设备'}
size={'sm'}
colorPalette={'teal'}
@ -227,7 +202,7 @@ const ConfigPage = () => {
</Select.IndicatorGroup>
</Select.Control>
<Portal>
<Select.Positioner style={{ zIndex: 1500 }}>
<Select.Positioner>
<Select.Content>
{deviceCollection.items.map((item) => (
<Select.Item key={item.value} item={item}>
@ -239,14 +214,13 @@ const ConfigPage = () => {
</Portal>
</Select.Root>
</Field.Root>
<Field.Root>
<Field.Label fontWeight={'bold'} mb={1} fontSize="sm">
配置指令输入
</Field.Label>
<Textarea
rows={4}
placeholder={'例创建VLAN 10并配置IP 192.168.10.1/24并在端口1启用SSH访问'}
placeholder={'例创建VLAN 10并配置IP 192.168.10.1/24并在端口1启用SSH访问"'}
value={inputText}
colorPalette={'teal'}
orientation={'vertical'}
@ -255,27 +229,30 @@ const ConfigPage = () => {
size={'sm'}
/>
</Field.Root>
<Button
colorScheme={'teal'}
variant={'solid'}
size={'sm'}
onClick={handleParse}
isDisabled={selectedDevices.length === 0 || !inputText.trim()}
isDisabled={!selectedDevice || !inputText.trim()}
>
解析配置
</Button>
{hasParsed && selectedDevices.length > 0 && (
{isPeizhi && parsedConfig && (
<FadeInWrapper delay={0.2}>
<VStack spacing={4} align={'stretch'}>
{selectedDevices.map((ip) => {
const item = deviceConfigs[ip];
if (!item) return null;
const cfg = item.config;
return (
{(() => {
let parsed;
try {
parsed = JSON.parse(editableConfig);
} catch (e) {
return <Text color={'red.300'}>配置 JSON 格式错误无法解析</Text>;
}
const config = parsed.config ? [parsed.config] : parsed;
return config.map((cfg, idx) => (
<Box
key={ip}
key={idx}
p={4}
bg={'whiteAlpha.100'}
borderRadius={'xl'}
@ -283,7 +260,7 @@ const ConfigPage = () => {
borderColor={'whiteAlpha.300'}
>
<Text fontSize={'lg'} fontWeight={'bold'} mb={2}>
设备: {item.device.name} ({ip}) - 配置类型: {cfg.type}
配置类型: {cfg.type}
</Text>
{Object.entries(cfg).map(([key, value]) => {
@ -301,16 +278,9 @@ const ConfigPage = () => {
value={value}
onChange={(e) => {
const newVal = e.target.value;
setDeviceConfigs((prev) => ({
...prev,
[ip]: {
...prev[ip],
config: {
...prev[ip].config,
[key]: newVal,
},
},
}));
const updated = JSON.parse(editableConfig);
updated.config[key] = newVal;
setEditableConfig(JSON.stringify(updated, null, 2));
}}
/>
</Field.Root>
@ -329,26 +299,20 @@ const ConfigPage = () => {
value={cmd}
onChange={(e) => {
const newCmd = e.target.value;
setDeviceConfigs((prev) => {
const updated = { ...prev };
updated[ip].config.commands[i] = newCmd;
return updated;
});
const updated = JSON.parse(editableConfig);
updated.config.commands[i] = newCmd;
setEditableConfig(JSON.stringify(updated, null, 2));
}}
/>
</Field.Root>
))}
<HStack mt={4} spacing={3} justify={'flex-end'}>
<Button
variant={'outline'}
colorScheme={'gray'}
size={'sm'}
onClick={() => {
setDeviceConfigs((prev) => ({
...prev,
[ip]: item,
}));
setEditableConfig(parsedConfig);
Notification.success({
title: '成功重置配置!',
description: '现在您可以重新审查生成的配置',
@ -377,14 +341,63 @@ const ConfigPage = () => {
size={'sm'}
onClick={handleApply}
isLoading={applying}
isDisabled={!cfg.commands || cfg.commands.length === 0}
isDisabled={!editableConfig}
>
应用到交换机
</Button>
</HStack>
</Box>
);
})}
));
})()}
{
<FadeInWrapper delay={0.2}>
<VStack spacing={4} align={'stretch'}>
<Box
p={4}
bg={'whiteAlpha.100'}
borderRadius={'xl'}
border={'1px solid'}
borderColor={'whiteAlpha.300'}
>
<Text fontSize={'lg'} fontWeight={'bold'} mb={2}>
应用配置命令
</Text>
<Box>
{JSON.parse(editableConfig).config?.commands.map((command, index) => (
<HStack key={index} mb={2}>
<Text fontSize={'sm'} flex={1}>
{command}
</Text>
<Spinner
size={'sm'}
color={applyStatus[index] === 'success' ? 'green.500' : 'red.500'}
display={
applyStatus[index] === 'pending' ||
applyStatus[index] === 'in-progress'
? 'inline-block'
: 'none'
}
/>
<Text
color={applyStatus[index] === 'success' ? 'green.500' : 'red.500'}
ml={2}
>
{applyStatus[index] === 'success'
? '成功'
: applyStatus[index] === 'failed'
? '失败'
: applyStatus[index] === 'in-progress'
? '正在应用'
: ''}
</Text>
</HStack>
))}
</Box>
</Box>
</VStack>
</FadeInWrapper>
}
</VStack>
</FadeInWrapper>
)}

View File

@ -25,7 +25,7 @@ export const api = {
/**
* 扫描网络
* @param {string} subnet 子网地址
* @param subnet 子网地址
* @returns {Promise<axios.AxiosResponse<any>>}
*/
scan: (subnet) => axios.get(buildUrl('/api/scan_network'), { params: { subnet } }),
@ -38,25 +38,19 @@ export const api = {
/**
* 解析命令
* @param {Object} payload
* @param {string} payload.command - 自然语言命令
* @param {Array<Object>} payload.devices - 设备列表
* 每个对象包含 { id: string, ip: string, vendor: string(huawei/cisco/h3c/ruijie/zte) }
* @param text 文本
* @returns {Promise<axios.AxiosResponse<any>>}
*/
parseCommand: ({ command, devices }) =>
axios.post(buildUrl('/api/parse_command'), { command, devices }),
parseCommand: (text) => axios.post(buildUrl('/api/parse_command'), { command: text }),
/**
* 应用配置
* @param {string} switch_ip 交换机IP
* @param {Array<string>} commands 配置命令数组
* @param username 用户名无时使用NONE
* @param password 密码
* @param switch_ip 交换机ip
* @param commands 配置,为数组[]
* @returns {Promise<axios.AxiosResponse<any>>}
*/
applyConfig: (switch_ip, commands, username, password) =>
axios.post(buildUrl('/api/execute_cli_commands'), { switch_ip, commands, username, password }),
applyConfig: (switch_ip, commands) =>
axios.post(buildUrl('/api/execute_cli_commands'), { switch_ip: switch_ip, commands: commands }),
/**
* 获取网络适配器信息
@ -66,7 +60,7 @@ export const api = {
/**
* 更新基础URL
* @param {string} url
* @param url
*/
updateBaseUrl: (url) => {
const config = ConfigTool.load();
@ -83,7 +77,7 @@ export const getConfig = () => ConfigTool.load();
/**
* 获取基础URL
* @returns {string}
* @returns {string|string}
*/
export const getBaseUrl = () => ConfigTool.load().backendUrl || '';