feat: 服务器管理前后端字段对齐 — 移除CMDB幽灵字段 + SSH导向表单 + GB→MB内存转换

This commit is contained in:
attacker committed 2026-06-14 05:11:15 +08:00
1 parent 56e0cb15ac
commit a1237587f6
4 files changed
+146 -187

No files matched your search

+4
View File
@@ -148,6 +148,10 @@ func (h *ServerHandler) CreateServer(c *gin.Context) {
Status: "stopped",
}
if req.Status != "" {
server.Status = req.Status
}
if server.Port == 0 {
server.Port = 22
}
+1
View File
@@ -144,6 +144,7 @@ type CreateServerRequest struct {
CPU int `json:"cpu"`
Memory int64 `json:"memory"`
Disk int64 `json:"disk"`
Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"`
RegionID *uint `json:"region_id"`
CloudID *uint `json:"cloud_id"`
Tags string `json:"tags"`
+24
View File
@@ -129,3 +129,27 @@ cd ~/devops/aiops
- 成本统计从硬编码改为基于实际云账号数据计算
- K8s 集群拓扑图(TopologyView)前后端数据联调
- 告警"通知配置"按钮功能实现(通知渠道管理弹窗)
## 2026-06-14 05:15 自动推进
- **任务**: 服务器管理模块前后端字段对齐 — 修复前端表单使用后端不支持的CMDB幽灵字段导致数据丢失 + 内存单位 GB→MB 转换 + 表格列重构
- **修改**:
- `backend/internal/models/server.go` — CreateServerRequest 添加 Status 字段(原硬编码 "stopped")
- `backend/internal/api/handlers/server.go` — CreateServer handler 改用 req.Status 替代硬编码值
- `frontend/src/components/server/ServerManager.tsx` — 完全重写表单/表格/过滤器:
- 移除后端不支持的幽灵字段:asset_type/connect_type/floatingip/instance_id/expire_time/remark/region/region_id
- 表单改为SSH连接导向:主机名/IP/公网IP/SSH端口/登录用户/认证方式(密码/密钥)/密码 + 可折叠详情区(OS/状态/CPU/内存/磁盘/标签/描述)
- 内存字段前端输入GB,提交时 ×1024 转换为MB(后端存储MB)
- 表格列从11列精简到8列,新增公网IP列,移除资产类型/连接方式/区域列
- 移除不存在的区域筛选器(servers 无 region_id 数据)
- 移除未实现的"查看详情"下拉项(无详情页)
- **结果**:
- `go build -o aiops-server cmd/main.go` 编译通过 ✅
- `npx tsc --noEmit` 类型检查通过 ✅
- `npm run build` 构建通过 ✅
- API端到端验证: POST /servers → Code 200,hostname/ip/public_ip/port/username/auth_type/memory(16384MB=16GB)/status/tags 全部正确保存 ✅
- API端到端验证: PUT /servers/:id → Code 200,更新成功 ✅
- API端到端验证: DELETE /servers/:id → Code 200 "删除成功" ✅
- 浏览器UI验证: 服务器列表页正常渲染,内存显示为GB(128GB而非131072MB),公网IP列正确显示 ✅
- 表单UI验证: 添加服务器弹窗字段正确,验证逻辑正常(hostname+ip必填时按钮启用)✅
- 服务运行中: 后端:8080 + 前端:5173 ✅
+117 -187
View File
@@ -12,7 +12,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
DropdownMenu,
DropdownMenuContent,
@@ -53,7 +52,6 @@ import {
RotateCw,
Trash2,
Edit,
Eye,
Download,
Upload,
Server,
@@ -70,59 +68,39 @@ import type { Server as ServerType } from '@/types';
import { CloudServerImport } from './CloudServerImport';
import { ExcelServerImport } from './ExcelServerImport';
// 资产类型字典(参考 CMDB 运维平台)
const ASSET_TYPE_OPTIONS = [
{ value: 'physical', label: '物理机', color: 'var(--dg-orange)' },
{ value: 'virtual', label: '虚拟机', color: 'var(--dg-blue)' },
{ value: 'cloud', label: '云主机', color: 'var(--dg-cyan)' },
{ value: 'container', label: '容器', color: 'var(--dg-purple)' },
];
const getAssetTypeLabel = (value: string): string =>
ASSET_TYPE_OPTIONS.find((o) => o.value === value)?.label || value || '-';
const getAssetTypeColor = (value: string): string =>
ASSET_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)';
const getConnectTypeLabel = (value: string): string => {
if (value === 'public') return '公网';
if (value === 'private') return '内网';
return value || '-';
};
// 表单数据结构
// 表单数据结构 — 与后端 CreateServerRequest/UpdateServerRequest 字段对齐
interface ServerFormData {
hostname: string;
ip: string;
public_ip: string;
port: string;
username: string;
auth_type: string; // password | key
password: string;
os: string;
cpu: string;
memory: string;
memory: string; // GB (表单) → MB (后端)
disk: string;
status: string;
region: string;
asset_type: string;
connect_type: string;
floatingip: string;
instance_id: string;
expire_time: string;
remark: string;
tags: string;
description: string;
}
const EMPTY_FORM: ServerFormData = {
hostname: '',
ip: '',
public_ip: '',
port: '22',
username: 'root',
auth_type: 'password',
password: '',
os: '',
cpu: '',
memory: '',
disk: '',
status: 'running',
region: '',
asset_type: 'physical',
connect_type: 'private',
floatingip: '',
instance_id: '',
expire_time: '',
remark: '',
tags: '',
description: '',
};
export function ServerManager() {
@@ -131,7 +109,6 @@ export function ServerManager() {
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [regionFilter, setRegionFilter] = useState<string>('all');
const [cloudImportOpen, setCloudImportOpen] = useState(false);
const [excelImportOpen, setExcelImportOpen] = useState(false);
@@ -175,21 +152,13 @@ export function ServerManager() {
}
};
// 获取区域名称:后端 region 可能是对象 {name} 或字符串
const getRegionName = (server: any): string => {
if (!server.region) return '';
if (typeof server.region === 'string') return server.region;
return server.region.name || '';
};
const filteredServers = servers.filter((server) => {
const region = getRegionName(server);
const matchesSearch =
(server.hostname || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(server.ip || '').includes(searchQuery);
(server.ip || '').includes(searchQuery) ||
(server.public_ip || '').includes(searchQuery);
const matchesStatus = statusFilter === 'all' || server.status === statusFilter;
const matchesRegion = regionFilter === 'all' || region === regionFilter;
return matchesSearch && matchesStatus && matchesRegion;
return matchesSearch && matchesStatus;
});
const toggleSelectAll = () => {
@@ -243,18 +212,18 @@ export function ServerManager() {
setFormData({
hostname: server.hostname || '',
ip: server.ip || '',
public_ip: server.public_ip || '',
port: String(server.port || 22),
username: server.username || 'root',
auth_type: server.auth_type || 'password',
password: '',
os: server.os || '',
cpu: String(server.cpu || ''),
memory: String(server.memory || ''),
memory: server.memory ? String(Math.round(server.memory / 1024)) : '', // MB → GB
disk: String(server.disk || ''),
status: server.status || 'running',
region: getRegionName(server),
asset_type: server.asset_type || 'physical',
connect_type: server.connect_type || 'private',
floatingip: server.floatingip || '',
instance_id: server.instance_id || '',
expire_time: server.expire_time || '',
remark: server.remark || '',
tags: server.tags || '',
description: server.description || '',
});
setDetailsOpen(false);
setFormOpen(true);
@@ -265,12 +234,25 @@ export function ServerManager() {
if (!formData.hostname || !formData.ip) return;
try {
setFormSaving(true);
const payload = {
...formData,
const payload: Record<string, any> = {
hostname: formData.hostname,
ip: formData.ip,
public_ip: formData.public_ip || undefined,
port: Number(formData.port) || 22,
username: formData.username || 'root',
auth_type: formData.auth_type || 'password',
os: formData.os,
cpu: Number(formData.cpu) || 0,
memory: Number(formData.memory) || 0,
memory: (Number(formData.memory) || 0) * 1024, // GB → MB
disk: Number(formData.disk) || 0,
status: formData.status,
tags: formData.tags || undefined,
description: formData.description || undefined,
};
// 密码仅在填写时提交
if (formData.password) {
payload.password = formData.password;
}
if (editingId) {
await serverApi.updateServer(editingId, payload);
} else {
@@ -318,9 +300,7 @@ export function ServerManager() {
}
};
const regions = Array.from(new Set(servers.map((s) => getRegionName(s)).filter(Boolean)));
const colSpanCount = 11;
const colSpanCount = 8;
return (
<div className="space-y-6">
@@ -470,19 +450,6 @@ export function ServerManager() {
<SelectItem value="offline">离线</SelectItem>
</SelectContent>
</Select>
<Select value={regionFilter} onValueChange={setRegionFilter}>
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder="区域" />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="all">全部区域</SelectItem>
{regions.map((region) => (
<SelectItem key={region} value={region}>
{region}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedServers.length > 0 && (
<div className="flex items-center gap-2">
@@ -523,12 +490,10 @@ export function ServerManager() {
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">主机名</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">IP地址</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">资产类型</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">连接方式</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">公网IP</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">配置</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">使用率</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">区域</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">标签</TableHead>
<TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead>
</TableRow>
@@ -552,7 +517,7 @@ export function ServerManager() {
const cpuUsage = Number(server.cpu_usage || 0);
const memoryUsage = Number(server.memory_usage || 0);
const tags = getTags(server);
const region = getRegionName(server);
const memGB = server.memory ? Math.round(server.memory / 1024) : 0;
return (
<TableRow key={server.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<TableCell>
@@ -576,35 +541,21 @@ export function ServerManager() {
{server.ip}
</code>
</TableCell>
{/* 资产类型 */}
<TableCell>
<Badge
variant="outline"
className="text-xs border-[var(--dg-border)]"
style={{ color: getAssetTypeColor(server.asset_type) }}
>
{getAssetTypeLabel(server.asset_type)}
</Badge>
</TableCell>
{/* 连接方式 */}
<TableCell>
<span
className={cn(
'text-xs px-2 py-0.5 rounded',
server.connect_type === 'public'
? 'text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10'
: 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)]'
)}
>
{getConnectTypeLabel(server.connect_type)}
</span>
{server.public_ip ? (
<code className="text-sm text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10 px-2 py-0.5 rounded">
{server.public_ip}
</code>
) : (
<span className="text-sm text-[var(--dg-text-muted)]">-</span>
)}
</TableCell>
<TableCell>{getStatusBadge(server.status)}</TableCell>
<TableCell>
<div className="text-sm text-[var(--dg-text-secondary)]">
<span className="text-[var(--dg-text-muted)]">{server.cpu}核</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{server.memory}GB</span>
<span className="text-[var(--dg-text-muted)]">{memGB}GB</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{server.disk}GB</span>
</div>
@@ -639,9 +590,6 @@ export function ServerManager() {
</div>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-[var(--dg-text-secondary)]">{region || '-'}</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag) => (
@@ -674,10 +622,6 @@ export function ServerManager() {
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
<Eye className="h-4 w-4 mr-2" />
查看详情
</DropdownMenuItem>
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => openEditForm(server)}
@@ -755,91 +699,68 @@ export function ServerManager() {
</div>
</div>
{/* CMDB 资产字段 */}
{/* SSH 连接配置 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">资产类型</Label>
<Label className="text-[var(--dg-text-secondary)]">公网IP</Label>
<Input
placeholder="如 203.0.113.10"
value={formData.public_ip}
onChange={(e) => setFormData({ ...formData, public_ip: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">SSH端口</Label>
<Input
type="number"
placeholder="22"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">登录用户</Label>
<Input
placeholder="root"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">认证方式</Label>
<Select
value={formData.asset_type}
onValueChange={(v) => setFormData({ ...formData, asset_type: v })}
value={formData.auth_type}
onValueChange={(v) => setFormData({ ...formData, auth_type: v })}
>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder="选择资产类型" />
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
{ASSET_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<span style={{ color: opt.color }} className="mr-1">●</span>
{opt.label}
</SelectItem>
))}
<SelectItem value="password">密码认证</SelectItem>
<SelectItem value="key">密钥认证</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">连接方式</Label>
<RadioGroup
value={formData.connect_type}
onValueChange={(v) => setFormData({ ...formData, connect_type: v })}
className="flex items-center gap-6 h-9"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="public" id="connect-public" />
<Label htmlFor="connect-public" className="text-[var(--dg-text-secondary)] cursor-pointer font-normal">
公网
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="private" id="connect-private" />
<Label htmlFor="connect-private" className="text-[var(--dg-text-secondary)] cursor-pointer font-normal">
内网
</Label>
</div>
</RadioGroup>
</div>
</div>
{/* 云主机扩展字段 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">弹性IP地址</Label>
<Input
placeholder="如 203.0.113.10"
value={formData.floatingip}
onChange={(e) => setFormData({ ...formData, floatingip: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">实例ID</Label>
<Input
placeholder="如 i-bp1abc123def"
value={formData.instance_id}
onChange={(e) => setFormData({ ...formData, instance_id: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">到期时间</Label>
<Input
type="datetime-local"
value={formData.expire_time}
onChange={(e) => setFormData({ ...formData, expire_time: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">所在区域</Label>
<Input
placeholder="如 华东1"
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">
{formData.auth_type === 'key' ? 'SSH 私钥' : '登录密码'}
{!editingId && <span className="text-red-400 ml-1">*</span>}
{editingId && <span className="text-[var(--dg-text-muted)] ml-1 text-xs">(留空则不修改)</span>}
</Label>
<Textarea
placeholder={formData.auth_type === 'key' ? '粘贴 SSH 私钥内容...' : '输入登录密码...'}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[50px] font-mono text-sm"
/>
</div>
{/* 更多详情 - 可折叠区域 */}
@@ -852,7 +773,7 @@ export function ServerManager() {
>
<span className="flex items-center gap-2">
<Server className="h-4 w-4" />
更多详情(OS版本 / CPU / 内存 / 磁盘 / 状态)
更多详情(OS版本 / 运行状态 / CPU / 内存 / 磁盘 / 标签 / 描述)
</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', detailsOpen && 'rotate-180')} />
</Button>
@@ -919,11 +840,20 @@ export function ServerManager() {
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">备注</Label>
<Label className="text-[var(--dg-text-secondary)]">标签</Label>
<Input
placeholder='JSON 格式,如 {"project":"web","team":"ops"}'
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">描述</Label>
<Textarea
placeholder="补充说明..."
value={formData.remark}
onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[60px]"
/>
</div>