From ed08ea255ed42da08a878262c39c9eb596d0c94e Mon Sep 17 00:00:00 2001 From: attacker Date: Fri, 19 Jun 2026 01:24:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Agent=E6=89=B9=E9=87=8F=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=20+=20=E7=89=88=E6=9C=AC=E5=8F=B7=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=20+=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=87=AD=E8=AF=81=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E5=85=B3=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 列表, 选择后自动填充用户名/认证方式 --- backend/Dockerfile | 16 +- .../internal/api/handlers/agent_install.go | 72 ++++++++ backend/internal/api/routes/routes.go | 4 + docker-compose.yml | 3 + .../monitor/dashboard/GrafanaDashboard.tsx | 22 ++- frontend/src/components/server/ServerForm.tsx | 167 +++++++++++++----- .../src/components/server/ServerManager.tsx | 14 +- frontend/src/services/api.ts | 1 + run.sh | 6 + 9 files changed, 253 insertions(+), 52 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 0205b5e..0b6a996 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,6 +6,10 @@ # 署名 Logan.Li # ============================================================ +# 构建参数 — 版本号通过 git tag 或环境变量注入 +ARG VERSION=dev +ARG BUILD_TIME=unknown + # ==================== Stage 1: Backend Builder ==================== FROM golang:1.24-alpine AS builder @@ -26,9 +30,9 @@ RUN go mod download # 复制后端源码 COPY backend/ ./ -# 构建后端二进制 +# 构建后端二进制(注入版本号) 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/ # ==================== Stage 2: Agent Builder ==================== @@ -46,10 +50,10 @@ RUN go mod download # 复制 agent 源码 COPY agent/ ./ -# 交叉编译三个平台的 agent 二进制 -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -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=darwin GOARCH=arm64 go build -ldflags="-s -w" -o bin/aiops-agent-darwin-arm64 . +# 交叉编译三个平台的 agent 二进制(注入版本号) +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 -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 -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" -o bin/aiops-agent-darwin-arm64 . # ==================== Stage 3: Runtime ==================== FROM alpine:latest diff --git a/backend/internal/api/handlers/agent_install.go b/backend/internal/api/handlers/agent_install.go index 3af635e..78957fa 100644 --- a/backend/internal/api/handlers/agent_install.go +++ b/backend/internal/api/handlers/agent_install.go @@ -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 执行命令(即时返回,无轮询) // POST /api/v1/agent/exec func (h *AgentInstallHandler) RemoteExec(c *gin.Context) { diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 29894b1..e595f42 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -455,8 +455,12 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // Agent 安装/卸载 agentQuery.POST("/uninstall", agentInstallHandler.UninstallAgent) 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 llmHandler := handlers.NewLLMHandler(db) aiopsGroup := authorized.Group("/aiops") diff --git a/docker-compose.yml b/docker-compose.yml index f2d19e9..c41a96f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,9 @@ services: build: context: . dockerfile: backend/Dockerfile + args: + VERSION: ${VERSION:-dev} + BUILD_TIME: ${BUILD_TIME:-unknown} container_name: aiops-backend restart: unless-stopped ports: diff --git a/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx b/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx index 1d1f365..7f95d5e 100644 --- a/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx +++ b/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react'; import { Monitor, Plus, RefreshCw, Maximize2, Minimize2, BarChart3, Clock, Server, Activity, Database, Wifi, WifiOff, ArrowUpDown, ArrowUp, ArrowDown, - ExternalLink, Download, Trash2, Rocket + ExternalLink, Download, Trash2, Rocket, Zap } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -104,6 +104,23 @@ const AgentTab: React.FC = () => { 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

加载中…

; if (!hosts.length) return

暂无服务器资产

请先在「服务器管理」中添加主机,然后在此安装 Agent

; return ( @@ -127,6 +144,9 @@ const AgentTab: React.FC = () => { + )} diff --git a/frontend/src/components/server/ServerForm.tsx b/frontend/src/components/server/ServerForm.tsx index 78baa94..4ab7bba 100644 --- a/frontend/src/components/server/ServerForm.tsx +++ b/frontend/src/components/server/ServerForm.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils'; +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -23,20 +24,23 @@ import { SelectTrigger, SelectValue, } 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 { hostname: string; ip: string; public_ip: string; port: string; username: string; - auth_type: string; // password | key + auth_type: string; password: string; + ssh_key: string; + credential_id: number | null; os: string; cpu: string; - memory: string; // GB (表单) → MB (后端) + memory: string; disk: string; status: string; tags: string; @@ -51,6 +55,8 @@ export const EMPTY_FORM: ServerFormData = { username: 'root', auth_type: 'password', password: '', + ssh_key: '', + credential_id: null, os: '', cpu: '', memory: '', @@ -72,6 +78,13 @@ interface ServerFormProps { formSaving: boolean; } +interface Credential { + id: number; + name: string; + username: string; + auth_type: string; +} + export function ServerForm({ open, onOpenChange, @@ -83,6 +96,41 @@ export function ServerForm({ onSave, formSaving, }: ServerFormProps) { + const [credentials, setCredentials] = useState([]); + + // 加载凭证列表 + 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 ( @@ -146,48 +194,83 @@ export function ServerForm({ -
-
- - 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)]" - /> -
-
- - -
-
- + {/* 凭证选择 — 下拉关联已有凭证 */}
-