feat: Agent批量升级 + 版本号注入 + 服务器凭证下拉关联
- Dockerfile: ARG VERSION/BUILD_TIME 通过 ldflags 注入 agent+backend 二进制 - run.sh: 自动从 git tag 获取版本号 - docker-compose: build args 传递 VERSION/BUILD_TIME - backend: BatchUpgradeAgent SSH下载新二进制+重启, /agent/bin 静态文件服务 - frontend: 监控大屏添加批量升级按钮 (闪电图标) - frontend: 服务器编辑/添加表单改为凭证下拉选择器 - frontend: ServerForm 加载 /credentials 列表, 选择后自动填充用户名/认证方式
This commit is contained in:
1 parent
228b6b52f9
commit
ed08ea255e
9 files changed
+253
-52
No files matched your search
+10
-6
@@ -6,6 +6,10 @@
|
|||||||
# 署名 Logan.Li
|
# 署名 Logan.Li
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
# 构建参数 — 版本号通过 git tag 或环境变量注入
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG BUILD_TIME=unknown
|
||||||
|
|
||||||
# ==================== Stage 1: Backend Builder ====================
|
# ==================== Stage 1: Backend Builder ====================
|
||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
@@ -26,9 +30,9 @@ RUN go mod download
|
|||||||
# 复制后端源码
|
# 复制后端源码
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
|
|
||||||
# 构建后端二进制
|
# 构建后端二进制(注入版本号)
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||||
-ldflags="-w -s" \
|
-ldflags="-w -s -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" \
|
||||||
-o aiops ./cmd/
|
-o aiops ./cmd/
|
||||||
|
|
||||||
# ==================== Stage 2: Agent Builder ====================
|
# ==================== Stage 2: Agent Builder ====================
|
||||||
@@ -46,10 +50,10 @@ RUN go mod download
|
|||||||
# 复制 agent 源码
|
# 复制 agent 源码
|
||||||
COPY agent/ ./
|
COPY agent/ ./
|
||||||
|
|
||||||
# 交叉编译三个平台的 agent 二进制
|
# 交叉编译三个平台的 agent 二进制(注入版本号)
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/aiops-agent-linux-amd64 . && \
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" -o bin/aiops-agent-linux-amd64 . && \
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o bin/aiops-agent-linux-arm64 . && \
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" -o bin/aiops-agent-linux-arm64 . && \
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o bin/aiops-agent-darwin-arm64 .
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" -o bin/aiops-agent-darwin-arm64 .
|
||||||
|
|
||||||
# ==================== Stage 3: Runtime ====================
|
# ==================== Stage 3: Runtime ====================
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|||||||
@@ -377,6 +377,78 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchUpgradeAgent 批量升级 Agent 到最新版本(SSH下载新二进制 + 重启服务)
|
||||||
|
// POST /api/v1/agent/batch-upgrade
|
||||||
|
func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
ServerIDs []uint `json:"server_ids"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请提供要升级的服务器 ID 列表"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
|
||||||
|
|
||||||
|
// 异步执行:立即返回,后台逐台升级
|
||||||
|
go func() {
|
||||||
|
successCount := 0
|
||||||
|
for _, sid := range req.ServerIDs {
|
||||||
|
var server models.Server
|
||||||
|
if err := h.db.First(&server, sid).Error; err != nil {
|
||||||
|
log.Printf("[BatchUpgrade] 服务器 %d 不存在,跳过", sid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
|
||||||
|
log.Printf("[BatchUpgrade] %s 无SSH凭据,跳过", server.Hostname)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测架构
|
||||||
|
client, err := sshDial(server)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[BatchUpgrade] %s SSH连接失败: %v", server.Hostname, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
archOut, _ := runSSHCommand(client, "uname -m")
|
||||||
|
binaryFile := "aiops-agent-linux-amd64"
|
||||||
|
if len(archOut) > 0 && (contains(archOut, "aarch64") || contains(archOut, "arm64")) {
|
||||||
|
binaryFile = "aiops-agent-linux-arm64"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载新二进制 + 重启服务
|
||||||
|
upgradeScript := fmt.Sprintf(
|
||||||
|
`curl -fsSL -o /tmp/aiops-agent %s/agent/bin/%s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent && echo "UPGRADE_OK"`,
|
||||||
|
serverURL, binaryFile,
|
||||||
|
)
|
||||||
|
|
||||||
|
out, err := runSSHCommand(client, upgradeScript)
|
||||||
|
client.Close()
|
||||||
|
|
||||||
|
if err != nil || !contains(out, "UPGRADE_OK") {
|
||||||
|
log.Printf("[BatchUpgrade] %s 升级失败: output=%s err=%v", server.Hostname, out, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新版本号
|
||||||
|
h.db.Model(&server).Updates(map[string]interface{}{
|
||||||
|
"agent_version": "latest",
|
||||||
|
"last_check_at": time.Now(),
|
||||||
|
})
|
||||||
|
log.Printf("[BatchUpgrade] %s Agent 升级成功", server.Hostname)
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
log.Printf("[BatchUpgrade] 批量升级完成: %d/%d 成功", successCount, len(req.ServerIDs))
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: fmt.Sprintf("已提交异步升级任务,共 %d 台主机,正在后台执行…", len(req.ServerIDs)),
|
||||||
|
Data: gin.H{"total": len(req.ServerIDs), "async": true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// RemoteExec 直连 SSH 执行命令(即时返回,无轮询)
|
// RemoteExec 直连 SSH 执行命令(即时返回,无轮询)
|
||||||
// POST /api/v1/agent/exec
|
// POST /api/v1/agent/exec
|
||||||
func (h *AgentInstallHandler) RemoteExec(c *gin.Context) {
|
func (h *AgentInstallHandler) RemoteExec(c *gin.Context) {
|
||||||
|
|||||||
@@ -455,8 +455,12 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
// Agent 安装/卸载
|
// Agent 安装/卸载
|
||||||
agentQuery.POST("/uninstall", agentInstallHandler.UninstallAgent)
|
agentQuery.POST("/uninstall", agentInstallHandler.UninstallAgent)
|
||||||
agentQuery.POST("/batch-uninstall", agentInstallHandler.BatchUninstallAgent)
|
agentQuery.POST("/batch-uninstall", agentInstallHandler.BatchUninstallAgent)
|
||||||
|
agentQuery.POST("/batch-upgrade", agentInstallHandler.BatchUpgradeAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent 二进制下载(无需鉴权,Agent 升级时使用)
|
||||||
|
router.Static("/agent/bin", "/app/agent/bin")
|
||||||
|
|
||||||
// AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li
|
// AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li
|
||||||
llmHandler := handlers.NewLLMHandler(db)
|
llmHandler := handlers.NewLLMHandler(db)
|
||||||
aiopsGroup := authorized.Group("/aiops")
|
aiopsGroup := authorized.Group("/aiops")
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
|
args:
|
||||||
|
VERSION: ${VERSION:-dev}
|
||||||
|
BUILD_TIME: ${BUILD_TIME:-unknown}
|
||||||
container_name: aiops-backend
|
container_name: aiops-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import {
|
import {
|
||||||
Monitor, Plus, RefreshCw, Maximize2, Minimize2, BarChart3, Clock,
|
Monitor, Plus, RefreshCw, Maximize2, Minimize2, BarChart3, Clock,
|
||||||
Server, Activity, Database, Wifi, WifiOff, ArrowUpDown, ArrowUp, ArrowDown,
|
Server, Activity, Database, Wifi, WifiOff, ArrowUpDown, ArrowUp, ArrowDown,
|
||||||
ExternalLink, Download, Trash2, Rocket
|
ExternalLink, Download, Trash2, Rocket, Zap
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -104,6 +104,23 @@ const AgentTab: React.FC = () => {
|
|||||||
finally { setBatchUninstalling(false); }
|
finally { setBatchUninstalling(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 批量升级 Agent 到最新版本
|
||||||
|
const [batchUpgrading, setBatchUpgrading] = useState(false);
|
||||||
|
const handleBatchUpgrade = async () => {
|
||||||
|
const online = hosts.filter((h: any) => selIds.has(h.server_id) && h.agent_status === 'online');
|
||||||
|
if (online.length === 0) { toast.error('所选主机中没有在线 Agent'); return; }
|
||||||
|
const ids = online.map((h: any) => h.server_id);
|
||||||
|
const ok = await _confirm({ title: '批量升级 Agent', description: `确定升级选中的 ${ids.length} 台在线主机的 Agent 到最新版本?升级将在后台异步执行。`, confirmText: '升级', variant: 'default' });
|
||||||
|
if (!ok) return;
|
||||||
|
setBatchUpgrading(true);
|
||||||
|
try {
|
||||||
|
const res = await agentApi.batchUpgrade(ids);
|
||||||
|
toast.success(res?.message || `已提交:批量升级 ${ids.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
|
||||||
|
setTimeout(() => fetch(), 30000);
|
||||||
|
} catch { toast.error('提交失败'); }
|
||||||
|
finally { setBatchUpgrading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载中…</p></div>;
|
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载中…</p></div>;
|
||||||
if (!hosts.length) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">暂无服务器资产</p><p className="text-xs mt-1">请先在「服务器管理」中添加主机,然后在此安装 Agent</p></div>;
|
if (!hosts.length) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">暂无服务器资产</p><p className="text-xs mt-1">请先在「服务器管理」中添加主机,然后在此安装 Agent</p></div>;
|
||||||
return (
|
return (
|
||||||
@@ -127,6 +144,9 @@ const AgentTab: React.FC = () => {
|
|||||||
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleBatchUninstall} disabled={batchUninstalling}>
|
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleBatchUninstall} disabled={batchUninstalling}>
|
||||||
<Trash2 className="w-3 h-3" />批量卸载 ({selected.size})
|
<Trash2 className="w-3 h-3" />批量卸载 ({selected.size})
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-amber-500/30 text-amber-400 hover:bg-amber-500/10" onClick={handleBatchUpgrade} disabled={batchUpgrading}>
|
||||||
|
<Zap className="w-3 h-3" />批量升级 ({selected.size})
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch}><RefreshCw className="w-3 h-3" />刷新</Button>
|
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch}><RefreshCw className="w-3 h-3" />刷新</Button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -23,20 +24,23 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Server, ChevronDown, Loader2, Save } from 'lucide-react';
|
import { Server, ChevronDown, Loader2, Save, Key, Shield } from 'lucide-react';
|
||||||
|
import http from '@/services/http';
|
||||||
|
|
||||||
// 表单数据结构 — 与后端 CreateServerRequest/UpdateServerRequest 字段对齐
|
// 表单数据结构
|
||||||
export interface ServerFormData {
|
export interface ServerFormData {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
public_ip: string;
|
public_ip: string;
|
||||||
port: string;
|
port: string;
|
||||||
username: string;
|
username: string;
|
||||||
auth_type: string; // password | key
|
auth_type: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
ssh_key: string;
|
||||||
|
credential_id: number | null;
|
||||||
os: string;
|
os: string;
|
||||||
cpu: string;
|
cpu: string;
|
||||||
memory: string; // GB (表单) → MB (后端)
|
memory: string;
|
||||||
disk: string;
|
disk: string;
|
||||||
status: string;
|
status: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
@@ -51,6 +55,8 @@ export const EMPTY_FORM: ServerFormData = {
|
|||||||
username: 'root',
|
username: 'root',
|
||||||
auth_type: 'password',
|
auth_type: 'password',
|
||||||
password: '',
|
password: '',
|
||||||
|
ssh_key: '',
|
||||||
|
credential_id: null,
|
||||||
os: '',
|
os: '',
|
||||||
cpu: '',
|
cpu: '',
|
||||||
memory: '',
|
memory: '',
|
||||||
@@ -72,6 +78,13 @@ interface ServerFormProps {
|
|||||||
formSaving: boolean;
|
formSaving: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Credential {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
auth_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function ServerForm({
|
export function ServerForm({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -83,6 +96,41 @@ export function ServerForm({
|
|||||||
onSave,
|
onSave,
|
||||||
formSaving,
|
formSaving,
|
||||||
}: ServerFormProps) {
|
}: ServerFormProps) {
|
||||||
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||||
|
|
||||||
|
// 加载凭证列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
http.get('/credentials')
|
||||||
|
.then((res: any) => {
|
||||||
|
const list = Array.isArray(res) ? res : (res?.data || []);
|
||||||
|
setCredentials(list);
|
||||||
|
})
|
||||||
|
.catch(() => setCredentials([]));
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// 选择凭证时自动填充
|
||||||
|
const handleCredentialSelect = (credId: string) => {
|
||||||
|
const id = Number(credId);
|
||||||
|
if (!id) {
|
||||||
|
// 手动输入模式
|
||||||
|
setFormData({ ...formData, credential_id: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cred = credentials.find(c => c.id === id);
|
||||||
|
if (cred) {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
credential_id: id,
|
||||||
|
username: cred.username || 'root',
|
||||||
|
auth_type: cred.auth_type || 'password',
|
||||||
|
password: '', // 已关联凭证,不需要重复输入
|
||||||
|
ssh_key: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-h-[90vh] overflow-y-auto">
|
||||||
@@ -146,48 +194,83 @@ export function ServerForm({
|
|||||||
</div>
|
</div>
|
||||||
</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.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 />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
|
||||||
<SelectItem value="password">密码认证</SelectItem>
|
|
||||||
<SelectItem value="key">密钥认证</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-[var(--dg-text-secondary)]">
|
<Label className="text-[var(--dg-text-secondary)] flex items-center gap-1.5">
|
||||||
{formData.auth_type === 'key' ? 'SSH 私钥' : '登录密码'}
|
<Key className="h-3.5 w-3.5" /> SSH 凭证
|
||||||
{!editingId && <span className="text-red-400 ml-1">*</span>}
|
{!editingId && !formData.credential_id && <span className="text-red-400 ml-1">*</span>}
|
||||||
{editingId && <span className="text-[var(--dg-text-muted)] ml-1 text-xs">(留空则不修改)</span>}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Select
|
||||||
placeholder={formData.auth_type === 'key' ? '粘贴 SSH 私钥内容...' : '输入登录密码...'}
|
value={formData.credential_id ? String(formData.credential_id) : ''}
|
||||||
value={formData.password}
|
onValueChange={handleCredentialSelect}
|
||||||
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"
|
<SelectTrigger className="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="">手动输入(不关联凭证)</SelectItem>
|
||||||
|
{credentials.map((cred) => (
|
||||||
|
<SelectItem key={cred.id} value={String(cred.id)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Shield className="h-3 w-3 text-blue-400" />
|
||||||
|
{cred.name} ({cred.username}@{cred.auth_type === 'key' ? '密钥' : '密码'})
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{formData.credential_id && (
|
||||||
|
<p className="text-[10px] text-green-400/70">已关联凭证,登录信息从凭证自动获取</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 更多详情 - 可折叠区域 */}
|
{/* 手动输入模式 — 仅当未选凭证时显示 */}
|
||||||
|
{!formData.credential_id && (
|
||||||
|
<>
|
||||||
|
<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.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 />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||||
|
<SelectItem value="password">密码认证</SelectItem>
|
||||||
|
<SelectItem value="key">密钥认证</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 更多详情 */}
|
||||||
<Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
|
<Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -300,9 +300,17 @@ export function ServerManager() {
|
|||||||
tags: formData.tags || undefined,
|
tags: formData.tags || undefined,
|
||||||
description: formData.description || undefined,
|
description: formData.description || undefined,
|
||||||
};
|
};
|
||||||
// 密码仅在填写时提交
|
// 凭证关联 — 下拉选择后自动关联,无需手输密码
|
||||||
if (formData.password) {
|
if (formData.credential_id) {
|
||||||
payload.password = formData.password;
|
payload.credential_id = formData.credential_id;
|
||||||
|
} else {
|
||||||
|
// 手动输入模式:仅填了密码才提交
|
||||||
|
if (formData.password) {
|
||||||
|
payload.password = formData.password;
|
||||||
|
}
|
||||||
|
if (formData.ssh_key) {
|
||||||
|
payload.ssh_key = formData.ssh_key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await serverApi.updateServer(editingId, payload);
|
await serverApi.updateServer(editingId, payload);
|
||||||
|
|||||||
@@ -505,6 +505,7 @@ export const agentApi = {
|
|||||||
// Agent 安装/卸载
|
// Agent 安装/卸载
|
||||||
uninstallAgent: (data: { hostname?: string; server_id?: number }) => http.post('/agent/uninstall', data),
|
uninstallAgent: (data: { hostname?: string; server_id?: number }) => http.post('/agent/uninstall', data),
|
||||||
batchUninstall: (serverIds: number[]) => http.post('/agent/batch-uninstall', { server_ids: serverIds }),
|
batchUninstall: (serverIds: number[]) => http.post('/agent/batch-uninstall', { server_ids: serverIds }),
|
||||||
|
batchUpgrade: (serverIds: number[]) => http.post('/agent/batch-upgrade', { server_ids: serverIds }),
|
||||||
listAlerts: (params?: Record<string, string>) => http.get('/agent/alerts', params),
|
listAlerts: (params?: Record<string, string>) => http.get('/agent/alerts', params),
|
||||||
resolveAlert: (id: number) => http.post(`/agent/alerts/${id}/resolve`),
|
resolveAlert: (id: number) => http.post(`/agent/alerts/${id}/resolve`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -306,7 +306,13 @@ cmd_docker() {
|
|||||||
error "Docker Compose 未安装 (需要 docker compose 插件或 docker-compose)"
|
error "Docker Compose 未安装 (需要 docker compose 插件或 docker-compose)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# 自动从 git tag 获取版本号
|
||||||
|
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
|
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
export VERSION="${GIT_TAG:-dev}"
|
||||||
|
export BUILD_TIME
|
||||||
info "使用: $DOCKER_COMPOSE"
|
info "使用: $DOCKER_COMPOSE"
|
||||||
|
info "版本: $VERSION (构建时间: $BUILD_TIME)"
|
||||||
|
|
||||||
case "$action" in
|
case "$action" in
|
||||||
up)
|
up)
|
||||||
|
|||||||
Reference in new issue
Block a user