Compare commits

..

7 Commits

Author SHA1 Message Date
3
2980489b5b Merge remote-tracking branch 'origin/main' 2025-07-11 14:04:10 +08:00
5f48b6d030 feat:交换机可配置username和password 2025-07-11 14:01:43 +08:00
3
435d140108 修改 2025-07-11 14:00:22 +08:00
6ce1c89d8c feat:交换机可配置username和password 2025-07-11 13:54:46 +08:00
3
ed50ed2fb2 修改 2025-07-11 13:48:33 +08:00
8d1889bba7 修复state变量问题 2025-07-11 13:31:30 +08:00
3
7030adb81c 修改 2025-07-11 13:25:44 +08:00
8 changed files with 278 additions and 134 deletions

View File

@ -50,13 +50,19 @@ async def favicon():
class BatchConfigRequest(BaseModel):
config: dict
switch_ips: List[str] # 支持多个IP
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()
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)
@ -92,6 +98,9 @@ class CommandRequest(BaseModel):
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):
@ -115,9 +124,9 @@ async def apply_config(request: ConfigRequest):
"""
try:
configurator = SwitchConfigurator(
username=settings.SWITCH_USERNAME,
password=settings.SWITCH_PASSWORD,
timeout=settings.SWITCH_TIMEOUT
username=request.username,
password=request.password,
timeout=request.timeout
)
result = await configurator.safe_apply(request.switch_ip, request.config)
return {"success": True, "result": result}
@ -133,16 +142,40 @@ class CLICommandRequest(BaseModel):
commands: List[str] # 前端生成的CLI命令列表
is_ensp: bool = False # 是否为eNSP模拟器模式
# 添加方法从commands中提取凭据
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=settings.SWITCH_USERNAME,
password=settings.SWITCH_PASSWORD,
username=username,
password=password,
timeout=settings.SWITCH_TIMEOUT,
ensp_mode=request.is_ensp
)
@ -220,7 +253,7 @@ async def root():
}
# ... 其他路由保持不变 ...
@router.get("/traffic/switch/interfaces", summary="获取交换机的网络接口")
async def get_switch_interfaces(switch_ip: str):

View File

@ -10,6 +10,8 @@ import asyncssh
from pydantic import BaseModel
from tenacity import retry, stop_after_attempt, wait_exponential
from src.backend.config import settings
# ----------------------
# 数据模型
@ -44,18 +46,18 @@ class SSHConnectionException(SwitchConfigException):
class SwitchConfigurator:
def __init__(
self,
username: str = "admin",
password: str = "admin",
timeout: int = 10,
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
self.password = password
self.timeout = timeout
self.username = username if username is not None else settings.SWITCH_USERNAME
self.password = password if password is not None else settings.SWITCH_PASSWORD
self.timeout = timeout if timeout is not None else settings.SWITCH_TIMEOUT
self.semaphore = asyncio.Semaphore(max_workers)
self.backup_dir = Path("config_backups")
self.backup_dir.mkdir(exist_ok=True)

View File

@ -21,7 +21,7 @@ class NetworkScanner:
# 扫描开放22(SSH)或23(Telnet)端口的设备
self.nm.scan(
hosts=subnet,
arguments="-p 22,23 --open -T4"
arguments="-p 22,23,80,161 --min-rate 1000 --max-retries 1"
)
devices = []

View File

@ -16,6 +16,8 @@ class Settings(BaseSettings):
SILICONFLOW_API_URL: str = os.getenv("SILICONFLOW_API_URL", "https://api.siliconflow.cn/v1")
# 交换机配置
SWITCH_USERNAME: str = os.getenv("SWITCH_USERNAME", "admin")
SWITCH_PASSWORD: str = os.getenv("SWITCH_PASSWORD", "admin")
SWITCH_TIMEOUT: int = os.getenv("SWITCH_TIMEOUT", 10)
# eNSP配置

View File

@ -1,7 +1,6 @@
fastapi==0.110.0
uvicorn==0.29.0
python-dotenv==1.0.1
pysnmp
pydantic==2.6.4
pydantic-settings==2.2.1
openai==1.93.2

View File

@ -0,0 +1,138 @@
import React, { useState } from 'react';
import {
Button,
Box,
Dialog,
DialogBackdrop,
DialogPositioner,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
Field,
Input,
Stack,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import { FiCheck } from 'react-icons/fi';
import Notification from '@/libs/system/Notification';
const MotionBox = motion(Box);
/**
* 设备配置弹窗
* @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 [saved, setSaved] = useState(false);
const handleSave = () => {
const updatedDevice = { ...device, username, password };
onSave(updatedDevice);
setSaved(true);
setTimeout(() => {
setSaved(false);
onClose();
}, 1200);
};
return (
<Dialog.Root open={isOpen} onClose={onClose}>
<DialogBackdrop />
<DialogPositioner>
<MotionBox
as={DialogContent}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.3 }}
bg={'whiteAlpha.100'}
backdropFilter={'blur(12px)'}
border={'1px solid'}
borderColor={'whiteAlpha.300'}
>
<DialogHeader>交换机设备配置</DialogHeader>
<DialogBody>
<Stack gap={4}>
<Field.Root>
<Field.Label>交换机用户名</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={'请输入设备用户名'}
bg={'whiteAlpha.200'}
/>
</Field.Root>
<Field.Root>
<Field.Label>交换机密码</Field.Label>
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={'请输入设备密码'}
bg={'whiteAlpha.200'}
type={'password'}
/>
</Field.Root>
</Stack>
</DialogBody>
<DialogFooter justifyContent={'space-between'}>
<Button
variant={'outline'}
borderColor={'whiteAlpha.500'}
color={'white'}
onClick={onClose}
_hover={{ bg: 'rgba(0, 0, 255, 0.3)' }}
>
取消
</Button>
<Button
variant={'outline'}
borderColor={'whiteAlpha.500'}
color={'white'}
onClick={handleSave}
isDisabled={saved}
width={'80px'}
position={'relative'}
_hover={{ bg: 'rgba(0, 0, 255, 0.3)' }}
>
{saved ? (
<motion.div
key={'saved'}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.3 }}
>
<FiCheck size={20} color={'lightgreen'} />
</motion.div>
) : (
<motion.div
key={'save'}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.3 }}
>
保存
</motion.div>
)}
</Button>
</DialogFooter>
</MotionBox>
</DialogPositioner>
</Dialog.Root>
);
};
export default DeviceConfigModal;

View File

@ -34,7 +34,6 @@ const ConfigPage = () => {
const [editableConfig, setEditableConfig] = useState('');
const [applying, setApplying] = useState(false);
const [hasParsed, setHasParsed] = useState(false);
const [conmmand, setConmmand] = useState([]);
const [isPeizhi, setisPeizhi] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [applyStatus, setApplyStatus] = useState([]);
@ -61,13 +60,6 @@ const ConfigPage = () => {
}
try {
/**
const response = await api.parseCommand(inputText);
if (response) {
setParsedConfig(response);
setEditableConfig(response);
setHasParsed(true);
}**/
const performParse = async () => {
if (testMode) {
await Common.sleep(800 + Math.random() * 700);
@ -97,10 +89,6 @@ const ConfigPage = () => {
setParsedConfig(JSON.stringify(result.data));
setEditableConfig(JSON.stringify(result.data));
setHasParsed(true);
result = result.data;
if (result.config && Array.isArray(result.config.commands)) {
setConmmand(result.config.commands);
}
setisPeizhi(true);
}
} catch (error) {
@ -112,37 +100,6 @@ const ConfigPage = () => {
}
};
const applyCommand = async (switch_ip, command, index) => {
try {
setApplyStatus((prevStatus) => {
const updated = [...prevStatus];
updated[index] = 'in-progress';
return updated;
});
const applyResult = testMode
? await Common.sleep(1000)
: await api.applyConfig(switch_ip, [command]);
if (applyResult?.data?.success) {
setApplyStatus((prevStatus) => {
const updated = [...prevStatus];
updated[index] = 'success';
return updated;
});
} else {
Notification.error({ title: '命令应用失败', description: '请检查命令是否合法' });
}
} catch (error) {
setApplyStatus((prevStatus) => {
const updated = [...prevStatus];
updated[index] = 'failed';
return updated;
});
throw error;
}
};
const handleApply = async () => {
if (!editableConfig) {
Notification.warn({
@ -153,10 +110,11 @@ const ConfigPage = () => {
}
setApplying(true);
setIsApplying(true);
try {
const applyOperation = testMode
? Common.sleep(1000).then(() => ({ success: true }))
: await api.applyConfig(selectedDevice, conmmand);
: await api.applyConfig(selectedDevice, JSON.parse(editableConfig)?.config?.commands);
await Notification.promise({
promise: applyOperation,
@ -187,7 +145,6 @@ const ConfigPage = () => {
<Heading fontSize={'xl'} color={'teal.300'}>
交换机配置中心
</Heading>
<Field.Root>
<Field.Label fontWeight={'bold'} mb={1} fontSize="sm">
选择交换机设备
@ -223,21 +180,21 @@ 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'}
onChange={(e) => setInputText(e.target.value)}
bg={'whiteAlpha.200'}
size={'sm'}
/>
</Field.Root>
<Button
colorScheme={'teal'}
variant={'solid'}
@ -247,57 +204,6 @@ const ConfigPage = () => {
>
解析配置
</Button>
{hasParsed && (
<FadeInWrapper>
<Box
p={4}
bg={'whiteAlpha.100'}
borderRadius={'xl'}
border={'1px solid'}
borderColor={'whiteAlpha.300'}
>
<Text fontWeight={'bold'} mb={2} fontSize="sm">
生成配置:
</Text>
<Textarea
value={JSON.stringify(editableConfig)}
rows={12}
onChange={(e) => setEditableConfig(e.target.value)}
fontFamily={'monospace'}
size={'sm'}
bg={'blackAlpha.200'}
whiteSpace="pre-wrap"
/>
<HStack mt={4} spacing={3} justify={'flex-end'}>
<Button
variant={'outline'}
colorScheme={'gray'}
size={'sm'}
onClick={() => {
setEditableConfig(parsedConfig);
Notification.success({
title: '成功重置配置!',
description: '现在您可以重新审查生成的配置',
});
}}
>
重置为原始配置
</Button>
<Button
colorScheme={'teal'}
size={'sm'}
onClick={handleApply}
isLoading={applying}
isDisabled={!editableConfig}
>
应用到交换机
</Button>
</HStack>
</Box>
</FadeInWrapper>
)}
{isPeizhi && parsedConfig && (
<FadeInWrapper delay={0.2}>
<VStack spacing={4} align={'stretch'}>
@ -306,7 +212,7 @@ const ConfigPage = () => {
try {
parsed = JSON.parse(editableConfig);
} catch (e) {
return <Text color={'red.300'}>配置 JSON 格式错误无法解析</Text>;
return <Text color={'red.300'}>配置 JSON 格式错误无法解析</Text>;
}
const config = parsed.config ? [parsed.config] : parsed;
@ -319,7 +225,7 @@ const ConfigPage = () => {
border={'1px solid'}
borderColor={'whiteAlpha.300'}
>
<Text fontSize="lg" fontWeight="bold" mb={2}>
<Text fontSize={'lg'} fontWeight={'bold'} mb={2}>
配置类型: {cfg.type}
</Text>
@ -343,7 +249,6 @@ const ConfigPage = () => {
setEditableConfig(JSON.stringify(updated, null, 2));
}}
/>
<Field.HelperText>可编辑字段: {key}</Field.HelperText>
</Field.Root>
);
})}
@ -353,7 +258,7 @@ const ConfigPage = () => {
</Text>
{cfg.commands?.map((cmd, i) => (
<Field.Root key={i} colorPalette={'teal'} orientation={'vertical'} mb={2}>
<Field.Label fontSize="sm">命令 #{i + 1}</Field.Label>
<Field.Label fontSize="sm">命令 {i + 1}</Field.Label>
<Textarea
size={'sm'}
fontFamily={'monospace'}
@ -367,11 +272,26 @@ const ConfigPage = () => {
/>
</Field.Root>
))}
<HStack mt={4} spacing={3} justify={'flex-end'}>
<Button
variant={'outline'}
colorScheme={'gray'}
size={'sm'}
onClick={() => {
setEditableConfig(parsedConfig);
Notification.success({
title: '成功重置配置!',
description: '现在您可以重新审查生成的配置',
});
}}
>
重置为原始配置
</Button>
<Button
size={'sm'}
mt={3}
colorScheme={'teal'}
variant={'outline'}
colorScheme={'gray'}
onClick={() => {
Notification.success({
title: `配置 ${cfg.type} 已保存`,
@ -381,10 +301,22 @@ const ConfigPage = () => {
>
保存当前配置
</Button>
<Button
colorScheme={'teal'}
size={'sm'}
onClick={handleApply}
isLoading={applying}
isDisabled={!editableConfig}
>
应用到交换机
</Button>
</HStack>
</Box>
));
})()}
{isApplying && (
{
<FadeInWrapper delay={0.2}>
<VStack spacing={4} align={'stretch'}>
<Box
@ -398,7 +330,7 @@ const ConfigPage = () => {
应用配置命令
</Text>
<Box>
{conmmand.map((command, index) => (
{JSON.parse(editableConfig).config?.commands.map((command, index) => (
<HStack key={index} mb={2}>
<Text fontSize={'sm'} flex={1}>
{command}
@ -431,7 +363,7 @@ const ConfigPage = () => {
</Box>
</VStack>
</FadeInWrapper>
)}
}
</VStack>
</FadeInWrapper>
)}

View File

@ -19,6 +19,7 @@ import ConfigTool from '@/libs/config/ConfigTool';
import Common from '@/libs/common';
import switchIcon from '@/resources/icon/pages/devices/switch.png';
import Notification from '@/libs/system/Notification';
import DeviceConfigModal from '@/components/pages/config/DeviceConfigModal';
/**
* 交换机管理
@ -29,6 +30,8 @@ const DevicesPage = () => {
const [devices, setDevices] = useState([]);
const [editingIndex, setEditingIndex] = useState(null);
const [editingName, setEditingName] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentDevice, setCurrentDevice] = useState(null);
useEffect(() => {
const config = ConfigTool.load();
@ -53,6 +56,23 @@ const DevicesPage = () => {
});
};
const handleOpenConfigModal = (device) => {
setCurrentDevice(device);
setIsModalOpen(true);
};
const handleSaveDeviceConfig = (updatedDevice) => {
const updatedDevices = devices.map((device) =>
device.ip === updatedDevice.ip ? updatedDevice : device
);
setDevices(updatedDevices);
ConfigTool.save({ ...ConfigTool.load(), devices: updatedDevices });
Notification.success({
title: '设备配置已保存!',
});
setIsModalOpen(false);
};
return (
<DocumentTitle title={'交换机设备'}>
<DashboardBackground />
@ -124,6 +144,15 @@ const DevicesPage = () => {
{'端口: '}
{device.ports.join(', ')}
</Text>
<Button
size={'sm'}
colorPalette={'teal'}
mt={2}
onClick={() => handleOpenConfigModal(device)}
>
配置
</Button>
</Box>
))}
</SimpleGrid>
@ -134,6 +163,15 @@ const DevicesPage = () => {
</VStack>
</FadeInWrapper>
</PageContainer>
{isModalOpen && currentDevice && (
<DeviceConfigModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveDeviceConfig}
device={currentDevice}
/>
)}
</DocumentTitle>
);
};