diff --git a/backend/controller/agent_install.go b/backend/controller/agent_install.go index 2595485..5d61559 100644 --- a/backend/controller/agent_install.go +++ b/backend/controller/agent_install.go @@ -58,9 +58,9 @@ func truncateAuditText(s string, n int) string { return s } -// recordAgentAudit 记录 Agent 安装/卸载/升级操作审计 // Logan.Li -// status: 1=成功, 0=失败;errMsg 记录失败原因(SSH 错误、脚本输出等),用于追溯"安装过程遇到什么问题" -func (h *AgentInstallHandler) recordAgentAudit(ac auditCtx, action, resource, resourceID string, status int, errMsg string) { +// recordAgentAudit 记录 Agent 安装/卸载/升级操作审计(含命令输出与验证结果)// Logan.Li +// status: 1=成功, 0=失败 +func (h *AgentInstallHandler) recordAgentAudit(ac auditCtx, action, resource, resourceID string, status int, reqDetail, respDetail, errMsg string) { uid := ac.userID h.db.Create(&model.AuditLog{ UserID: &uid, @@ -71,8 +71,10 @@ func (h *AgentInstallHandler) recordAgentAudit(ac auditCtx, action, resource, re Action: action, Resource: resource, ResourceID: resourceID, + Request: truncateAuditText(reqDetail, 2048), + Response: truncateAuditText(respDetail, 8192), Status: status, - Error: errMsg, + Error: truncateAuditText(errMsg, 512), }) } @@ -235,7 +237,7 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { client, err := sshDial(effectiveServer) if err != nil { log.Printf("[AgentInstall] SSH连接失败 %s: %v", server.IP, err) - h.recordAgentAudit(ac, "install", resource, resourceID, 0, "SSH连接失败: "+formatSSHError(err)) + h.recordAgentAudit(ac, "install", resource, resourceID, 0, resource, "", "SSH连接失败: "+formatSSHError(err)) c.JSON(http.StatusOK, model.Response{ Code: 500, Message: "SSH 连接失败: " + formatSSHError(err), @@ -296,7 +298,8 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { } else if strings.Contains(lowerOut, "下载失败") || strings.Contains(lowerOut, "failed to connect") || strings.Contains(lowerOut, "connection refused") { msg = "目标主机无法下载安装脚本,请检查后端地址 " + serverURL + " 是否可达" } - h.recordAgentAudit(ac, "install", resource, resourceID, 0, msg+": "+truncateAuditText(installOut, 300)) + h.recordAgentAudit(ac, "install", resource, resourceID, 0, + fmt.Sprintf("server=%s arch=%s", serverURL, arch), installOut, msg) c.JSON(http.StatusOK, model.Response{ Code: 500, Message: msg, @@ -309,23 +312,30 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { verifyOut, _ := runSSHCommand(client, "systemctl is-active aiops-agent 2>/dev/null || echo 'not_found'") installed := contains(verifyOut, "active") || contains(verifyOut, "running") + var agentVer string if installed { // 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本(等 Agent 首次上报真实数据)// Logan.Li updates := map[string]interface{}{ "agent_status": "online", "last_check_at": time.Now(), } - if agentVersion := detectAgentVersion(client); agentVersion != "" { - updates["agent_version"] = agentVersion + agentVer = detectAgentVersion(client) + if agentVer != "" { + updates["agent_version"] = agentVer } h.db.Model(&server).Updates(updates) } // 审计:成功或"命令已执行但服务未运行"均记录,便于追溯 // Logan.Li + reqDetail := fmt.Sprintf("server=%s arch=%s", serverURL, arch) + respDetail := fmt.Sprintf("=== 安装输出 ===\n%s\n=== 服务状态 ===\n%s", installOut, verifyOut) + if agentVer != "" { + respDetail += "\n=== Agent版本 ===\n" + agentVer + } if installed { - h.recordAgentAudit(ac, "install", resource, resourceID, 1, "") + h.recordAgentAudit(ac, "install", resource, resourceID, 1, reqDetail, respDetail, "") } else { - h.recordAgentAudit(ac, "install", resource, resourceID, 0, "安装命令已执行但服务未检测到运行: "+truncateAuditText(verifyOut, 300)) + h.recordAgentAudit(ac, "install", resource, resourceID, 0, reqDetail, respDetail, "安装命令已执行但服务未检测到运行") } c.JSON(http.StatusOK, model.Response{ @@ -434,20 +444,22 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) { } } - installed, _, verifyOut, err := h.installOne(effectiveServer, serverURL, token) + installed, installOut, verifyOut, err := h.installOne(effectiveServer, serverURL, token) res := server.Hostname + " (" + server.IP + ")" rid := strconv.Itoa(int(server.ID)) + reqDetail := fmt.Sprintf("server=%s host=%s", serverURL, res) if err != nil { log.Printf("[BatchInstall] %s (%s) 安装失败: %v", server.Hostname, server.IP, err) - h.recordAgentAudit(ac, "install", res, rid, 0, truncateAuditText(err.Error(), 500)) + h.recordAgentAudit(ac, "install", res, rid, 0, reqDetail, installOut, truncateAuditText(err.Error(), 500)) } else { successCount++ + respDetail := fmt.Sprintf("=== 安装输出 ===\n%s\n=== 服务状态 ===\n%s", installOut, verifyOut) if installed { log.Printf("[BatchInstall] %s Agent 安装成功", server.Hostname) - h.recordAgentAudit(ac, "install", res, rid, 1, "") + h.recordAgentAudit(ac, "install", res, rid, 1, reqDetail, respDetail, "") } else { log.Printf("[BatchInstall] %s Agent 安装命令已执行但服务未检测到运行", server.Hostname) - h.recordAgentAudit(ac, "install", res, rid, 0, "服务未检测到运行: "+truncateAuditText(verifyOut, 300)) + h.recordAgentAudit(ac, "install", res, rid, 0, reqDetail, respDetail, "服务未检测到运行") } } } @@ -498,14 +510,14 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) { h.db.Model(&server).Updates(map[string]interface{}{ "agent_status": "offline", "agent_version": "", }) - h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, "无SSH凭据,已标记离线") + h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, resource, "无SSH凭据,已标记离线", "") c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已标记为离线(该主机无SSH凭据,无法远程卸载,请手动执行 systemctl stop aiops-agent)"}) return } client, err := sshDial(server) if err != nil { - h.recordAgentAudit(ac, "uninstall", resource, resourceID, 0, "SSH连接失败: "+formatSSHError(err)) + h.recordAgentAudit(ac, "uninstall", resource, resourceID, 0, resource, "", "SSH连接失败: "+formatSSHError(err)) c.JSON(http.StatusOK, model.Response{Code: 500, Message: "SSH连接失败: " + formatSSHError(err)}) return } @@ -516,7 +528,7 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) { h.db.Model(&server).Updates(map[string]interface{}{ "agent_status": "offline", "agent_version": "", }) - h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, "") + h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, resource, "systemctl stop + rm binary", "") c.JSON(http.StatusOK, model.Response{Code: 200, Message: fmt.Sprintf("已卸载 %s (%s) 的 Agent", server.Hostname, server.IP)}) } @@ -548,21 +560,21 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) { if server.Username == "" || (server.Password == "" && server.SSHKey == "") { h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""}) log.Printf("[BatchUninstall] %s 无SSH凭据,已标记离线", server.Hostname) - h.recordAgentAudit(ac, "uninstall", res, rid, 1, "无SSH凭据,已标记离线") + h.recordAgentAudit(ac, "uninstall", res, rid, 1, res, "无SSH凭据,已标记离线", "") successCount++ continue } client, err := sshDial(server) if err != nil { log.Printf("[BatchUninstall] %s SSH失败: %v", server.Hostname, err) - h.recordAgentAudit(ac, "uninstall", res, rid, 0, "SSH连接失败: "+formatSSHError(err)) + h.recordAgentAudit(ac, "uninstall", res, rid, 0, res, "", "SSH连接失败: "+formatSSHError(err)) continue } runSSHCommand(client, "systemctl stop aiops-agent 2>/dev/null; systemctl disable aiops-agent 2>/dev/null; rm -f /usr/local/bin/aiops-agent /etc/systemd/system/aiops-agent.service; systemctl daemon-reload 2>/dev/null") client.Close() h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""}) log.Printf("[BatchUninstall] %s Agent 已卸载", server.Hostname) - h.recordAgentAudit(ac, "uninstall", res, rid, 1, "") + h.recordAgentAudit(ac, "uninstall", res, rid, 1, res, "systemctl stop + rm binary", "") successCount++ } log.Printf("[BatchUninstall] 批量卸载完成: %d/%d 成功", successCount, len(req.ServerIDs)) @@ -610,7 +622,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { client, err := sshDial(server) if err != nil { log.Printf("[BatchUpgrade] %s SSH连接失败: %v", server.Hostname, err) - h.recordAgentAudit(ac, "upgrade", res, rid, 0, "SSH连接失败: "+formatSSHError(err)) + h.recordAgentAudit(ac, "upgrade", res, rid, 0, res, "", "SSH连接失败: "+formatSSHError(err)) continue } @@ -631,7 +643,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { if err != nil || !contains(out, "UPGRADE_OK") { log.Printf("[BatchUpgrade] %s 升级失败: output=%s err=%v", server.Hostname, out, err) - h.recordAgentAudit(ac, "upgrade", res, rid, 0, "升级失败: "+truncateAuditText(out, 300)) + h.recordAgentAudit(ac, "upgrade", res, rid, 0, + fmt.Sprintf("server=%s binary=%s", serverURL, binaryFile), out, "升级失败") continue } @@ -647,7 +660,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { "last_check_at": time.Now(), }) log.Printf("[BatchUpgrade] %s Agent 升级成功", server.Hostname) - h.recordAgentAudit(ac, "upgrade", res, rid, 1, "") + h.recordAgentAudit(ac, "upgrade", res, rid, 1, + fmt.Sprintf("server=%s binary=%s", serverURL, binaryFile), out, "") successCount++ } log.Printf("[BatchUpgrade] 批量升级完成: %d/%d 成功", successCount, len(req.ServerIDs)) diff --git a/backend/controller/alert_engine.go b/backend/controller/alert_engine.go index 5dbd877..57866ec 100644 --- a/backend/controller/alert_engine.go +++ b/backend/controller/alert_engine.go @@ -67,7 +67,7 @@ func (e *AlertEngine) Check() { e.checkMiddlewareAlerts() e.checkSNMPAlerts() e.checkSiteAlerts() - e.autoResolveSiteSslAlerts() + e.autoResolveSiteAlerts() e.checkLogAlertRules() e.autoResolveAlerts() e.checkLogAlertRecovery() diff --git a/backend/controller/alert_engine_monitor.go b/backend/controller/alert_engine_monitor.go index 70f4ddb..c8df9eb 100644 --- a/backend/controller/alert_engine_monitor.go +++ b/backend/controller/alert_engine_monitor.go @@ -192,6 +192,7 @@ func (e *AlertEngine) evalSiteRule(rule model.MonitorAlertRule, task model.SiteT } if !triggered { + e.resolveActiveSiteAlerts(task.ID, rule.Metric) return } @@ -294,22 +295,79 @@ func extractHostMetricValue(report model.AgentReport, metric string) (float64, b } } -// autoResolveSiteSslAlerts 证书续期后自动恢复 SSL 过期告警 // Logan.Li -func (e *AlertEngine) autoResolveSiteSslAlerts() { +// resolveActiveSiteAlerts 指标恢复正常时关闭对应活跃告警 // Logan.Li +func (e *AlertEngine) resolveActiveSiteAlerts(taskID uint, triggerType string) { + now := time.Now() + e.db.Model(&model.SiteAlert{}). + Where("task_id = ? AND status = ? AND trigger_type = ?", taskID, model.SiteAlertStatusActive, triggerType). + Updates(map[string]interface{}{ + "status": model.SiteAlertStatusResolved, "resolved_at": now, + }) +} + +// siteRuleStillTriggered 判断任务是否仍满足告警规则 // Logan.Li +func (e *AlertEngine) siteRuleStillTriggered(rule model.MonitorAlertRule, task model.SiteTask) bool { + switch rule.Metric { + case "probe_fail": + return task.LastResult == "fail" || task.LastResult == "timeout" + case "probe_timeout": + return task.LastResult == "timeout" + case "http_status_error": + return task.LastResult == "fail" + case "response_time": + return e.compareOperator(rule.Operator, task.AvgResponse, rule.Threshold) + case "availability": + return e.compareOperator(rule.Operator, task.SuccessRate, rule.Threshold) + case "ssl_expiry_days": + if !siteprobe.IsSSLProbeTask(task) { + return false + } + var result model.SiteResult + e.db.Where("task_id = ? AND ssl_cert_expiry IS NOT NULL", task.ID). + Order("checked_at DESC").First(&result) + if result.ID == 0 { + return false + } + return e.compareOperator(rule.Operator, float64(result.SSLDaysRemaining), rule.Threshold) + default: + return true + } +} + +// autoResolveSiteAlerts 扫描活跃拨测告警,指标已恢复则自动关闭(含 SSL)// Logan.Li +func (e *AlertEngine) autoResolveSiteAlerts() { var alerts []model.SiteAlert - e.db.Where("status = ? AND trigger_type = ?", model.SiteAlertStatusActive, "ssl_expiry_days").Find(&alerts) + e.db.Where("status = ?", model.SiteAlertStatusActive).Find(&alerts) if len(alerts) == 0 { return } + taskCache := map[uint]model.SiteTask{} + ruleCache := map[string]model.MonitorAlertRule{} now := time.Now() for _, alert := range alerts { - var result model.SiteResult - e.db.Where("task_id = ? AND ssl_cert_expiry IS NOT NULL", alert.TaskID). - Order("checked_at DESC").First(&result) - if result.ID == 0 { - continue + task, ok := taskCache[alert.TaskID] + if !ok { + var t model.SiteTask + if e.db.First(&t, alert.TaskID).Error != nil { + continue + } + task = t + taskCache[alert.TaskID] = task } - if float64(result.SSLDaysRemaining) >= 30 { + rule, ok := ruleCache[alert.Title] + if !ok { + var r model.MonitorAlertRule + e.db.Where("category = ? AND name = ?", model.MonitorAlertSite, alert.Title).First(&r) + if r.ID == 0 { + r.Metric = alert.TriggerType + } + rule = r + ruleCache[alert.Title] = rule + } + if rule.Metric == "" { + rule.Metric = alert.TriggerType + } + if !e.siteRuleStillTriggered(rule, task) { e.db.Model(&alert).Updates(map[string]interface{}{ "status": model.SiteAlertStatusResolved, "resolved_at": now, }) diff --git a/backend/controller/build_node.go b/backend/controller/build_node.go new file mode 100644 index 0000000..247c3e8 --- /dev/null +++ b/backend/controller/build_node.go @@ -0,0 +1,224 @@ +package controller + +import ( + "net/http" + "strconv" + "strings" + "time" + + "ezops/model" + "ezops/pkg/remoteexec" + + "github.com/gin-gonic/gin" +) + +// ListBuildNodes 构建节点列表 // Logan.Li +func (h *PipelineHandler) ListBuildNodes(c *gin.Context) { + masterCfg := h.ensureBuildMasterConfig() + var nodes []model.BuildNode + h.db.Order("id ASC").Find(&nodes) + + // 填充运行时负载与服务器信息 + nodeMu.Lock() + for i := range nodes { + nodes[i].RunningCount = nodeRunningCount[nodes[i].ID] + var srv model.Server + if err := h.db.First(&srv, nodes[i].ServerID).Error; err == nil { + nodes[i].Hostname = srv.Hostname + nodes[i].IP = srv.IP + } + } + masterRunning := nodeRunningCount[0] + nodeMu.Unlock() + + c.JSON(http.StatusOK, model.Response{ + Code: 200, + Data: map[string]interface{}{ + "list": nodes, + "total": len(nodes), + "master": map[string]interface{}{ + "name": "Master", + "max_executors": masterCfg.MaxExecutors, + "running_count": masterRunning, + }, + }, + }) +} + +// CreateBuildNode 创建构建节点 // Logan.Li +func (h *PipelineHandler) CreateBuildNode(c *gin.Context) { + var req model.CreateBuildNodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()}) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "节点名称不能为空"}) + return + } + var srv model.Server + if err := h.db.First(&srv, req.ServerID).Error; err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "关联服务器不存在"}) + return + } + var dup int64 + h.db.Model(&model.BuildNode{}).Where("name = ?", req.Name).Count(&dup) + if dup > 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "节点名称已存在"}) + return + } + + node := model.BuildNode{ + Name: req.Name, + Description: req.Description, + ServerID: req.ServerID, + Labels: req.Labels, + MaxExecutors: req.MaxExecutors, + WorkspacePath: req.WorkspacePath, + Enabled: true, + } + if node.MaxExecutors <= 0 { + node.MaxExecutors = 2 + } + if strings.TrimSpace(node.WorkspacePath) == "" { + node.WorkspacePath = "/data/aiops-workspace" + } + + if err := h.db.Create(&node).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败"}) + return + } + node.Hostname = srv.Hostname + node.IP = srv.IP + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "创建成功", Data: node}) +} + +// UpdateBuildNode 更新构建节点 // Logan.Li +func (h *PipelineHandler) UpdateBuildNode(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var node model.BuildNode + if err := h.db.First(&node, id).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "节点不存在"}) + return + } + var req model.UpdateBuildNodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误"}) + return + } + updates := map[string]interface{}{} + if req.Name != "" { + updates["name"] = strings.TrimSpace(req.Name) + } + if req.Description != "" { + updates["description"] = req.Description + } + if req.ServerID != nil { + var srv model.Server + if err := h.db.First(&srv, *req.ServerID).Error; err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "关联服务器不存在"}) + return + } + updates["server_id"] = *req.ServerID + } + if req.Labels != "" { + updates["labels"] = req.Labels + } + if req.MaxExecutors > 0 { + updates["max_executors"] = req.MaxExecutors + } + if req.WorkspacePath != "" { + updates["workspace_path"] = req.WorkspacePath + } + if req.Enabled != nil { + updates["enabled"] = *req.Enabled + } + if len(updates) == 0 { + c.JSON(http.StatusOK, model.Response{Code: 200, Data: node}) + return + } + if err := h.db.Model(&node).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "更新失败"}) + return + } + h.db.First(&node, id) + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "更新成功", Data: node}) +} + +// DeleteBuildNode 删除构建节点 // Logan.Li +func (h *PipelineHandler) DeleteBuildNode(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var inUse int64 + h.db.Model(&model.Pipeline{}).Where("build_node_id = ?", id).Count(&inUse) + if inUse > 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "仍有流水线绑定此节点,请先解除关联"}) + return + } + if err := h.db.Delete(&model.BuildNode{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "删除失败"}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"}) +} + +// TestBuildNode 测试构建节点 SSH 连通性 // Logan.Li +func (h *PipelineHandler) TestBuildNode(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var node model.BuildNode + if err := h.db.First(&node, id).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "节点不存在"}) + return + } + var srv model.Server + if err := h.db.First(&srv, node.ServerID).Error; err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "关联服务器不存在"}) + return + } + cfg, err := buildSSHConfig(h.db, srv) + if err != nil { + c.JSON(http.StatusOK, model.Response{Code: 200, Data: map[string]interface{}{ + "success": false, "message": err.Error(), + }}) + return + } + result, err := remoteexec.Exec(cfg, "echo aiops-node-ok && uname -a", 15*time.Second) + if err != nil || !result.Success { + msg := "" + if result != nil { + msg = result.Error + if msg == "" { + msg = result.Output + } + } + if err != nil { + msg = err.Error() + } + c.JSON(http.StatusOK, model.Response{Code: 200, Data: map[string]interface{}{ + "success": false, "message": msg, + }}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Data: map[string]interface{}{ + "success": true, "message": strings.TrimSpace(result.Output), + }}) +} + +// UpdateBuildMaster 更新 Master 内置节点最大并发(1=串行按触发顺序排队) // Logan.Li +func (h *PipelineHandler) UpdateBuildMaster(c *gin.Context) { + var req model.UpdateBuildMasterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()}) + return + } + cfg := h.setMasterMaxExecutors(req.MaxExecutors) + nodeMu.Lock() + running := nodeRunningCount[masterNodeID] + nodeMu.Unlock() + c.JSON(http.StatusOK, model.Response{ + Code: 200, Message: "已更新", + Data: map[string]interface{}{ + "name": "Master", "max_executors": cfg.MaxExecutors, "running_count": running, + }, + }) +} \ No newline at end of file diff --git a/backend/controller/log_datasource.go b/backend/controller/log_datasource.go index fd44258..74630cf 100644 --- a/backend/controller/log_datasource.go +++ b/backend/controller/log_datasource.go @@ -5,13 +5,17 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io" "log" "net/http" + "strconv" "strings" "time" + "ezops/middleware" "ezops/model" + "ezops/pkg/logaccess" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -40,6 +44,7 @@ func (h *LogDSHandler) List(c *gin.Context) { if sources == nil { sources = []model.LogDataSource{} } + sources = logaccess.FilterDataSources(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), sources) // 按项目惯例返回 {list,total},前端 listDatasources 取 .list // Logan.Li c.JSON(200, model.Response{Code: 200, Data: model.ListResponse{List: sources, Total: int64(len(sources))}}) } @@ -268,6 +273,67 @@ func (h *LogDSHandler) ListIndices(c *gin.Context) { c.JSON(200, model.Response{Code: 200, Data: indices}) } +// ListIndicesByID GET /api/v1/log-datasources/:id/indices — 按数据源 ID 拉取索引(查询面板用)// Logan.Li +func (h *LogDSHandler) ListIndicesByID(c *gin.Context) { + ds, err := loadLogDS(h, parseUintParam(c.Param("id"))) + if err != nil { + c.JSON(404, model.Response{Code: 404, Message: "数据源不存在"}) + return + } + if ds.Type != "elasticsearch" { + c.JSON(400, model.Response{Code: 400, Message: "仅 Elasticsearch 数据源支持索引列表"}) + return + } + filtered := logaccess.FilterDataSources(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), []model.LogDataSource{*ds}) + if len(filtered) == 0 { + c.JSON(403, model.Response{Code: 403, Message: "无权访问该数据源"}) + return + } + indices, err := fetchESIndices(ds) + if err != nil { + c.JSON(502, model.Response{Code: 502, Message: err.Error()}) + return + } + c.JSON(200, model.Response{Code: 200, Data: indices}) +} + +func fetchESIndices(ds *model.LogDataSource) ([]string, error) { + client := newLogHTTPClient(ds.SkipTLSVerify, 15*time.Second) + target := strings.TrimRight(ds.URL, "/") + "/_cat/indices?format=json&h=index" + httpReq, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + return nil, err + } + applyLogDSAuthV2(httpReq, ds) + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("查询索引失败: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("ES 错误: %s", string(body)) + } + var raw []map[string]interface{} + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("解析索引列表失败") + } + indices := make([]string, 0, len(raw)) + for _, entry := range raw { + idx, ok := entry["index"].(string) + if !ok || idx == "" || strings.HasPrefix(idx, ".") { + continue + } + indices = append(indices, idx) + } + return indices, nil +} + +func parseUintParam(s string) uint { + n, _ := strconv.ParseUint(s, 10, 64) + return uint(n) +} + func testLogDSConnection(req model.TestLogDSReq) (bool, string) { client := newLogHTTPClient(req.SkipTLSVerify, 10*time.Second) url := strings.TrimRight(req.URL, "/") diff --git a/backend/controller/log_proxy.go b/backend/controller/log_proxy.go index 0ccee41..83014a4 100644 --- a/backend/controller/log_proxy.go +++ b/backend/controller/log_proxy.go @@ -11,21 +11,25 @@ import ( "strings" "time" + "ezops/middleware" "ezops/model" + "ezops/pkg/logaccess" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) // LogProxyHandler Loki / ES 无状态查询代理 // 实时转发请求并解析展示字段,不将日志写入 AIOps 数据库 // Logan.Li type LogProxyHandler struct { ds *LogDSHandler + db *gorm.DB } const maxLogQueryLimit = 1000 // 单次查询返回日志行数硬上限(Loki limit / ES size 共用)// Logan.Li -func NewLogProxyHandler(ds *LogDSHandler) *LogProxyHandler { - return &LogProxyHandler{ds: ds} +func NewLogProxyHandler(ds *LogDSHandler, db *gorm.DB) *LogProxyHandler { + return &LogProxyHandler{ds: ds, db: db} } // QueryLoki POST /api/v1/logs/loki/query @@ -37,11 +41,19 @@ func (h *LogProxyHandler) QueryLoki(c *gin.Context) { Start string `json:"start"` End string `json:"end"` Direction string `json:"direction"` + Cursor string `json:"cursor"` // 纳秒时间戳,向后翻页时作为 end // Logan.Li } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: err.Error()}) return } + allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{ + Engine: "loki", DatasourceID: req.DatasourceID, Query: req.Query, + }) + if !allowed { + c.JSON(403, model.Response{Code: 403, Message: denyMsg}) + return + } ds, err := loadLogDS(h.ds, req.DatasourceID) if err != nil || ds.Type != "loki" { c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"}) @@ -68,6 +80,11 @@ func (h *LogProxyHandler) QueryLoki(c *gin.Context) { start = t } } + if req.Cursor != "" { + if n, err := strconv.ParseInt(strings.TrimSpace(req.Cursor), 10, 64); err == nil && n > 0 { + end = time.Unix(0, n) + } + } params := url.Values{} params.Set("query", req.Query) @@ -94,8 +111,12 @@ func (h *LogProxyHandler) QueryLoki(c *gin.Context) { var raw map[string]interface{} _ = json.Unmarshal(body, &raw) entries := flattenLokiStreams(raw) + nextCursor := "" + if len(entries) > 0 && len(entries) >= req.Limit { + nextCursor = lokiEntryCursor(entries[len(entries)-1].Timestamp) + } c.JSON(200, model.Response{Code: 200, Data: gin.H{ - "entries": entries, "total": len(entries), + "entries": entries, "total": len(entries), "has_more": nextCursor != "", "next_cursor": nextCursor, }}) } @@ -109,23 +130,33 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) { Size int `json:"size"` Start string `json:"start"` End string `json:"end"` + SearchAfter []interface{} `json:"search_after"` // ES 深分页游标 // Logan.Li } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: err.Error()}) return } + index := req.Index + if index == "" { + if dsPreview, err := loadLogDS(h.ds, req.DatasourceID); err == nil { + index = dsPreview.DefaultIndex + } + } + if index == "" { + index = "*" + } + allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{ + Engine: "elasticsearch", DatasourceID: req.DatasourceID, Index: index, Query: req.Query, + }) + if !allowed { + c.JSON(403, model.Response{Code: 403, Message: denyMsg}) + return + } ds, err := loadLogDS(h.ds, req.DatasourceID) if err != nil || ds.Type != "elasticsearch" { c.JSON(400, model.Response{Code: 400, Message: "Elasticsearch 数据源无效"}) return } - index := req.Index - if index == "" { - index = ds.DefaultIndex - } - if index == "" { - index = "*" - } if req.Size <= 0 { req.Size = 200 } @@ -140,7 +171,8 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) { // ES 7+ 默认 track_total_hits 为 10000,必须显式开启才能返回真实命中总数 // Logan.Li "track_total_hits": true, "sort": []interface{}{ - map[string]interface{}{"_score": map[string]string{"order": "desc"}}, + map[string]interface{}{"@timestamp": map[string]string{"order": "desc", "unmapped_type": "date"}}, + map[string]interface{}{"_id": map[string]string{"order": "desc"}}, }, "query": map[string]interface{}{ "bool": map[string]interface{}{ @@ -206,6 +238,9 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) { } } } + if len(req.SearchAfter) > 0 { + searchBody["search_after"] = req.SearchAfter + } payload, _ := json.Marshal(searchBody) client := newLogHTTPClient(ds.SkipTLSVerify, 60*time.Second) @@ -226,10 +261,11 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) { } var raw map[string]interface{} _ = json.Unmarshal(body, &raw) - entries := flattenESEntries(raw) + entries, nextSearchAfter := flattenESEntries(raw) total := esHitsTotal(raw) + hasMore := len(entries) >= req.Size && len(nextSearchAfter) > 0 c.JSON(200, model.Response{Code: 200, Data: gin.H{ - "entries": entries, "total": total, + "entries": entries, "total": total, "has_more": hasMore, "search_after": nextSearchAfter, }}) } @@ -258,6 +294,28 @@ func logProxyErrCategory(status int) string { } } +// formatLokiTimestamp 将 Loki 纳秒/秒级(含科学计数法)时间戳转为 RFC3339 // Logan.Li +func formatLokiTimestamp(ts string) string { + ts = strings.TrimSpace(ts) + if f, err := strconv.ParseFloat(ts, 64); err == nil { + if strings.Contains(ts, ".") || strings.Contains(strings.ToLower(ts), "e") { + sec := int64(f) + nsec := int64((f - float64(sec)) * 1e9) + return time.Unix(sec, nsec).UTC().Format(time.RFC3339) + } + } + if n, err := strconv.ParseInt(ts, 10, 64); err == nil { + if n > 1e15 { + return time.Unix(0, n).UTC().Format(time.RFC3339Nano) + } + if n > 1e12 { + return time.UnixMilli(n).UTC().Format(time.RFC3339Nano) + } + return time.Unix(n, 0).UTC().Format(time.RFC3339) + } + return ts +} + func parseLogTime(s string) (time.Time, error) { if n, err := strconv.ParseInt(s, 10, 64); err == nil { if n > 1e15 { @@ -301,7 +359,7 @@ func flattenLokiStreams(raw map[string]interface{}) []logEntryRow { if len(pair) < 2 { continue } - ts := fmt.Sprintf("%v", pair[0]) + ts := formatLokiTimestamp(fmt.Sprintf("%v", pair[0])) msg := fmt.Sprintf("%v", pair[1]) out = append(out, logEntryRow{ Timestamp: ts, @@ -315,18 +373,19 @@ func flattenLokiStreams(raw map[string]interface{}) []logEntryRow { return out } -func flattenESEntries(raw map[string]interface{}) []logEntryRow { +func flattenESEntries(raw map[string]interface{}) ([]logEntryRow, []interface{}) { out := []logEntryRow{} + var nextAfter []interface{} hitsWrap, _ := raw["hits"].(map[string]interface{}) hits, _ := hitsWrap["hits"].([]interface{}) - for _, h := range hits { + for i, h := range hits { doc, _ := h.(map[string]interface{}) src, _ := doc["_source"].(map[string]interface{}) if src == nil { continue } msg := pickESMessage(src) - level, _ := src["level"].(string) + level := pickESLevel(src) ts, _ := src["@timestamp"].(string) if ts == "" { ts = fmt.Sprintf("%v", src["timestamp"]) @@ -336,10 +395,63 @@ func flattenESEntries(raw map[string]interface{}) []logEntryRow { Timestamp: ts, Level: level, Message: msg, + Labels: extractESLabels(src), Raw: string(rawBytes), }) + if i == len(hits)-1 { + if sortVals, ok := doc["sort"].([]interface{}); ok { + nextAfter = sortVals + } + } } - return out + return out, nextAfter +} + +// lokiEntryCursor 从 RFC3339 时间戳反推纳秒游标 // Logan.Li +func lokiEntryCursor(ts string) string { + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { + return strconv.FormatInt(t.UnixNano(), 10) + } + if t, err := time.Parse(time.RFC3339, ts); err == nil { + return strconv.FormatInt(t.UnixNano(), 10) + } + return "" +} + +// extractESLabels 从 _source 提取常用字段作为 labels(便于字段过滤)// Logan.Li +func extractESLabels(src map[string]interface{}) map[string]string { + labels := map[string]string{} + for _, key := range []string{"app", "service", "service_name", "host.name", "kubernetes.namespace", "namespace", "container.name", "level"} { + if v := pickESField(src, key); v != "" { + labels[key] = v + } + } + if lv := pickESLevel(src); lv != "" { + labels["level"] = lv + } + return labels +} + +func pickESField(src map[string]interface{}, key string) string { + if strings.Contains(key, ".") { + parts := strings.Split(key, ".") + cur := interface{}(src) + for _, p := range parts { + m, ok := cur.(map[string]interface{}) + if !ok { + return "" + } + cur, ok = m[p] + if !ok { + return "" + } + } + return stringifyESField(cur) + } + if v, ok := src[key]; ok { + return stringifyESField(v) + } + return "" } // esHitsTotal 从 ES 响应中提取真实命中总数(兼容 ES 7+ total.value 与旧版 total)// Logan.Li @@ -356,10 +468,58 @@ func esHitsTotal(raw map[string]interface{}) int64 { return 0 } +// pickESLevel 提取日志级别(兼容顶层 level 与 ECS/Filebeat 的 log.level) // Logan.Li +func pickESLevel(src map[string]interface{}) string { + if v, ok := src["level"].(string); ok && v != "" { + return v + } + if v, ok := src["log.level"].(string); ok && v != "" { + return v + } + if logObj, ok := src["log"].(map[string]interface{}); ok { + if v, ok := logObj["level"].(string); ok && v != "" { + return v + } + } + return "" +} + +// stringifyESField 将 ES 字段值转为展示字符串 // Logan.Li +func stringifyESField(v interface{}) string { + switch t := v.(type) { + case string: + return t + case float64: + return fmt.Sprintf("%v", t) + case bool: + return fmt.Sprintf("%t", t) + default: + return "" + } +} + +// pickESMessage 提取日志正文(兼容 message / ECS log.original / event.original) // Logan.Li func pickESMessage(src map[string]interface{}) string { - for _, key := range []string{"message", "msg", "log", "content"} { + for _, key := range []string{"message", "msg", "content", "event.original"} { if v, ok := src[key]; ok { - return fmt.Sprintf("%v", v) + if s := stringifyESField(v); s != "" { + return s + } + } + } + if logObj, ok := src["log"].(map[string]interface{}); ok { + for _, key := range []string{"original", "message"} { + if v, ok := logObj[key]; ok { + if s := stringifyESField(v); s != "" { + return s + } + } + } + } + // 顶层 log 为字符串时(部分旧索引) + if v, ok := src["log"]; ok { + if s := stringifyESField(v); s != "" { + return s } } b, _ := json.Marshal(src) diff --git a/backend/controller/log_proxy_explore.go b/backend/controller/log_proxy_explore.go new file mode 100644 index 0000000..288b039 --- /dev/null +++ b/backend/controller/log_proxy_explore.go @@ -0,0 +1,480 @@ +package controller + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "ezops/middleware" + "ezops/model" + "ezops/pkg/logaccess" + + "github.com/gin-gonic/gin" +) + +// 探索类 API(标签浏览 / 直方图)与 log_proxy.go 查询代理拆分 // Logan.Li + +// ListLokiLabels GET /api/v1/logs/loki/labels?datasource_id=&start=&end= +func (h *LogProxyHandler) ListLokiLabels(c *gin.Context) { + dsID, _ := strconv.ParseUint(c.Query("datasource_id"), 10, 64) + if dsID == 0 { + c.JSON(400, model.Response{Code: 400, Message: "datasource_id 必填"}) + return + } + ds, err := loadLogDS(h.ds, uint(dsID)) + if err != nil || ds.Type != "loki" { + c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"}) + return + } + start, end := parseLogTimeRange(c.Query("start"), c.Query("end")) + params := url.Values{} + params.Set("start", fmt.Sprintf("%d", start.UnixNano())) + params.Set("end", fmt.Sprintf("%d", end.UnixNano())) + client := newLogHTTPClient(ds.SkipTLSVerify, 15*time.Second) + target := strings.TrimRight(ds.URL, "/") + "/loki/api/v1/labels?" + params.Encode() + httpReq, _ := http.NewRequest(http.MethodGet, target, nil) + applyLogDSAuthV2(httpReq, ds) + resp, err := client.Do(httpReq) + if err != nil { + c.JSON(502, model.Response{Code: 502, Message: "查询标签失败: " + err.Error()}) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + c.JSON(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "Loki 错误: " + string(body)}) + return + } + var raw map[string]interface{} + _ = json.Unmarshal(body, &raw) + labels := extractStringSlice(raw["data"]) + c.JSON(200, model.Response{Code: 200, Data: labels}) +} + +// ListLokiLabelValues GET /api/v1/logs/loki/labels/:name/values +func (h *LogProxyHandler) ListLokiLabelValues(c *gin.Context) { + name := c.Param("name") + dsID, _ := strconv.ParseUint(c.Query("datasource_id"), 10, 64) + if dsID == 0 || name == "" { + c.JSON(400, model.Response{Code: 400, Message: "datasource_id 与 label 名必填"}) + return + } + ds, err := loadLogDS(h.ds, uint(dsID)) + if err != nil || ds.Type != "loki" { + c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"}) + return + } + start, end := parseLogTimeRange(c.Query("start"), c.Query("end")) + params := url.Values{} + params.Set("start", fmt.Sprintf("%d", start.UnixNano())) + params.Set("end", fmt.Sprintf("%d", end.UnixNano())) + if q := extractLokiStreamSelector(strings.TrimSpace(c.Query("query"))); q != "" { + params.Set("query", q) + } + client := newLogHTTPClient(ds.SkipTLSVerify, 15*time.Second) + target := fmt.Sprintf("%s/loki/api/v1/label/%s/values?%s", strings.TrimRight(ds.URL, "/"), url.PathEscape(name), params.Encode()) + httpReq, _ := http.NewRequest(http.MethodGet, target, nil) + applyLogDSAuthV2(httpReq, ds) + resp, err := client.Do(httpReq) + if err != nil { + c.JSON(502, model.Response{Code: 502, Message: "查询标签值失败: " + err.Error()}) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + c.JSON(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "Loki 错误: " + string(body)}) + return + } + var raw map[string]interface{} + _ = json.Unmarshal(body, &raw) + values := extractStringSlice(raw["data"]) + c.JSON(200, model.Response{Code: 200, Data: values}) +} + +// LokiHistogram POST /api/v1/logs/loki/histogram — 服务端日志量直方图 // Logan.Li +func (h *LogProxyHandler) LokiHistogram(c *gin.Context) { + var req struct { + DatasourceID uint `json:"datasource_id" binding:"required"` + Query string `json:"query" binding:"required"` + Start string `json:"start"` + End string `json:"end"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, model.Response{Code: 400, Message: err.Error()}) + return + } + allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{ + Engine: "loki", DatasourceID: req.DatasourceID, Query: req.Query, + }) + if !allowed { + c.JSON(403, model.Response{Code: 403, Message: denyMsg}) + return + } + ds, err := loadLogDS(h.ds, req.DatasourceID) + if err != nil || ds.Type != "loki" { + c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"}) + return + } + start, end := parseLogTimeRange(req.Start, req.End) + step := pickHistogramStep(end.Sub(start)) + metricQuery := fmt.Sprintf("sum(count_over_time(%s[%s]))", req.Query, step) + params := url.Values{} + params.Set("query", metricQuery) + params.Set("start", fmt.Sprintf("%d", start.UnixNano())) + params.Set("end", fmt.Sprintf("%d", end.UnixNano())) + params.Set("step", step) + client := newLogHTTPClient(ds.SkipTLSVerify, 30*time.Second) + target := strings.TrimRight(ds.URL, "/") + "/loki/api/v1/query_range?" + params.Encode() + httpReq, _ := http.NewRequest(http.MethodGet, target, nil) + applyLogDSAuthV2(httpReq, ds) + resp, err := client.Do(httpReq) + if err != nil { + c.JSON(502, model.Response{Code: 502, Message: "直方图查询失败: " + err.Error()}) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + c.JSON(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "Loki 错误: " + string(body)}) + return + } + var raw map[string]interface{} + _ = json.Unmarshal(body, &raw) + buckets := flattenLokiMetricSeries(raw) + c.JSON(200, model.Response{Code: 200, Data: gin.H{"buckets": buckets}}) +} + +// ESHistogram POST /api/v1/logs/elasticsearch/histogram — ES date_histogram 聚合 // Logan.Li +func (h *LogProxyHandler) ESHistogram(c *gin.Context) { + var req struct { + DatasourceID uint `json:"datasource_id" binding:"required"` + Index string `json:"index"` + Query string `json:"query"` + Start string `json:"start"` + End string `json:"end"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, model.Response{Code: 400, Message: err.Error()}) + return + } + index := req.Index + if index == "" { + if dsPreview, err := loadLogDS(h.ds, req.DatasourceID); err == nil { + index = dsPreview.DefaultIndex + } + } + if index == "" { + index = "*" + } + allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{ + Engine: "elasticsearch", DatasourceID: req.DatasourceID, Index: index, Query: req.Query, + }) + if !allowed { + c.JSON(403, model.Response{Code: 403, Message: denyMsg}) + return + } + ds, err := loadLogDS(h.ds, req.DatasourceID) + if err != nil || ds.Type != "elasticsearch" { + c.JSON(400, model.Response{Code: 400, Message: "Elasticsearch 数据源无效"}) + return + } + start, end := parseLogTimeRange(req.Start, req.End) + interval := pickHistogramStep(end.Sub(start)) + searchBody := buildESHistogramBody(req.Query, start, end, interval) + payload, _ := json.Marshal(searchBody) + client := newLogHTTPClient(ds.SkipTLSVerify, 30*time.Second) + target := fmt.Sprintf("%s/%s/_search", strings.TrimRight(ds.URL, "/"), strings.Trim(index, "/")) + httpReq, _ := http.NewRequest(http.MethodPost, target, bytes.NewReader(payload)) + httpReq.Header.Set("Content-Type", "application/json") + applyLogDSAuthV2(httpReq, ds) + resp, err := client.Do(httpReq) + if err != nil { + c.JSON(502, model.Response{Code: 502, Message: "直方图查询失败: " + err.Error()}) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + c.JSON(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "ES 错误: " + string(body)}) + return + } + var raw map[string]interface{} + _ = json.Unmarshal(body, &raw) + buckets := flattenESHistogram(raw) + c.JSON(200, model.Response{Code: 200, Data: gin.H{"buckets": buckets}}) +} + +func parseLogTimeRange(startStr, endStr string) (time.Time, time.Time) { + end := time.Now() + start := end.Add(-1 * time.Hour) + if endStr != "" { + if t, e := parseLogTime(endStr); e == nil { + end = t + } + } + if startStr != "" { + if t, e := parseLogTime(startStr); e == nil { + start = t + } + } + return start, end +} + +func pickHistogramStep(d time.Duration) string { + switch { + case d <= 15*time.Minute: + return "30s" + case d <= time.Hour: + return "1m" + case d <= 6*time.Hour: + return "5m" + case d <= 24*time.Hour: + return "15m" + case d <= 7*24*time.Hour: + return "1h" + default: + return "6h" + } +} + +func extractStringSlice(v interface{}) []string { + arr, _ := v.([]interface{}) + out := make([]string, 0, len(arr)) + for _, item := range arr { + out = append(out, fmt.Sprintf("%v", item)) + } + return out +} + +func flattenLokiMetricSeries(raw map[string]interface{}) []map[string]interface{} { + out := []map[string]interface{}{} + data, _ := raw["data"].(map[string]interface{}) + result, _ := data["result"].([]interface{}) + for _, item := range result { + series, _ := item.(map[string]interface{}) + values, _ := series["values"].([]interface{}) + for _, val := range values { + pair, _ := val.([]interface{}) + if len(pair) < 2 { + continue + } + ts := formatLokiMetricTimestamp(pair[0]) + cnt, _ := strconv.ParseFloat(fmt.Sprintf("%v", pair[1]), 64) + out = append(out, map[string]interface{}{ + "timestamp": ts, + "count": int64(cnt), + }) + } + } + return out +} + +func buildESHistogramBody(query string, start, end time.Time, interval string) map[string]interface{} { + body := map[string]interface{}{ + "size": 0, + "query": map[string]interface{}{ + "bool": map[string]interface{}{ + "must": []interface{}{ + map[string]interface{}{"query_string": map[string]interface{}{"query": defaultESQuery(query)}}, + }, + }, + }, + "aggs": map[string]interface{}{ + "log_volume": map[string]interface{}{ + "date_histogram": map[string]interface{}{ + "field": "@timestamp", + "fixed_interval": interval, + "min_doc_count": 0, + }, + }, + }, + } + rangeCond := map[string]interface{}{ + "bool": map[string]interface{}{ + "should": []interface{}{ + map[string]interface{}{"range": map[string]interface{}{"@timestamp": map[string]string{ + "gte": start.UTC().Format(time.RFC3339Nano), + "lte": end.UTC().Format(time.RFC3339Nano), + }}}, + map[string]interface{}{"range": map[string]interface{}{"timestamp": map[string]string{ + "gte": start.UTC().Format(time.RFC3339Nano), + "lte": end.UTC().Format(time.RFC3339Nano), + }}}, + }, + "minimum_should_match": 1, + }, + } + body["query"] = map[string]interface{}{ + "bool": map[string]interface{}{ + "must": []interface{}{body["query"], rangeCond}, + }, + } + return body +} + +func flattenESHistogram(raw map[string]interface{}) []map[string]interface{} { + out := []map[string]interface{}{} + aggs, _ := raw["aggregations"].(map[string]interface{}) + vol, _ := aggs["log_volume"].(map[string]interface{}) + buckets, _ := vol["buckets"].([]interface{}) + for _, b := range buckets { + bucket, _ := b.(map[string]interface{}) + ts, _ := bucket["key_as_string"].(string) + if ts == "" { + if key, ok := bucket["key"].(float64); ok { + ts = time.UnixMilli(int64(key)).UTC().Format(time.RFC3339Nano) + } + } + cnt, _ := bucket["doc_count"].(float64) + out = append(out, map[string]interface{}{"timestamp": ts, "count": int64(cnt)}) + } + return out +} + +// ESMapping GET /api/v1/logs/elasticsearch/mapping?datasource_id=&index= +func (h *LogProxyHandler) ESMapping(c *gin.Context) { + dsID, _ := strconv.ParseUint(c.Query("datasource_id"), 10, 64) + index := strings.TrimSpace(c.Query("index")) + if dsID == 0 { + c.JSON(400, model.Response{Code: 400, Message: "datasource_id 必填"}) + return + } + if index == "" { + index = "*" + } + allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{ + Engine: "elasticsearch", DatasourceID: uint(dsID), Index: index, Query: "*", + }) + if !allowed { + c.JSON(403, model.Response{Code: 403, Message: denyMsg}) + return + } + ds, err := loadLogDS(h.ds, uint(dsID)) + if err != nil || ds.Type != "elasticsearch" { + c.JSON(400, model.Response{Code: 400, Message: "Elasticsearch 数据源无效"}) + return + } + client := newLogHTTPClient(ds.SkipTLSVerify, 20*time.Second) + target := fmt.Sprintf("%s/%s/_mapping", strings.TrimRight(ds.URL, "/"), strings.Trim(index, "/")) + httpReq, _ := http.NewRequest(http.MethodGet, target, nil) + applyLogDSAuthV2(httpReq, ds) + resp, err := client.Do(httpReq) + if err != nil { + c.JSON(502, model.Response{Code: 502, Message: "获取 mapping 失败: " + err.Error()}) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + c.JSON(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "ES 错误: " + string(body)}) + return + } + var raw map[string]interface{} + _ = json.Unmarshal(body, &raw) + fields := flattenESMapping(raw) + c.JSON(200, model.Response{Code: 200, Data: fields}) +} + +func flattenESMapping(raw map[string]interface{}) []map[string]string { + out := []map[string]string{} + seen := map[string]bool{} + var walk func(prefix string, props map[string]interface{}) + walk = func(prefix string, props map[string]interface{}) { + for name, node := range props { + full := name + if prefix != "" { + full = prefix + "." + name + } + m, _ := node.(map[string]interface{}) + if m == nil { + continue + } + if sub, ok := m["properties"].(map[string]interface{}); ok { + walk(full, sub) + continue + } + if fields, ok := m["fields"].(map[string]interface{}); ok { + walk(full, fields) + } + typ, _ := m["type"].(string) + if typ == "" { + typ = "object" + } + if !seen[full] { + seen[full] = true + out = append(out, map[string]string{"name": full, "type": mapESFieldType(typ)}) + } + } + } + for _, idxDef := range raw { + idxMap, _ := idxDef.(map[string]interface{}) + mappings, _ := idxMap["mappings"].(map[string]interface{}) + props, _ := mappings["properties"].(map[string]interface{}) + if props != nil { + walk("", props) + } + } + return out +} + +func mapESFieldType(esType string) string { + switch esType { + case "keyword", "text", "match_only_text": + return "keyword" + case "long", "integer", "short", "byte", "double", "float", "half_float", "scaled_float": + return "long" + case "date", "date_nanos": + return "date" + case "boolean": + return "boolean" + case "ip": + return "ip" + default: + return "text" + } +} + +// extractLokiStreamSelector 从 LogQL 提取 {...} 选择器(Label API 仅接受 stream selector)// Logan.Li +func extractLokiStreamSelector(query string) string { + query = strings.TrimSpace(query) + start := strings.Index(query, "{") + if start < 0 { + return "" + } + depth := 0 + for i := start; i < len(query); i++ { + switch query[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return query[start : i+1] + } + } + } + return "" +} + +// formatLokiMetricTimestamp 解析 Loki metric 序列时间戳(秒级 float / 科学计数法)// Logan.Li +func formatLokiMetricTimestamp(v interface{}) string { + switch t := v.(type) { + case float64: + sec := int64(t) + nsec := int64((t - float64(sec)) * 1e9) + return time.Unix(sec, nsec).UTC().Format(time.RFC3339) + case json.Number: + if f, err := t.Float64(); err == nil { + sec := int64(f) + return time.Unix(sec, 0).UTC().Format(time.RFC3339) + } + } + return formatLokiTimestamp(fmt.Sprintf("%v", v)) +} \ No newline at end of file diff --git a/backend/controller/log_scope_role.go b/backend/controller/log_scope_role.go new file mode 100644 index 0000000..77b711c --- /dev/null +++ b/backend/controller/log_scope_role.go @@ -0,0 +1,201 @@ +package controller + +import ( + "errors" + "net/http" + "regexp" + "strconv" + "strings" + + "ezops/model" + "ezops/pkg/logaccess" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// LogScopeRoleHandler 日志查询权限 // Logan.Li +type LogScopeRoleHandler struct { + db *gorm.DB +} + +func NewLogScopeRoleHandler(db *gorm.DB) *LogScopeRoleHandler { + return &LogScopeRoleHandler{db: db} +} + +// ListLogScopeRoles GET /log-scope-roles +func (h *LogScopeRoleHandler) ListLogScopeRoles(c *gin.Context) { + var roles []model.LogScopeRole + h.db.Order("created_at DESC").Find(&roles) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: roles, Total: int64(len(roles))}}) +} + +// CreateLogScopeRole POST /log-scope-roles +func (h *LogScopeRoleHandler) CreateLogScopeRole(c *gin.Context) { + var req model.CreateLogScopeRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) + return + } + if _, err := regexp.Compile(req.Pattern); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "正则表达式无效: " + err.Error()}) + return + } + engine := strings.TrimSpace(req.Engine) + if engine == "" { + engine = "*" + } + role := req.Role + if role == "" { + role = "viewer" + } + var dup int64 + h.db.Model(&model.LogScopeRole{}).Where("name = ? OR code = ?", req.Name, req.Code).Count(&dup) + if dup > 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "名称或编码已存在"}) + return + } + item := model.LogScopeRole{ + Name: req.Name, Code: req.Code, Engine: engine, MatchField: req.MatchField, + Pattern: req.Pattern, DatasourceID: req.DatasourceID, Role: role, + Description: req.Description, Status: 1, + } + if err := h.db.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败"}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "创建成功", Data: item}) +} + +// UpdateLogScopeRole PUT /log-scope-roles/:id +func (h *LogScopeRoleHandler) UpdateLogScopeRole(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var item model.LogScopeRole + if err := h.db.First(&item, id).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "策略不存在"}) + return + } + var req model.UpdateLogScopeRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) + return + } + updates := map[string]interface{}{} + if req.Name != "" { + updates["name"] = req.Name + } + if req.Code != "" { + updates["code"] = req.Code + } + if req.Engine != "" { + updates["engine"] = req.Engine + } + if req.MatchField != "" { + updates["match_field"] = req.MatchField + } + if req.Pattern != "" { + if _, err := regexp.Compile(req.Pattern); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "正则无效"}) + return + } + updates["pattern"] = req.Pattern + } + if req.DatasourceID != nil { + updates["datasource_id"] = req.DatasourceID + } + if req.Role != "" { + updates["role"] = req.Role + } + if req.Description != "" { + updates["description"] = req.Description + } + if req.Status != nil { + updates["status"] = *req.Status + } + if len(updates) > 0 { + h.db.Model(&item).Updates(updates) + } + h.db.First(&item, id) + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "更新成功", Data: item}) +} + +// DeleteLogScopeRole DELETE /log-scope-roles/:id +func (h *LogScopeRoleHandler) DeleteLogScopeRole(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + h.db.Where("log_scope_role_id = ?", id).Delete(&model.UserLogScopeRole{}) + if err := h.db.Delete(&model.LogScopeRole{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "删除失败"}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"}) +} + +// ListUserLogScopeRoles GET /log-scope-roles/users/:id +func (h *LogScopeRoleHandler) ListUserLogScopeRoles(c *gin.Context) { + uid, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var rows []model.UserLogScopeRole + h.db.Preload("LogScopeRole").Where("user_id = ?", uid).Find(&rows) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: rows, Total: int64(len(rows))}}) +} + +// AssignLogScopeRole POST /log-scope-roles/users/:id +func (h *LogScopeRoleHandler) AssignLogScopeRole(c *gin.Context) { + uid, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || uid == 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的用户 ID"}) + return + } + var req model.AssignLogScopeRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + var role model.LogScopeRole + if err := h.db.First(&role, req.LogScopeRoleID).Error; err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "策略不存在"}) + return + } + var existing model.UserLogScopeRole + if err := h.db.Where("user_id = ? AND log_scope_role_id = ?", uid, req.LogScopeRoleID).Take(&existing).Error; err == nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "已分配该策略"}) + return + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询失败: " + err.Error()}) + return + } + row := model.UserLogScopeRole{UserID: uint(uid), LogScopeRoleID: req.LogScopeRoleID} + if err := h.db.Create(&row).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "分配失败: " + err.Error()}) + return + } + h.db.Preload("LogScopeRole").Take(&row, row.ID) + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "分配成功", Data: row}) +} + +// RemoveLogScopeRole DELETE /log-scope-roles/users/:userId/:assignId +func (h *LogScopeRoleHandler) RemoveLogScopeRole(c *gin.Context) { + assignID, _ := strconv.ParseUint(c.Param("assignId"), 10, 64) + h.db.Delete(&model.UserLogScopeRole{}, assignID) + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已撤销"}) +} + +// PreviewLogScope POST /log-scope-roles/preview +func (h *LogScopeRoleHandler) PreviewLogScope(c *gin.Context) { + var req model.PreviewLogScopeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) + return + } + var roles []model.LogScopeRole + h.db.Where("status = 1").Find(&roles) + qc := logaccess.QueryContext{ + Engine: req.Engine, DatasourceID: req.DatasourceID, + Index: req.Index, Query: req.Query, + } + matched := logaccess.MatchRolesForPreview(roles, qc, req.SampleValue) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ + "allowed": len(matched) > 0, + "matched": matched, + "total": len(matched), + }}) +} \ No newline at end of file diff --git a/backend/controller/pipeline.go b/backend/controller/pipeline.go index b508d9f..1d526c0 100644 --- a/backend/controller/pipeline.go +++ b/backend/controller/pipeline.go @@ -61,7 +61,9 @@ type PipelineHandler struct { // NewPipelineHandler 创建流水线处理器 func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache, ti *toolinstaller.ToolInstaller) *PipelineHandler { - return &PipelineHandler{db: db, cache: cache, toolInstaller: ti} + h := &PipelineHandler{db: db, cache: cache, toolInstaller: ti} + h.ensureBuildMasterConfig() + return h } // PipelineListItem 列表项(含最近一次运行摘要)// Logan.Li @@ -221,9 +223,13 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) { Variables: req.Variables, DeployMode: req.DeployMode, DeployTarget: req.DeployTarget, - ServerIDs: req.ServerIDs, - K8sClusterID: req.K8sClusterID, - K8sNamespace: req.K8sNamespace, + ServerIDs: req.ServerIDs, + DeployRemotePath: req.DeployRemotePath, + DeployScript: req.DeployScript, + K8sClusterID: req.K8sClusterID, + K8sNamespace: req.K8sNamespace, + K8sDeployment: req.K8sDeployment, + K8sContainerName: req.K8sContainerName, RepoID: req.RepoID, RegistryID: req.RegistryID, GitCredentialID: req.GitCredentialID, @@ -239,6 +245,7 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) { CachePaths: req.CachePaths, // 并发控制 MaxExecutors: req.MaxExecutors, + BuildNodeID: req.BuildNodeID, Enabled: true, } @@ -306,7 +313,11 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) { if req.DeployMode != "" { updates["deploy_mode"] = req.DeployMode } if req.DeployTarget != "" { updates["deploy_target"] = req.DeployTarget } if req.ServerIDs != "" { updates["server_ids"] = req.ServerIDs } + if req.DeployRemotePath != "" { updates["deploy_remote_path"] = req.DeployRemotePath } + if req.DeployScript != "" { updates["deploy_script"] = req.DeployScript } if req.K8sNamespace != "" { updates["k8s_namespace"] = req.K8sNamespace } + if req.K8sDeployment != "" { updates["k8s_deployment"] = req.K8sDeployment } + if req.K8sContainerName != "" { updates["k8s_container_name"] = req.K8sContainerName } if req.K8sClusterID != nil { updates["k8s_cluster_id"] = req.K8sClusterID } if req.RepoID != nil { updates["repo_id"] = req.RepoID } if req.RegistryID != nil { updates["registry_id"] = req.RegistryID } @@ -321,6 +332,7 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) { if req.CachePaths != "" { updates["cache_paths"] = req.CachePaths } if req.Enabled != nil { updates["enabled"] = req.Enabled } if req.MaxExecutors > 0 { updates["max_executors"] = req.MaxExecutors } + if req.BuildNodeID != nil { updates["build_node_id"] = req.BuildNodeID } if err := h.db.Model(&pipeline).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{ @@ -543,7 +555,7 @@ func (h *PipelineHandler) WebhookTrigger(c *gin.Context) { return } - go h.executePipeline(pipeline, run) + go h.tryExecuteOrQueue(pipeline, run) c.JSON(http.StatusOK, model.Response{ Code: 200, diff --git a/backend/controller/pipeline_executor.go b/backend/controller/pipeline_executor.go index 327de8f..57b2d74 100644 --- a/backend/controller/pipeline_executor.go +++ b/backend/controller/pipeline_executor.go @@ -6,11 +6,13 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "sync" "time" "ezops/model" + "ezops/pkg/remoteexec" "go.uber.org/zap" ) @@ -38,8 +40,9 @@ func init() { logger, _ = zap.NewProduction() } -// executePipeline 后台执行流水线(goroutine)— V2 Redis 版 -func (h *PipelineHandler) executePipeline(pipeline model.Pipeline, run model.PipelineRun) { +// executePipeline 后台执行流水线(goroutine)— V2 Redis 版,支持 Master/SSH 构建节点 // Logan.Li +func (h *PipelineHandler) executePipeline(pipeline model.Pipeline, run model.PipelineRun, nodeID uint) { + defer h.releaseBuildNode(nodeID) runID := run.ID ctx := context.Background() logKey := fmt.Sprintf("pipeline:run:%d:logs", runID) @@ -129,15 +132,47 @@ func (h *PipelineHandler) executePipeline(pipeline model.Pipeline, run model.Pip } ts := time.Now() tsDir := fmt.Sprintf("%d%02d%02d%02d%02d", ts.Year(), ts.Month(), ts.Day(), ts.Hour(), ts.Minute()) - workspace := fmt.Sprintf("/app/workspace/%s-%s", repoName, pipeline.Code) + localWorkspace := fmt.Sprintf("/app/workspace/%s-%s", repoName, pipeline.Code) + workspace := localWorkspace - os.RemoveAll(workspace) - os.MkdirAll(workspace, 0755) - // 保存 workspace 路径到运行记录,方便终端查看 - h.db.Model(&model.PipelineRun{}).Where("id = ?", runID).Update("workspace_path", workspace) + execCtx, err := h.buildExecCtx(nodeID, workspace) + if err != nil { + log("error", "init", fmt.Sprintf("构建执行上下文失败: %v", err)) + updateStatus("failed", 0) + updatePipelineStats("failed") + h.dequeueNext(pipeline.ID) + h.flushLogsToDB(ctx, runID, logKey) + return + } + if nodeID > 0 { + var bn model.BuildNode + if h.db.First(&bn, nodeID).Error == nil { + workspace = remoteWorkspacePath(bn, repoName, pipeline.Code) + } + } + execCtx.Workspace = workspace + + if err := h.prepareWorkspace(execCtx, log); err != nil { + log("error", "init", fmt.Sprintf("初始化工作区失败: %v", err)) + updateStatus("failed", 0) + updatePipelineStats("failed") + h.dequeueNext(pipeline.ID) + h.flushLogsToDB(ctx, runID, logKey) + return + } + + runUpdates := map[string]interface{}{ + "workspace_path": workspace, + "build_node_name": execCtx.NodeName, + } + if nodeID > 0 { + runUpdates["build_node_id"] = nodeID + } + h.db.Model(&model.PipelineRun{}).Where("id = ?", runID).Updates(runUpdates) imageTag := fmt.Sprintf("%s-%s", repoName, tsDir) + log("info", "init", fmt.Sprintf("执行节点: %s", execCtx.NodeName)) log("info", "init", fmt.Sprintf("触发方式: 手动 (admin)")) log("info", "init", fmt.Sprintf("分支: %s | 镜像标签: %s", branch, imageTag)) @@ -159,17 +194,31 @@ func (h *PipelineHandler) executePipeline(pipeline model.Pipeline, run model.Pip var err error switch stage.Type { case "checkout": - err = h.runCheckout(gitURL, branch, workspace, log) + err = h.runCheckout(execCtx, gitURL, branch, log) case "build": - err = h.runBuild(workspace, pipeline.Dockerfile, log) + err = h.runBuild(execCtx, pipeline.Dockerfile, log) case "test": - err = h.runTest(workspace, log) + err = h.runTest(execCtx, log) case "image": - err = h.runDockerBuild(workspace, pipeline, imageTag, log) + err = h.runDockerBuild(execCtx, pipeline, imageTag, log) case "deploy": - err = h.runDeploy(workspace, pipeline, imageTag, log) + deployWS := execCtx.Workspace + if execCtx.Remote { + if syncErr := h.syncRemoteWorkspaceToLocal(execCtx, localWorkspace, log); syncErr != nil { + err = syncErr + } else { + deployWS = localWorkspace + } + } + if err == nil { + err = h.runDeploy(deployWS, pipeline, imageTag, log) + } case "verify": - err = h.runVerify(workspace, log) + verifyWS := execCtx.Workspace + if execCtx.Remote { + verifyWS = localWorkspace + } + err = h.runVerify(verifyWS, log) default: log("info", stage.Name, "跳过未知阶段类型: "+stage.Type) } @@ -214,25 +263,26 @@ func (h *PipelineHandler) flushLogsToDB(ctx context.Context, runID uint, logKey h.cache.Del(ctx, logKey) } -// runCheckout git clone -func (h *PipelineHandler) runCheckout(url, branch, workspace string, logFn func(string, string, string)) error { +// runCheckout git clone(Master 或远程构建节点) // Logan.Li +func (h *PipelineHandler) runCheckout(execCtx *PipelineExecCtx, url, branch string, logFn func(string, string, string)) error { if url == "" { logFn("warn", "代码检出", "没有配置 Git URL,跳过代码检出") return nil } logFn("info", "代码检出", "> Git 拉取 "+url+" (分支: "+branch+")") - // U4: 确保工具可用(slim 镜像模式下 git 为懒加载)// Logan.Li - if h.toolInstaller != nil { + if !execCtx.Remote && h.toolInstaller != nil { if err := h.toolInstaller.EnsureAvailable("git"); err != nil { return fmt.Errorf("git 不可用(安装失败): %v", err) } } - cmd := exec.Command("git", "clone", "--depth", "1", "-b", branch, url, workspace) - output, err := cmd.CombinedOutput() - // 流式输出到日志 - for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + cloneCmd := fmt.Sprintf( + "git clone --depth 1 -b %s %s %s", + shellQuotePath(branch), shellQuotePath(url), shellQuotePath(execCtx.Workspace), + ) + output, err := h.execOnCtx(execCtx, "", cloneCmd, 15*time.Minute) + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { if line != "" { logFn("info", "代码检出", line) } @@ -240,19 +290,19 @@ func (h *PipelineHandler) runCheckout(url, branch, workspace string, logFn func( if err != nil { return fmt.Errorf("git clone 失败: %v", err) } - hashCmd := exec.Command("git", "-C", workspace, "rev-parse", "--short", "HEAD") - hashOut, _ := hashCmd.Output() - logFn("success", "代码检出", fmt.Sprintf("已拉取 commit: %s", strings.TrimSpace(string(hashOut)))) + hashOut, _ := h.execOnCtx(execCtx, execCtx.Workspace, "git rev-parse --short HEAD", 30*time.Second) + logFn("success", "代码检出", fmt.Sprintf("已拉取 commit: %s", strings.TrimSpace(hashOut))) return nil } -// runBuild 构建(自动检测 Maven / npm / Go / Makefile) -func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(string, string, string)) error { +// runBuild 构建(自动检测 Maven / npm / Go / Makefile,支持远程节点) // Logan.Li +func (h *PipelineHandler) runBuild(execCtx *PipelineExecCtx, dockerfile string, logFn func(string, string, string)) error { + workspace := execCtx.Workspace buildDir := workspace if dockerfile != "" { if dir := dotdot(dockerfile); dir != "." && dir != "" { testDir := workspace + "/" + dir - if _, err := os.Stat(testDir); err == nil { + if h.pathExists(execCtx, testDir) { buildDir = testDir logFn("info", "构建", ">>> 切换到构建目录: "+dir) } @@ -260,30 +310,48 @@ func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(stri } // 如果根目录没找到构建文件,扫描一级子目录 - if !hasBuildFile(buildDir) { + if !h.hasBuildFileCtx(execCtx, buildDir) { type subMatch struct { dir string kind string // "maven", "npm", "go", "make" } var matches []subMatch - entries, err := os.ReadDir(buildDir) - if err == nil { - for _, e := range entries { - if !e.IsDir() || e.Name() == ".git" { + if execCtx.Remote { + out, _ := h.execOnCtx(execCtx, buildDir, `for d in */; do + [ "$d" = ".git/" ] && continue + [ -f "${d}pom.xml" ] && echo "maven:$d" + [ -f "${d}package.json" ] && echo "npm:$d" + [ -f "${d}go.mod" ] && echo "go:$d" + [ -f "${d}Makefile" ] && echo "make:$d" +done`, 60*time.Second) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if line == "" || !strings.Contains(line, ":") { continue } - sub := buildDir + "/" + e.Name() - if _, err := os.Stat(sub + "/pom.xml"); err == nil { - matches = append(matches, subMatch{sub, "maven"}) - } - if _, err := os.Stat(sub + "/package.json"); err == nil { - matches = append(matches, subMatch{sub, "npm"}) - } - if _, err := os.Stat(sub + "/go.mod"); err == nil { - matches = append(matches, subMatch{sub, "go"}) - } - if _, err := os.Stat(sub + "/Makefile"); err == nil { - matches = append(matches, subMatch{sub, "make"}) + parts := strings.SplitN(line, ":", 2) + sub := strings.TrimSuffix(buildDir+"/"+strings.TrimSuffix(parts[1], "/"), "/") + matches = append(matches, subMatch{sub, parts[0]}) + } + } else { + entries, err := os.ReadDir(buildDir) + if err == nil { + for _, e := range entries { + if !e.IsDir() || e.Name() == ".git" { + continue + } + sub := buildDir + "/" + e.Name() + if _, err := os.Stat(sub + "/pom.xml"); err == nil { + matches = append(matches, subMatch{sub, "maven"}) + } + if _, err := os.Stat(sub + "/package.json"); err == nil { + matches = append(matches, subMatch{sub, "npm"}) + } + if _, err := os.Stat(sub + "/go.mod"); err == nil { + matches = append(matches, subMatch{sub, "go"}) + } + if _, err := os.Stat(sub + "/Makefile"); err == nil { + matches = append(matches, subMatch{sub, "make"}) + } } } } @@ -304,13 +372,19 @@ func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(stri } } - if _, err := os.Stat(buildDir + "/pom.xml"); err == nil { + if h.pathExists(execCtx, buildDir+"/pom.xml") { + if !execCtx.Remote && h.toolInstaller != nil { + if err := h.toolInstaller.EnsureAvailable("jdk"); err != nil { + return fmt.Errorf("JDK 不可用(安装失败): %v", err) + } + if err := h.toolInstaller.EnsureAvailable("maven"); err != nil { + return fmt.Errorf("Maven 不可用(安装失败): %v", err) + } + } logFn("info", "构建", ">>> Maven 构建 (mvn clean package -DskipTests)") logFn("info", "构建", "🔨 正在下载依赖并编译...") - cmd := exec.Command("mvn", "clean", "package", "-DskipTests") - cmd.Dir = buildDir - out, err := cmd.CombinedOutput() - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + out, err := h.execOnCtx(execCtx, buildDir, "mvn clean package -DskipTests", 30*time.Minute) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { if line != "" { logFn("info", "构建", line) } @@ -322,13 +396,16 @@ func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(stri return nil } - if _, err := os.Stat(buildDir + "/package.json"); err == nil { + if h.pathExists(execCtx, buildDir+"/package.json") { + if !execCtx.Remote && h.toolInstaller != nil { + if err := h.toolInstaller.EnsureAvailable("nodejs"); err != nil { + return fmt.Errorf("Node.js 不可用(安装失败): %v", err) + } + } logFn("info", "构建", ">>> npm 构建 (npm install && npm run build)") logFn("info", "构建", "📦 正在安装依赖...") - installCmd := exec.Command("sh", "-c", "npm install --registry=https://registry.npmmirror.com --legacy-peer-deps 2>&1") - installCmd.Dir = buildDir - out, err := installCmd.CombinedOutput() - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + out, err := h.execOnCtx(execCtx, buildDir, "npm install --registry=https://registry.npmmirror.com --legacy-peer-deps 2>&1", 30*time.Minute) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { if line != "" { logFn("info", "构建", line) } @@ -337,10 +414,8 @@ func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(stri return fmt.Errorf("npm install 失败: %v", err) } logFn("info", "构建", "🏗️ 正在打包构建...") - buildCmd := exec.Command("sh", "-c", "npm run build 2>&1") - buildCmd.Dir = buildDir - out, err = buildCmd.CombinedOutput() - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + out, err = h.execOnCtx(execCtx, buildDir, "npm run build 2>&1", 30*time.Minute) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { if line != "" { logFn("info", "构建", line) } @@ -352,12 +427,16 @@ func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(stri return nil } - if _, err := os.Stat(buildDir + "/go.mod"); err == nil { + if h.pathExists(execCtx, buildDir+"/go.mod") { + if !execCtx.Remote && h.toolInstaller != nil { + if err := h.toolInstaller.EnsureAvailable("golang"); err != nil { + return fmt.Errorf("Go 不可用(安装失败): %v", err) + } + } logFn("info", "构建", ">>> Go 构建 (go build ./...)") - cmd := exec.Command("go", "build", "./...") - cmd.Dir = buildDir - out, err := cmd.CombinedOutput() - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + goEnv := "export GOROOT=/usr/local/go PATH=/usr/local/go/bin:$PATH GOPROXY=https://goproxy.cn,direct GOTOOLCHAIN=local && " + out, err := h.execOnCtx(execCtx, buildDir, goEnv+"go build ./...", 30*time.Minute) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { if line != "" { logFn("info", "构建", line) } @@ -383,14 +462,12 @@ func hasBuildFile(dir string) bool { return false } -// runTest 执行测试 -func (h *PipelineHandler) runTest(workspace string, logFn func(string, string, string)) error { - if _, err := os.Stat(workspace + "/pom.xml"); err == nil { +// runTest 执行测试 // Logan.Li +func (h *PipelineHandler) runTest(execCtx *PipelineExecCtx, logFn func(string, string, string)) error { + if h.pathExists(execCtx, execCtx.Workspace+"/pom.xml") { logFn("info", "测试", ">>> Maven 测试 (mvn test)") - cmd := exec.Command("mvn", "test") - cmd.Dir = workspace - out, _ := cmd.CombinedOutput() - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + out, _ := h.execOnCtx(execCtx, execCtx.Workspace, "mvn test", 30*time.Minute) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { if line != "" { logFn("info", "测试", line) } @@ -399,31 +476,15 @@ func (h *PipelineHandler) runTest(workspace string, logFn func(string, string, s return nil } -// runDockerBuild Docker 构建 & 推送 -func (h *PipelineHandler) runDockerBuild(workspace string, pipeline model.Pipeline, tag string, logFn func(string, string, string)) error { +// runDockerBuild Docker 构建 & 推送(支持远程节点,需节点已安装 Docker) // Logan.Li +func (h *PipelineHandler) runDockerBuild(execCtx *PipelineExecCtx, pipeline model.Pipeline, tag string, logFn func(string, string, string)) error { + workspace := execCtx.Workspace dockerfile := pipeline.Dockerfile if dockerfile == "" { dockerfile = "Dockerfile" } - var registryURL, imageName string - if pipeline.RegistryID != nil { - var reg model.PipelineRegistry - if err := h.db.First(®, *pipeline.RegistryID).Error; err == nil { - registryURL = reg.RegistryURL - if reg.Namespace != "" { - imageName = reg.Namespace + "/" + pipeline.Code - } - } - } - if imageName == "" { - imageName = pipeline.Code - } - if registryURL == "" { - registryURL = "registry.example.com" - } - - fullImage := fmt.Sprintf("%s/%s:%s", registryURL, imageName, tag) + fullImage := h.resolvePipelineImage(pipeline, tag) logFn("info", "镜像打包", fmt.Sprintf("镜像地址: %s", fullImage)) buildDir := workspace @@ -437,17 +498,15 @@ func (h *PipelineHandler) runDockerBuild(workspace string, pipeline model.Pipeli logFn("info", "镜像打包", fmt.Sprintf(">>> Docker 构建 (dockerfile: %s)", dfPath)) - // U4: 确保 Docker CLI 可用(slim 镜像模式下 docker-cli 为懒加载)// Logan.Li - if h.toolInstaller != nil { + if !execCtx.Remote && h.toolInstaller != nil { if err := h.toolInstaller.EnsureAvailable("docker-cli"); err != nil { return fmt.Errorf("docker 不可用(安装失败): %v", err) } } - cmd := exec.Command("docker", "build", "-t", fullImage, "-f", dfPath, ".") - cmd.Dir = buildDir - out, err := cmd.CombinedOutput() - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + buildCmd := fmt.Sprintf("docker build -t %s -f %s .", shellQuotePath(fullImage), shellQuotePath(dfPath)) + out, err := h.execOnCtx(execCtx, buildDir, buildCmd, 60*time.Minute) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { if line != "" { logFn("info", "镜像打包", line) } @@ -458,9 +517,8 @@ func (h *PipelineHandler) runDockerBuild(workspace string, pipeline model.Pipeli logFn("success", "镜像打包", "Docker 构建成功") logFn("info", "镜像打包", ">>> 推送镜像: "+fullImage) - pushCmd := exec.Command("docker", "push", fullImage) - pushOut, err := pushCmd.CombinedOutput() - for _, line := range strings.Split(strings.TrimSpace(string(pushOut)), "\n") { + pushOut, err := h.execOnCtx(execCtx, buildDir, "docker push "+shellQuotePath(fullImage), 60*time.Minute) + for _, line := range strings.Split(strings.TrimSpace(pushOut), "\n") { if line != "" { logFn("info", "镜像打包", line) } @@ -472,8 +530,165 @@ func (h *PipelineHandler) runDockerBuild(workspace string, pipeline model.Pipeli return nil } -// runDeploy 部署到 K8s / 服务器 +// runDeploy 部署:K8s 走 update-image CI;SSH 走 SCP + 远程命令 // Logan.Li func (h *PipelineHandler) runDeploy(workspace string, pipeline model.Pipeline, tag string, logFn func(string, string, string)) error { + switch pipeline.DeployTarget { + case "k8s": + return h.deployToK8s(pipeline, tag, logFn) + case "ssh": + return h.deployToSSH(workspace, pipeline, logFn) + default: + return h.deployViaYaml(workspace, logFn) + } +} + +// resolvePipelineImage 解析流水线构建镜像全名(与镜像打包阶段一致) // Logan.Li +func (h *PipelineHandler) resolvePipelineImage(pipeline model.Pipeline, tag string) string { + var registryURL, imageName string + if pipeline.RegistryID != nil { + var reg model.PipelineRegistry + if err := h.db.First(®, *pipeline.RegistryID).Error; err == nil { + registryURL = reg.RegistryURL + if reg.Namespace != "" { + imageName = reg.Namespace + "/" + } + } + } + appName := pipeline.Code + if pipeline.JobName != "" { + appName = pipeline.JobName + } + if imageName == "" { + imageName = appName + } else { + imageName += appName + } + if registryURL == "" { + registryURL = "registry.example.com" + } + return fmt.Sprintf("%s/%s:%s", registryURL, imageName, tag) +} + +// deployToK8s 调用 K8s API 更新 Deployment 镜像(等同 curl CI update-image) // Logan.Li +func (h *PipelineHandler) deployToK8s(pipeline model.Pipeline, tag string, logFn func(string, string, string)) error { + if pipeline.K8sDeployment == "" || pipeline.K8sNamespace == "" { + return fmt.Errorf("未配置 K8s Deployment") + } + if pipeline.K8sClusterID == nil { + return fmt.Errorf("未配置 K8s 集群") + } + var cluster model.KubernetesCluster + if err := h.db.First(&cluster, *pipeline.K8sClusterID).Error; err != nil { + return fmt.Errorf("集群不存在: %v", err) + } + client, err := buildK8sClient(&cluster) + if err != nil { + return err + } + + fullImage := h.resolvePipelineImage(pipeline, tag) + logFn("info", "部署", fmt.Sprintf(">>> CI 更新镜像 %s/%s", pipeline.K8sNamespace, pipeline.K8sDeployment)) + logFn("info", "部署", fmt.Sprintf("镜像: %s", fullImage)) + logFn("info", "部署", fmt.Sprintf("API: POST /kubernetes/%s/namespace/%s/deployments/%s/update-image", + cluster.Code, pipeline.K8sNamespace, pipeline.K8sDeployment)) + + if err := client.UpdateDeploymentImage(pipeline.K8sNamespace, pipeline.K8sDeployment, pipeline.K8sContainerName, fullImage); err != nil { + return fmt.Errorf("更新 Deployment 镜像失败: %v", err) + } + logFn("success", "部署", "Deployment 镜像已更新,滚动发布已触发") + return nil +} + +// deployToSSH 通过 SCP 上传构建产物并在关联服务器上执行部署命令 // Logan.Li +func (h *PipelineHandler) deployToSSH(workspace string, pipeline model.Pipeline, logFn func(string, string, string)) error { + if pipeline.ServerIDs == "" { + return fmt.Errorf("未关联部署主机") + } + var serverIDs []uint + if err := json.Unmarshal([]byte(pipeline.ServerIDs), &serverIDs); err != nil || len(serverIDs) == 0 { + return fmt.Errorf("server_ids 解析失败") + } + + var servers []model.Server + if err := h.db.Where("id IN ?", serverIDs).Find(&servers).Error; err != nil { + return fmt.Errorf("加载服务器失败: %v", err) + } + if len(servers) == 0 { + return fmt.Errorf("关联的服务器不存在") + } + + artifact := findDeployArtifact(workspace) + if artifact == "" { + return fmt.Errorf("工作区未找到可部署产物(jar/tar.gz/zip)") + } + + remoteDir := strings.TrimSpace(pipeline.DeployRemotePath) + if remoteDir == "" { + remoteDir = fmt.Sprintf("/opt/apps/%s", pipeline.Code) + } + remotePath := remoteDir + "/" + filepath.Base(artifact) + + deployScript := strings.TrimSpace(pipeline.DeployScript) + if deployScript == "" { + deployScript = fmt.Sprintf( + "mkdir -p %s && cd %s && (test -x ./restart.sh && ./restart.sh) || (systemctl restart %s 2>/dev/null) || echo '部署完成,请配置 deploy_script'", + remoteDir, remoteDir, pipeline.Code, + ) + } + + configs := make([]remoteexec.SSHConfig, 0, len(servers)) + hostnames := make([]string, 0, len(servers)) + for _, srv := range servers { + cfg, err := buildSSHConfig(h.db, srv) + if err != nil { + logFn("error", "部署", fmt.Sprintf("%s: %v", srv.Hostname, err)) + continue + } + configs = append(configs, cfg) + hostnames = append(hostnames, srv.Hostname) + } + if len(configs) == 0 { + return fmt.Errorf("无可用 SSH 配置,请检查服务器凭据") + } + + logFn("info", "部署", fmt.Sprintf(">>> SCP %s → %s(%s)", filepath.Base(artifact), remotePath, strings.Join(hostnames, ", "))) + uploadResults := remoteexec.BatchUpload(configs, artifact, remotePath, 5) + for _, r := range uploadResults { + if r.Success { + logFn("success", "部署", fmt.Sprintf("✓ %s 上传成功", r.Host)) + } else { + logFn("error", "部署", fmt.Sprintf("✗ %s 上传失败: %s", r.Host, r.Error)) + } + } + for _, r := range uploadResults { + if !r.Success { + return fmt.Errorf("部分主机 SCP 上传失败") + } + } + + logFn("info", "部署", ">>> 远程执行部署命令") + execResults := remoteexec.BatchExec(configs, deployScript, 5*time.Minute, 5) + for _, r := range execResults { + for _, line := range strings.Split(r.Output, "\n") { + if strings.TrimSpace(line) != "" { + logFn("info", "部署", fmt.Sprintf("[%s] %s", r.Host, line)) + } + } + if !r.Success { + errMsg := r.Error + if errMsg == "" { + errMsg = fmt.Sprintf("exit=%d", r.ExitCode) + } + logFn("error", "部署", fmt.Sprintf("✗ %s 部署失败: %s", r.Host, errMsg)) + return fmt.Errorf("主机 %s 部署失败", r.Host) + } + logFn("success", "部署", fmt.Sprintf("✓ %s 部署完成", r.Host)) + } + return nil +} + +// deployViaYaml 兜底:仓库内 K8s YAML kubectl apply // Logan.Li +func (h *PipelineHandler) deployViaYaml(workspace string, logFn func(string, string, string)) error { deployYamls := findFiles(workspace, ".yaml", ".yml") var k8sFiles []string for _, f := range deployYamls { @@ -485,12 +700,10 @@ func (h *PipelineHandler) runDeploy(workspace string, pipeline model.Pipeline, t k8sFiles = append(k8sFiles, f) } } - if len(k8sFiles) == 0 { logFn("info", "部署", "未找到 K8s 部署文件,跳过部署") return nil } - for _, yamlFile := range k8sFiles { logFn("info", "部署", ">>> kubectl apply -f "+yamlFile) cmd := exec.Command("kubectl", "apply", "-f", yamlFile) @@ -501,14 +714,65 @@ func (h *PipelineHandler) runDeploy(workspace string, pipeline model.Pipeline, t } } if err != nil { - logFn("error", "部署", fmt.Sprintf("kubectl apply 失败: %v", err)) - return fmt.Errorf("kubectl apply 失败") + return fmt.Errorf("kubectl apply 失败: %v", err) } } logFn("success", "部署", "部署完成") return nil } +// findDeployArtifact 在工作区查找可 SCP 的构建产物 // Logan.Li +func findDeployArtifact(workspace string) string { + priorityDirs := []string{"target", "build/libs", "dist", "bin"} + priorityExts := []string{".jar", ".tar.gz", ".tgz", ".zip", ".war"} + + var bestPath string + var bestTime time.Time + + checkFile := func(path string) { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return + } + if info.ModTime().After(bestTime) { + bestTime = info.ModTime() + bestPath = path + } + } + + for _, dir := range priorityDirs { + fullDir := filepath.Join(workspace, dir) + entries, err := os.ReadDir(fullDir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + for _, ext := range priorityExts { + if strings.HasSuffix(name, ext) { + checkFile(filepath.Join(fullDir, name)) + break + } + } + } + } + + if bestPath != "" { + return bestPath + } + + // 根目录兜底扫描 + for _, ext := range priorityExts { + for _, f := range findFiles(workspace, ext) { + checkFile(f) + } + } + return bestPath +} + // runVerify 部署后验证 func (h *PipelineHandler) runVerify(workspace string, logFn func(string, string, string)) error { logFn("info", "验证", ">>> 部署后健康检查") @@ -578,27 +842,51 @@ var ( queueMu sync.Mutex ) -// tryExecuteOrQueue 触发流水线时检查并发限制 -// 如果当前运行数 < maxExecutors → 立即执行 -// 否则 → 加入队列 (status=queued) +// tryExecuteOrQueue 触发流水线时检查流水线并发 + 构建节点槽位 // Logan.Li func (h *PipelineHandler) tryExecuteOrQueue(pipeline model.Pipeline, run model.PipelineRun) { max := pipeline.MaxExecutors - if max <= 0 { max = 2 } + if max <= 0 { + max = 2 + } queueMu.Lock() running := pipelineRunningCount[pipeline.ID] - if running < max { - pipelineRunningCount[pipeline.ID] = running + 1 + if running >= max { + pipelineQueues[pipeline.ID] = append(pipelineQueues[pipeline.ID], run.ID) queueMu.Unlock() - // 立即执行 - h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("status", "running") - go h.executePipeline(pipeline, run) + h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("status", "queued") return } - // 加入队列 - pipelineQueues[pipeline.ID] = append(pipelineQueues[pipeline.ID], run.ID) queueMu.Unlock() - h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("status", "queued") + + nodeID, canNode := h.tryAcquireBuildNode(pipeline) + if !canNode { + queueMu.Lock() + pipelineQueues[pipeline.ID] = append(pipelineQueues[pipeline.ID], run.ID) + queueMu.Unlock() + h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("status", "queued") + return + } + + queueMu.Lock() + pipelineRunningCount[pipeline.ID] = running + 1 + queueMu.Unlock() + + h.markRunStarting(run, nodeID) + go h.executePipeline(pipeline, run, nodeID) +} + +// markRunStarting 标记运行开始并记录执行节点 // Logan.Li +func (h *PipelineHandler) markRunStarting(run model.PipelineRun, nodeID uint) { + updates := map[string]interface{}{"status": "running", "build_node_name": "Master"} + if nodeID > 0 { + updates["build_node_id"] = nodeID + var node model.BuildNode + if h.db.First(&node, nodeID).Error == nil { + updates["build_node_name"] = node.Name + } + } + h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates) } // dequeueNext 流水线运行结束后,从队列中取下一个执行 @@ -625,8 +913,19 @@ func (h *PipelineHandler) dequeueNext(pipelineID uint) { var run model.PipelineRun if err := h.db.First(&run, nextRunID).Error; err != nil { return } - h.db.Model(&run).Update("status", "running") - go h.executePipeline(pipeline, run) + nodeID, canNode := h.tryAcquireBuildNode(pipeline) + if !canNode { + queueMu.Lock() + pipelineQueues[pipelineID] = append([]uint{nextRunID}, pipelineQueues[pipelineID]...) + if pipelineRunningCount[pipelineID] > 0 { + pipelineRunningCount[pipelineID]-- + } + queueMu.Unlock() + return + } + + h.markRunStarting(run, nodeID) + go h.executePipeline(pipeline, run, nodeID) } // GetQueueStatus 查询流水线队列状态(供 API 使用) diff --git a/backend/controller/pipeline_node.go b/backend/controller/pipeline_node.go new file mode 100644 index 0000000..cab3247 --- /dev/null +++ b/backend/controller/pipeline_node.go @@ -0,0 +1,263 @@ +package controller + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "ezops/model" + "ezops/pkg/remoteexec" +) + +// Master 内置节点 ID(0 表示在本机执行) // Logan.Li +const masterNodeID uint = 0 + +var ( + nodeRunningCount = make(map[uint]int) + masterMaxExecutors = 2 + nodeMu sync.Mutex +) + +// ensureBuildMasterConfig 确保 Master 配置行存在并加载到内存 // Logan.Li +func (h *PipelineHandler) ensureBuildMasterConfig() model.BuildMasterConfig { + var cfg model.BuildMasterConfig + if err := h.db.First(&cfg).Error; err != nil { + cfg = model.BuildMasterConfig{MaxExecutors: 2} + h.db.Create(&cfg) + } + if cfg.MaxExecutors <= 0 { + cfg.MaxExecutors = 2 + } + nodeMu.Lock() + masterMaxExecutors = cfg.MaxExecutors + nodeMu.Unlock() + return cfg +} + +// setMasterMaxExecutors 更新 Master 并发上限(内存 + DB) // Logan.Li +func (h *PipelineHandler) setMasterMaxExecutors(n int) model.BuildMasterConfig { + if n <= 0 { + n = 1 + } + cfg := h.ensureBuildMasterConfig() + cfg.MaxExecutors = n + h.db.Model(&cfg).Update("max_executors", n) + nodeMu.Lock() + masterMaxExecutors = n + nodeMu.Unlock() + return cfg +} + +// PipelineExecCtx 流水线执行上下文(Master 或远程 SSH 节点) // Logan.Li +type PipelineExecCtx struct { + Remote bool + SSHCfg *remoteexec.SSHConfig + Workspace string + NodeID uint + NodeName string +} + +// tryAcquireBuildNode 尝试占用构建节点执行槽位 // Logan.Li +func (h *PipelineHandler) tryAcquireBuildNode(pipeline model.Pipeline) (uint, bool) { + nodeMu.Lock() + defer nodeMu.Unlock() + + // 指定节点 + if pipeline.BuildNodeID != nil && *pipeline.BuildNodeID > 0 { + id := *pipeline.BuildNodeID + var node model.BuildNode + if h.db.First(&node, id).Error != nil || !node.Enabled { + return h.acquireMasterLocked() + } + max := node.MaxExecutors + if max <= 0 { + max = 1 + } + if nodeRunningCount[id] < max { + nodeRunningCount[id]++ + return id, true + } + return id, false + } + + // 自动调度:Master 优先,其次负载最低的远程节点 + if id, ok := h.acquireMasterLocked(); ok { + return id, true + } + + var nodes []model.BuildNode + h.db.Where("enabled = ?", true).Order("id ASC").Find(&nodes) + bestID := uint(0) + bestScore := 2.0 + for _, n := range nodes { + max := n.MaxExecutors + if max <= 0 { + max = 1 + } + score := float64(nodeRunningCount[n.ID]) / float64(max) + if score < 1.0 && score <= bestScore { + bestScore = score + bestID = n.ID + } + } + if bestID > 0 { + nodeRunningCount[bestID]++ + return bestID, true + } + return 0, false +} + +func (h *PipelineHandler) acquireMasterLocked() (uint, bool) { + if nodeRunningCount[masterNodeID] < masterMaxExecutors { + nodeRunningCount[masterNodeID]++ + return masterNodeID, true + } + return masterNodeID, false +} + +// releaseBuildNode 释放构建节点执行槽位 // Logan.Li +func (h *PipelineHandler) releaseBuildNode(nodeID uint) { + nodeMu.Lock() + defer nodeMu.Unlock() + if nodeRunningCount[nodeID] > 0 { + nodeRunningCount[nodeID]-- + } +} + +// buildExecCtx 根据节点 ID 构建执行上下文 // Logan.Li +func (h *PipelineHandler) buildExecCtx(nodeID uint, workspace string) (*PipelineExecCtx, error) { + ctx := &PipelineExecCtx{ + Workspace: workspace, + NodeID: nodeID, + NodeName: "Master", + } + if nodeID == masterNodeID { + return ctx, nil + } + var node model.BuildNode + if err := h.db.First(&node, nodeID).Error; err != nil { + return nil, fmt.Errorf("构建节点不存在") + } + var srv model.Server + if err := h.db.First(&srv, node.ServerID).Error; err != nil { + return nil, fmt.Errorf("节点关联服务器不存在") + } + cfg, err := buildSSHConfig(h.db, srv) + if err != nil { + return nil, err + } + ctx.Remote = true + ctx.SSHCfg = &cfg + ctx.NodeName = node.Name + return ctx, nil +} + +// remoteWorkspacePath 计算远程工作区路径 // Logan.Li +func remoteWorkspacePath(node model.BuildNode, repoName, code string) string { + base := strings.TrimRight(node.WorkspacePath, "/") + if base == "" { + base = "/data/aiops-workspace" + } + return fmt.Sprintf("%s/%s-%s", base, repoName, code) +} + +// execOnCtx 在 Master 或远程节点执行 shell 命令 // Logan.Li +func (h *PipelineHandler) execOnCtx(ctx *PipelineExecCtx, workDir, command string, timeout time.Duration) (string, error) { + if timeout <= 0 { + timeout = 30 * time.Minute + } + if !ctx.Remote { + cmd := exec.Command("bash", "-c", command) + if workDir != "" { + cmd.Dir = workDir + } + out, err := cmd.CombinedOutput() + return string(out), err + } + fullCmd := command + if workDir != "" { + fullCmd = fmt.Sprintf("cd %s && %s", shellQuotePath(workDir), command) + } + result, err := remoteexec.Exec(*ctx.SSHCfg, fullCmd, timeout) + output := "" + if result != nil { + output = result.Output + } + if err != nil || (result != nil && !result.Success) { + if err != nil { + return output, err + } + if result.Error != "" { + return output, fmt.Errorf("%s", result.Error) + } + return output, fmt.Errorf("exit=%d", result.ExitCode) + } + return output, nil +} + +// prepareWorkspace 初始化工作区(本地或远程) // Logan.Li +func (h *PipelineHandler) prepareWorkspace(ctx *PipelineExecCtx, logFn func(string, string, string)) error { + if !ctx.Remote { + os.RemoveAll(ctx.Workspace) + return os.MkdirAll(ctx.Workspace, 0755) + } + parent := filepath.Dir(ctx.Workspace) + _, err := h.execOnCtx(ctx, "", fmt.Sprintf("rm -rf %s && mkdir -p %s", shellQuotePath(ctx.Workspace), shellQuotePath(parent)), 5*time.Minute) + return err +} + +// syncRemoteWorkspaceToLocal 将远程工作区同步到 Master(部署阶段需要本地产物) // Logan.Li +func (h *PipelineHandler) syncRemoteWorkspaceToLocal(ctx *PipelineExecCtx, localWS string, logFn func(string, string, string)) error { + if !ctx.Remote || ctx.SSHCfg == nil { + return nil + } + logFn("info", "同步", fmt.Sprintf(">>> 从节点 %s 回拉工作区", ctx.NodeName)) + tmpTar := localWS + ".tar.gz" + os.RemoveAll(localWS) + os.MkdirAll(localWS, 0755) + + if err := remoteexec.StreamRemoteTar(*ctx.SSHCfg, ctx.Workspace, tmpTar, 30*time.Minute); err != nil { + return fmt.Errorf("远程打包下载失败: %v", err) + } + defer os.Remove(tmpTar) + + extract := exec.Command("tar", "xzf", tmpTar, "-C", localWS) + if out, err := extract.CombinedOutput(); err != nil { + return fmt.Errorf("解压工作区失败: %v, %s", err, string(out)) + } + logFn("success", "同步", "工作区已同步到 Master") + return nil +} + +// statOnCtx 检查远程/本地文件是否存在 // Logan.Li +func (h *PipelineHandler) statOnCtx(ctx *PipelineExecCtx, path string) bool { + if !ctx.Remote { + _, err := os.Stat(path) + return err == nil + } + out, err := h.execOnCtx(ctx, "", fmt.Sprintf("test -e %s && echo yes", shellQuotePath(path)), 30*time.Second) + return err == nil && strings.Contains(out, "yes") +} + +func shellQuotePath(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + +// pathExists 检查 Master/远程路径是否存在 // Logan.Li +func (h *PipelineHandler) pathExists(ctx *PipelineExecCtx, path string) bool { + return h.statOnCtx(ctx, path) +} + +// hasBuildFileCtx 检查目录是否包含可识别的构建文件 // Logan.Li +func (h *PipelineHandler) hasBuildFileCtx(ctx *PipelineExecCtx, dir string) bool { + for _, f := range []string{"pom.xml", "package.json", "go.mod", "Makefile"} { + if h.pathExists(ctx, dir+"/"+f) { + return true + } + } + return false +} \ No newline at end of file diff --git a/backend/controller/site.go b/backend/controller/site.go index d5818c1..dfcc926 100644 --- a/backend/controller/site.go +++ b/backend/controller/site.go @@ -71,9 +71,7 @@ func (h *SiteHandler) CreateTask(c *gin.Context) { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "名称和地址不能为空"}) return } - if task.Frequency < 10 { - task.Frequency = 60 - } + task.Frequency = siteprobe.NormalizeProbeFrequency(string(task.TaskType), task.Frequency) if task.Timeout <= 0 { task.Timeout = 10 } @@ -118,7 +116,11 @@ func (h *SiteHandler) UpdateTask(c *gin.Context) { updates["task_type"] = t } if v, ok := raw["frequency_sec"].(float64); ok { - updates["frequency"] = int(v) + taskType := string(task.TaskType) + if tv, ok := updates["task_type"].(string); ok { + taskType = tv + } + updates["frequency"] = siteprobe.NormalizeProbeFrequency(taskType, int(v)) } if v, ok := raw["timeout_sec"].(float64); ok { updates["timeout"] = int(v) diff --git a/backend/controller/site_view.go b/backend/controller/site_view.go index d306934..8dbfe69 100644 --- a/backend/controller/site_view.go +++ b/backend/controller/site_view.go @@ -70,6 +70,12 @@ func enrichSiteTaskViewsAlert(db *gorm.DB, views []SiteTaskView) { if len(views) == 0 { return } + // 列表加载时清理已恢复任务的陈旧 active 告警(与 down_count 对齐)// Logan.Li + for _, v := range views { + if v.LastStatus == "up" { + siteprobe.ResolveRecoveredProbeAlerts(db, v.ID, "success") + } + } ids := make([]uint, 0, len(views)) for _, v := range views { ids = append(ids, v.ID) @@ -87,9 +93,15 @@ func enrichSiteTaskViewsAlert(db *gorm.DB, views []SiteTaskView) { alerting[r.TaskID] = true } for i := range views { - if alerting[views[i].ID] { - views[i].AlertStatus = "alerting" + if !alerting[views[i].ID] { + continue } + // 最近一次拨测成功则显示正常(与 down_count 一致;SSL 证书预警走行内徽章)// Logan.Li + if views[i].LastStatus == "up" { + views[i].AlertStatus = "normal" + continue + } + views[i].AlertStatus = "alerting" } } @@ -195,7 +207,7 @@ func parseCreateTaskRequest(raw map[string]interface{}) model.SiteTask { TaskType: model.SiteTaskType(taskType), Method: model.SiteTaskMethodGET, Timeout: getInt(10, "timeout_sec", "timeout"), - Frequency: getInt(60, "frequency_sec", "frequency"), + Frequency: siteprobe.NormalizeProbeFrequency(taskType, getInt(0, "frequency_sec", "frequency")), ExpectedStatus: getInt(200, "expected_status_code", "expected_status"), FollowRedirect: true, VerifySSL: true, diff --git a/backend/controller/tool_config.go b/backend/controller/tool_config.go index ef5f003..9bc8c40 100644 --- a/backend/controller/tool_config.go +++ b/backend/controller/tool_config.go @@ -1,9 +1,11 @@ package controller import ( + "fmt" "log" "net/http" - "os/exec" + "strings" + "time" "ezops/model" "ezops/pkg/toolinstaller" @@ -25,17 +27,69 @@ func NewToolConfigHandler(db *gorm.DB, ti *toolinstaller.ToolInstaller) *ToolCon return &ToolConfigHandler{db: db, installer: ti} } +// toolAuditCtx 工具安装审计上下文快照 // Logan.Li +type toolAuditCtx struct { + userID uint + username string + ip string + userAgent string +} + +func newToolAuditCtx(c *gin.Context) toolAuditCtx { + uid, _ := c.Get("userID") + uname, _ := c.Get("username") + id, _ := uid.(uint) + name, _ := uname.(string) + return toolAuditCtx{ + userID: id, username: name, + ip: c.ClientIP(), userAgent: c.Request.UserAgent(), + } +} + +func truncateToolAuditText(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) > n { + return s[:n] + "..." + } + return s +} + +// recordToolAudit 记录工具安装/检测审计(含安装脚本输出与验证结果)// Logan.Li +func (h *ToolConfigHandler) recordToolAudit(ac toolAuditCtx, action, toolName, version string, status int, reqDetail, respDetail, errMsg string) { + uid := ac.userID + h.db.Create(&model.AuditLog{ + UserID: &uid, + Username: ac.username, + IP: ac.ip, + UserAgent: ac.userAgent, + Module: "deploy_tool", + Action: action, + Resource: toolName, + ResourceID: version, + Request: truncateToolAuditText(reqDetail, 2048), + Response: truncateToolAuditText(respDetail, 8192), + Status: status, + Error: truncateToolAuditText(errMsg, 512), + }) +} + // ListTools returns all configured tools func (h *ToolConfigHandler) ListTools(c *gin.Context) { var tools []model.DeployTool h.db.Order("sort_order ASC").Find(&tools) + // 列表加载时同步失败/安装中状态(磁盘已存在则自动纠正)// Logan.Li + for i := range tools { + if tools[i].Status == model.ToolFailed || tools[i].Status == model.ToolInstalling { + tools[i] = h.installer.SyncToolStatus(tools[i].ID) + } + } c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: tools, Total: int64(len(tools))}}) } // GetTool returns a single tool func (h *ToolConfigHandler) GetTool(c *gin.Context) { var tool model.DeployTool - if err := h.db.First(&tool, c.Param("id")); err.Error != nil { + if err := h.db.First(&tool, c.Param("id")).Error; err != nil { c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"}) return } @@ -45,7 +99,7 @@ func (h *ToolConfigHandler) GetTool(c *gin.Context) { // UpdateTool updates a tool's configuration func (h *ToolConfigHandler) UpdateTool(c *gin.Context) { var tool model.DeployTool - if err := h.db.First(&tool, c.Param("id")); err.Error != nil { + if err := h.db.First(&tool, c.Param("id")).Error; err != nil { c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"}) return } @@ -92,7 +146,7 @@ func (h *ToolConfigHandler) UpdateTool(c *gin.Context) { // InstallTool triggers lazy installation with optional version override func (h *ToolConfigHandler) InstallTool(c *gin.Context) { var tool model.DeployTool - if err := h.db.First(&tool, c.Param("id")); err.Error != nil { + if err := h.db.First(&tool, c.Param("id")).Error; err != nil { c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"}) return } @@ -107,7 +161,6 @@ func (h *ToolConfigHandler) InstallTool(c *gin.Context) { return } - // Support version override via query param or JSON body version := tool.DefaultVersion if v := c.Query("version"); v != "" { version = v @@ -115,7 +168,6 @@ func (h *ToolConfigHandler) InstallTool(c *gin.Context) { if v := c.PostForm("version"); v != "" { version = v } - // Also support JSON body var req struct { Version string `json:"version"` } @@ -123,62 +175,125 @@ func (h *ToolConfigHandler) InstallTool(c *gin.Context) { version = req.Version } - // Store the requested version for the installer if version != "" && version != tool.DefaultVersion { h.db.Model(&tool).Update("default_version", version) tool.DefaultVersion = version } - // Mark as installing immediately - h.db.Model(&tool).Update("status", model.ToolInstalling) + h.db.Model(&tool).Updates(map[string]interface{}{ + "status": model.ToolInstalling, "error_msg": "", + }) - // Background installation — actually call the installer + ac := newToolAuditCtx(c) + toolName := tool.Name + toolDisplay := tool.DisplayName go func() { - log.Printf("[ToolConfig] Background install triggered for %s (version: %s)", tool.Name, version) - if err := h.installer.ForceInstall(tool.Name, version); err != nil { - log.Printf("[ToolConfig] ERROR installing %s: %v", tool.Name, err) + log.Printf("[ToolConfig] Background install triggered for %s (version: %s)", toolName, version) + reqDetail := fmt.Sprintf("tool=%s version=%s", toolName, version) + res := h.installer.ForceInstall(toolName, version) + var latest model.DeployTool + if h.db.Where("name = ?", toolName).First(&latest).Error == nil { + latest = h.installer.SyncToolStatus(latest.ID) + } + respDetail := res.Output + if res.VerifyOut != "" { + respDetail += "\n--- verify ---\n" + res.VerifyOut + } + if res.Installed && res.Version != "" { + respDetail += "\n--- version ---\n" + res.Version + } + if res.Err != nil { + log.Printf("[ToolConfig] ERROR installing %s: %v", toolName, res.Err) + h.recordToolAudit(ac, "install", toolDisplay, version, 0, reqDetail, respDetail, res.Err.Error()) + return + } + success := res.Installed || latest.Status == model.ToolInstalled + log.Printf("[ToolConfig] %s installed successfully (status=%s installed=%v)", toolName, latest.Status, success) + if success { + h.recordToolAudit(ac, "install", toolDisplay, version, 1, reqDetail, respDetail, "") } else { - log.Printf("[ToolConfig] %s installed successfully", tool.Name) + h.recordToolAudit(ac, "install", toolDisplay, version, 0, reqDetail, respDetail, latest.ErrorMsg) } }() c.JSON(http.StatusOK, model.Response{Code: 200, Message: "安装已触发", Data: gin.H{"version": version}}) } -// CheckToolStatus re-checks a tool's installation status -func (h *ToolConfigHandler) CheckToolStatus(c *gin.Context) { +// UninstallTool 卸载工具(清理磁盘资源,可重新选择版本安装)// Logan.Li +func (h *ToolConfigHandler) UninstallTool(c *gin.Context) { var tool model.DeployTool - if err := h.db.First(&tool, c.Param("id")); err.Error != nil { + if err := h.db.First(&tool, c.Param("id")).Error; err != nil { c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"}) return } - available := false - version := "" - if tool.CheckCommand != "" { - cmd := exec.Command("sh", "-c", tool.CheckCommand) - out, err := cmd.Output() - if err == nil { - available = true - version = string(out) - if len(version) > 128 { - version = version[:128] - } - } + if tool.InstallMethod != model.ToolLazy { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "仅懒加载工具可手动卸载"}) + return } - status := model.ToolNotInstalled + if tool.Status != model.ToolInstalled && tool.Status != model.ToolFailed { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "当前状态不可卸载,请先检测或等待安装结束"}) + return + } + + ac := newToolAuditCtx(c) + toolName := tool.Name + toolDisplay := tool.DisplayName + installedVer := tool.InstalledVer + reqDetail := fmt.Sprintf("tool=%s installed_ver=%s", toolName, installedVer) + + res := h.installer.Uninstall(toolName) + respDetail := res.Output + if res.Err != nil { + h.recordToolAudit(ac, "uninstall", toolDisplay, installedVer, 0, reqDetail, respDetail, res.Err.Error()) + c.JSON(http.StatusOK, model.Response{Code: 500, Message: "卸载失败: " + res.Err.Error(), Data: gin.H{"output": respDetail}}) + return + } + + h.recordToolAudit(ac, "uninstall", toolDisplay, installedVer, 1, reqDetail, respDetail, "") + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "卸载完成", Data: gin.H{"output": respDetail}}) +} + +// CheckToolStatus re-checks a tool's installation status +func (h *ToolConfigHandler) CheckToolStatus(c *gin.Context) { + var tool model.DeployTool + if err := h.db.First(&tool, c.Param("id")).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"}) + return + } + + // 优先磁盘验证,纠正「审计成功但 DB 仍失败/安装中」// Logan.Li + available, version := h.installer.VerifyTool(tool) + now := time.Now() + if available { - status = model.ToolInstalled + h.db.Model(&tool).Updates(map[string]interface{}{ + "status": model.ToolInstalled, "installed_ver": version, + "error_msg": "", "last_check": &now, + }) + tool.Status = model.ToolInstalled + tool.InstalledVer = version + tool.ErrorMsg = "" + c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ + "available": true, "version": version, "tool": tool, + }}) + return + } + + // 安装中超时 + if tool.Status == model.ToolInstalling && tool.LastCheck != nil && time.Since(*tool.LastCheck) > 10*time.Minute { + msg := "安装超时,请重试" + h.db.Model(&tool).Updates(map[string]interface{}{ + "status": model.ToolFailed, "error_msg": msg, "last_check": &now, + }) + tool.Status = model.ToolFailed + tool.ErrorMsg = msg } - h.db.Model(&tool).Updates(map[string]interface{}{ - "status": status, - "installed_version": version, - }) c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ - "available": available, - "version": version, + "available": false, + "version": "", "tool": tool, }}) } @@ -186,7 +301,7 @@ func (h *ToolConfigHandler) CheckToolStatus(c *gin.Context) { // GetToolVersions returns available versions for a tool (jdk/node/maven) func (h *ToolConfigHandler) GetToolVersions(c *gin.Context) { var tool model.DeployTool - if err := h.db.First(&tool, c.Param("id")); err.Error != nil { + if err := h.db.First(&tool, c.Param("id")).Error; err != nil { c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"}) return } @@ -212,9 +327,12 @@ func getPresetVersions(toolName string) []map[string]string { } case "nodejs": return []map[string]string{ - {"version": "v18.20.5", "label": "Node 18 (LTS)"}, - {"version": "v20.18.1", "label": "Node 20 (LTS)"}, - {"version": "v22.14.0", "label": "Node 22 (LTS) ★ 推荐"}, + {"version": "lts", "label": "最新 LTS (nvm install --lts) ★ 推荐"}, + {"version": "v24.18.0", "label": "Node 24.18.0 (LTS Krypton)"}, + {"version": "v22.23.1", "label": "Node 22.23.1 (LTS Jod)"}, + {"version": "v20.20.2", "label": "Node 20.20.2 (LTS Iron)"}, + {"version": "v18.20.8", "label": "Node 18.20.8 (LTS Hydrogen)"}, + {"version": "v16.20.2", "label": "Node 16.20.2 (LTS Gallium)"}, } case "maven": return []map[string]string{ @@ -222,7 +340,20 @@ func getPresetVersions(toolName string) []map[string]string { {"version": "3.8.8", "label": "Maven 3.8.8"}, {"version": "3.6.3", "label": "Maven 3.6.3"}, } + case "golang": + return []map[string]string{ + {"version": "1.24.0", "label": "Go 1.24.0 ★ 推荐(与平台后端一致)"}, + {"version": "1.23.6", "label": "Go 1.23.6 (LTS)"}, + {"version": "1.22.12", "label": "Go 1.22.12"}, + {"version": "1.21.13", "label": "Go 1.21.13"}, + } + case "nvm": + return []map[string]string{ + {"version": "0.40.1", "label": "NVM 0.40.1 ★ 推荐"}, + {"version": "0.39.7", "label": "NVM 0.39.7"}, + {"version": "0.38.0", "label": "NVM 0.38.0"}, + } default: return nil } -} +} \ No newline at end of file diff --git a/backend/middleware/pipeline_access.go b/backend/middleware/pipeline_access.go index 434c27b..a35ab80 100644 --- a/backend/middleware/pipeline_access.go +++ b/backend/middleware/pipeline_access.go @@ -76,7 +76,7 @@ func PipelineAccess(db *gorm.DB) gin.HandlerFunc { func() uint { uid, _ := c.Get("userID"); return uid.(uint) }()). Find(&itemRoles) for _, ir := range itemRoles { - if matched, _ := regexp.MatchString(ir.Pattern, code); matched { + if pipelineMatchesItemRole(pipeline, ir.Pattern) { c.Next() return } @@ -122,7 +122,7 @@ func GetAccessiblePipelineIDs(db *gorm.DB, username string, userID uint) ([]uint db.Find(&allPipelines) for _, p := range allPipelines { for _, ir := range itemRoles { - if matched, _ := regexp.MatchString(ir.Pattern, p.Code); matched { + if pipelineMatchesItemRole(p, ir.Pattern) { idSet[p.ID] = true break } @@ -137,3 +137,19 @@ func GetAccessiblePipelineIDs(db *gorm.DB, username string, userID uint) ([]uint return ids, len(ids) > 0 } + +// pipelineMatchesItemRole 正则匹配流水线 code 或 Job 名称(Webhook Job) // Logan.Li +func pipelineMatchesItemRole(p model.Pipeline, pattern string) bool { + if pattern == "" { + return false + } + if matched, err := regexp.MatchString(pattern, p.Code); err == nil && matched { + return true + } + if p.JobName != "" { + if matched, err := regexp.MatchString(pattern, p.JobName); err == nil && matched { + return true + } + } + return false +} diff --git a/backend/model/build_node.go b/backend/model/build_node.go new file mode 100644 index 0000000..ecdb8f7 --- /dev/null +++ b/backend/model/build_node.go @@ -0,0 +1,61 @@ +package model + +import "time" + +// BuildNode CI/CD 构建节点(Jenkins Agent 风格,基于 SSH 关联服务器资产) // Logan.Li +type BuildNode struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null;uniqueIndex" json:"name"` + Description string `gorm:"size:255" json:"description"` + ServerID uint `gorm:"not null;index" json:"server_id"` // FK → servers + Labels string `gorm:"size:255;comment:标签(逗号分隔,用于调度匹配)" json:"labels,omitempty"` + MaxExecutors int `gorm:"default:2;comment:最大并发构建数" json:"max_executors"` + WorkspacePath string `gorm:"size:255;default:/data/aiops-workspace;comment:远程工作区根目录" json:"workspace_path"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // 运行时统计(非持久化,由调度器填充) + RunningCount int `gorm:"-" json:"running_count,omitempty"` + Hostname string `gorm:"-" json:"hostname,omitempty"` + IP string `gorm:"-" json:"ip,omitempty"` +} + +func (BuildNode) TableName() string { + return "deploy_build_nodes" +} + +// BuildMasterConfig Master 内置构建节点并发配置(单行) // Logan.Li +type BuildMasterConfig struct { + ID uint `gorm:"primarykey" json:"id"` + MaxExecutors int `gorm:"default:2;not null;comment:最大并发(1=串行排队)" json:"max_executors"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (BuildMasterConfig) TableName() string { return "deploy_build_master_config" } + +// UpdateBuildMasterRequest 更新 Master 并发数 // Logan.Li +type UpdateBuildMasterRequest struct { + MaxExecutors int `json:"max_executors" binding:"required,min=1,max=32"` +} + +// CreateBuildNodeRequest 创建构建节点 +type CreateBuildNodeRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + ServerID uint `json:"server_id" binding:"required"` + Labels string `json:"labels"` + MaxExecutors int `json:"max_executors"` + WorkspacePath string `json:"workspace_path"` +} + +// UpdateBuildNodeRequest 更新构建节点 +type UpdateBuildNodeRequest struct { + Name string `json:"name"` + Description string `json:"description"` + ServerID *uint `json:"server_id"` + Labels string `json:"labels"` + MaxExecutors int `json:"max_executors"` + WorkspacePath string `json:"workspace_path"` + Enabled *bool `json:"enabled"` +} \ No newline at end of file diff --git a/backend/model/cicd.go b/backend/model/cicd.go index a0c7bc5..5ee6ca9 100644 --- a/backend/model/cicd.go +++ b/backend/model/cicd.go @@ -27,12 +27,19 @@ type Pipeline struct { // 并发控制 MaxExecutors int `gorm:"default:2;comment:最大并发执行数(1=串行,默认2)" json:"max_executors"` + // 构建节点(nil=自动调度 Master 优先,非 nil=指定节点 ID) // Logan.Li + BuildNodeID *uint `json:"build_node_id,omitempty"` + // SSH 部署目标服务器 (JSON: [1,2,3]) - ServerIDs string `gorm:"type:text;comment:SSH服务器ID列表(JSON)" json:"server_ids,omitempty"` + ServerIDs string `gorm:"type:text;comment:SSH服务器ID列表(JSON)" json:"server_ids,omitempty"` + DeployRemotePath string `gorm:"size:255;comment:SSH部署远程目录" json:"deploy_remote_path,omitempty"` + DeployScript string `gorm:"type:text;comment:SSH部署后执行命令" json:"deploy_script,omitempty"` // K8s 部署目标 - K8sClusterID *uint `json:"k8s_cluster_id,omitempty"` // FK → kubernetes_clusters - K8sNamespace string `gorm:"size:128;comment:K8s部署命名空间" json:"k8s_namespace,omitempty"` + K8sClusterID *uint `json:"k8s_cluster_id,omitempty"` // FK → kubernetes_clusters + K8sNamespace string `gorm:"size:128;comment:K8s部署命名空间" json:"k8s_namespace,omitempty"` + K8sDeployment string `gorm:"size:128;comment:K8s Deployment 名称" json:"k8s_deployment,omitempty"` + K8sContainerName string `gorm:"size:128;comment:K8s更新镜像目标容器" json:"k8s_container_name,omitempty"` // 仓库和镜像仓库关联(旧字段,待废弃) RepoID *uint `json:"repo_id"` // FK → deploy_pipeline_repos @@ -87,6 +94,8 @@ type PipelineRun struct { ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签 ArtifactURL string `gorm:"size:500" json:"artifact_url"` // 构建产物地址 WorkspacePath string `gorm:"size:500" json:"workspace_path"` // 工作区路径 + BuildNodeID *uint `json:"build_node_id,omitempty"` // 实际执行节点(0/nil=Master) + BuildNodeName string `gorm:"size:64" json:"build_node_name,omitempty"` Duration int `json:"duration"` // 秒 Stages string `gorm:"type:text" json:"stages"` // JSON Logs string `gorm:"type:longtext" json:"logs"` @@ -153,9 +162,13 @@ type CreatePipelineRequest struct { // 部署模式 DeployMode string `json:"deploy_mode"` // branch / tag DeployTarget string `json:"deploy_target"` // ssh / k8s - ServerIDs string `json:"server_ids"` // SSH 服务器ID JSON [1,2,3] - K8sClusterID *uint `json:"k8s_cluster_id"` - K8sNamespace string `json:"k8s_namespace"` + ServerIDs string `json:"server_ids"` // SSH 服务器ID JSON [1,2,3] + DeployRemotePath string `json:"deploy_remote_path"` + DeployScript string `json:"deploy_script"` + K8sClusterID *uint `json:"k8s_cluster_id"` + K8sNamespace string `json:"k8s_namespace"` + K8sDeployment string `json:"k8s_deployment"` + K8sContainerName string `json:"k8s_container_name"` RepoID *uint `json:"repo_id"` RegistryID *uint `json:"registry_id"` @@ -176,6 +189,9 @@ type CreatePipelineRequest struct { // 并发控制 MaxExecutors int `json:"max_executors"` + + // 构建节点 + BuildNodeID *uint `json:"build_node_id"` } // UpdatePipelineRequest 更新流水线请求 @@ -191,9 +207,13 @@ type UpdatePipelineRequest struct { Variables string `json:"variables"` DeployMode string `json:"deploy_mode"` DeployTarget string `json:"deploy_target"` - ServerIDs string `json:"server_ids"` - K8sClusterID *uint `json:"k8s_cluster_id"` - K8sNamespace string `json:"k8s_namespace"` + ServerIDs string `json:"server_ids"` + DeployRemotePath string `json:"deploy_remote_path"` + DeployScript string `json:"deploy_script"` + K8sClusterID *uint `json:"k8s_cluster_id"` + K8sNamespace string `json:"k8s_namespace"` + K8sDeployment string `json:"k8s_deployment"` + K8sContainerName string `json:"k8s_container_name"` RepoID *uint `json:"repo_id"` RegistryID *uint `json:"registry_id"` // 凭证关联 @@ -212,6 +232,9 @@ type UpdatePipelineRequest struct { // 并发控制 MaxExecutors int `json:"max_executors"` + + // 构建节点(传 null 清除绑定) + BuildNodeID *uint `json:"build_node_id"` } // TriggerPipelineRequest 触发流水线请求 diff --git a/backend/model/deploy_tool.go b/backend/model/deploy_tool.go index 7e4ba6e..1a1b051 100644 --- a/backend/model/deploy_tool.go +++ b/backend/model/deploy_tool.go @@ -74,26 +74,65 @@ func PredefinedTools() []DeployTool { InstallMethod: ToolLazy, BinaryName: "java", InstallPath: "/usr/local/bin/java", - CheckCommand: "java -version 2>&1 | head -1", + CheckCommand: "/usr/local/bin/java -version 2>&1 | head -1", VersionURL: "https://api.adoptium.net/v3/info/release_versions?heap_size=normal&image_type=jdk&page=0&page_size=1&project=jdk&release_type=ga&semver=false&sort_method=DEFAULT&sort_order=DESC&version=%5B17%2C99%5D", DownloadURL: "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-{version}/OpenJDK17U-jdk_x64_linux_hotspot_{version}.tar.gz", DefaultVersion: "17.0.13+7", InstallScript: `#!/bin/sh -# JDK lazy install — Temurin OpenJDK +# JDK lazy install — Alpine apk 优先,否则 Adoptium API 多镜像 // Logan.Li JDK_VERSION="${TOOL_VERSION:-17.0.13+7}" JDK_MAJOR=$(echo "$JDK_VERSION" | cut -d. -f1) -JDK_URL="https://github.com/adoptium/temurin${JDK_MAJOR}-binaries/releases/download/jdk-${JDK_VERSION}/OpenJDK${JDK_MAJOR}U-jdk_x64_linux_hotspot_${JDK_VERSION}.tar.gz" -echo "[JDK] Downloading ${JDK_VERSION}..." -cd /tmp -curl -sL "$JDK_URL" -o jdk.tar.gz +JDK_FILE_VER=$(echo "$JDK_VERSION" | tr '+' '_') +echo "[JDK] Installing ${JDK_VERSION} (major ${JDK_MAJOR})..." + +if command -v apk >/dev/null 2>&1; then + case "$JDK_MAJOR" in + 11) APK_PKG="openjdk11-jdk" ;; + 17) APK_PKG="openjdk17-jdk" ;; + 21) APK_PKG="openjdk21-jdk" ;; + *) APK_PKG="" ;; + esac + if [ -n "$APK_PKG" ]; then + echo "[JDK] Trying apk: $APK_PKG" + if apk add --no-cache "$APK_PKG"; then + JAVA_BIN=$(command -v java 2>/dev/null || echo "/usr/lib/jvm/java-${JDK_MAJOR}-openjdk/bin/java") + mkdir -p /usr/local/bin /usr/local/share/jdk/bin + ln -sf "$JAVA_BIN" /usr/local/bin/java + ln -sf "$(dirname "$JAVA_BIN")/javac" /usr/local/bin/javac 2>/dev/null || true + ln -sf "$(dirname "$JAVA_BIN")/jar" /usr/local/bin/jar 2>/dev/null || true + echo "[JDK] Installed via apk: $(java -version 2>&1 | head -1)" + exit 0 + fi + fi +fi + +cd /tmp && rm -rf jdk.tar.gz jdk-install +mkdir -p jdk-install && cd jdk-install +DOWNLOADED=0 +for URL in \ + "https://api.adoptium.net/v3/binary/version/${JDK_VERSION}/linux/x64/jdk/hotspot/normal/eclipse?project=jdk" \ + "https://mirrors.tuna.tsinghua.edu.cn/Adoptium/${JDK_MAJOR}/jdk/${JDK_VERSION}/OpenJDK${JDK_MAJOR}U-jdk_x64_linux_hotspot_${JDK_FILE_VER}.tar.gz" \ + "https://github.com/adoptium/temurin${JDK_MAJOR}-binaries/releases/download/jdk-${JDK_VERSION}/OpenJDK${JDK_MAJOR}U-jdk_x64_linux_hotspot_${JDK_FILE_VER}.tar.gz" +do + echo "[JDK] Trying $URL" + if curl -fsSL -L -o jdk.tar.gz "$URL"; then + DOWNLOADED=1 + break + fi + rm -f jdk.tar.gz +done +[ "$DOWNLOADED" -eq 1 ] || { echo "[JDK] all mirrors failed"; exit 1; } tar xzf jdk.tar.gz +JDK_DIR=$(find . -maxdepth 1 -type d -name 'jdk-*' | head -1) +[ -n "$JDK_DIR" ] || { echo "[JDK] extract dir not found"; exit 1; } mkdir -p /usr/local/share -mv jdk-${JDK_VERSION} /usr/local/share/jdk +rm -rf /usr/local/share/jdk +mv "$JDK_DIR" /usr/local/share/jdk ln -sf /usr/local/share/jdk/bin/java /usr/local/bin/java ln -sf /usr/local/share/jdk/bin/javac /usr/local/bin/javac ln -sf /usr/local/share/jdk/bin/jar /usr/local/bin/jar -rm -f jdk.tar.gz -echo "[JDK] Installed: $(java -version 2>&1 | head -1)" +cd /tmp && rm -rf jdk-install +echo "[JDK] Installed: $(/usr/local/bin/java -version 2>&1 | head -1)" `, SortOrder: 1, }, @@ -106,26 +145,100 @@ echo "[JDK] Installed: $(java -version 2>&1 | head -1)" InstallMethod: ToolLazy, BinaryName: "mvn", InstallPath: "/usr/local/bin/mvn", - CheckCommand: "mvn --version 2>/dev/null", + CheckCommand: "/usr/local/bin/mvn --version 2>/dev/null | head -1", DownloadURL: "https://dlcdn.apache.org/maven/maven-3/{version}/binaries/apache-maven-{version}-bin.tar.gz", VersionURL: "https://dlcdn.apache.org/maven/maven-3/", DefaultVersion: "3.9.9", InstallScript: `#!/bin/sh -# Maven lazy install — download & extract +# Maven lazy install — 多镜像轮询 // Logan.Li MAVEN_VERSION="${TOOL_VERSION:-3.9.9}" -MAVEN_URL="https://dlcdn.apache.org/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" echo "[Maven] Downloading ${MAVEN_VERSION}..." -cd /tmp -curl -sL "$MAVEN_URL" -o maven.tar.gz +cd /tmp && rm -rf maven.tar.gz maven-install +mkdir -p maven-install && cd maven-install +DOWNLOADED=0 +for URL in \ + "https://mirrors.tuna.tsinghua.edu.cn/apache/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" \ + "https://repo.huaweicloud.com/apache/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" \ + "https://dlcdn.apache.org/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" \ + "https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" +do + echo "[Maven] Trying $URL" + if curl -fsSL -o maven.tar.gz "$URL"; then + DOWNLOADED=1 + break + fi + rm -f maven.tar.gz +done +[ "$DOWNLOADED" -eq 1 ] || { echo "[Maven] all mirrors failed"; exit 1; } tar xzf maven.tar.gz +MVN_DIR=$(find . -maxdepth 1 -type d -name 'apache-maven-*' | head -1) +[ -n "$MVN_DIR" ] || { echo "[Maven] extract dir not found"; exit 1; } mkdir -p /usr/local/share -mv apache-maven-${MAVEN_VERSION} /usr/local/share/maven +rm -rf /usr/local/share/maven +mv "$MVN_DIR" /usr/local/share/maven ln -sf /usr/local/share/maven/bin/mvn /usr/local/bin/mvn -rm -f maven.tar.gz -echo "[Maven] Installed: $(mvn --version 2>&1 | head -1)" +cd /tmp && rm -rf maven-install +echo "[Maven] Installed: $(/usr/local/bin/mvn --version 2>&1 | head -1)" `, SortOrder: 2, }, + // ──── Go — 后端构建 // Logan.Li ──── + { + Name: "golang", + DisplayName: "Go (Golang)", + Description: "Go 语言工具链,流水线 go build/test 阶段使用", + Category: "build", + InstallMethod: ToolLazy, + BinaryName: "go", + InstallPath: "/usr/local/bin/go", + CheckCommand: "/usr/local/bin/go version 2>/dev/null", + DownloadURL: "https://go.dev/dl/go{version}.linux-amd64.tar.gz", + DefaultVersion: "1.24.0", + InstallScript: `#!/bin/sh +# Go lazy install — 国内镜像优先 // Logan.Li +GO_VERSION="${TOOL_VERSION:-1.24.0}" +GO_VERSION="${GO_VERSION#go}" +echo "[Go] Installing go${GO_VERSION}..." +cd /tmp && rm -rf go.tar.gz go-install +mkdir -p go-install && cd go-install +DOWNLOADED=0 +for URL in \ + "https://golang.google.cn/dl/go${GO_VERSION}.linux-amd64.tar.gz" \ + "https://mirrors.aliyun.com/golang/go${GO_VERSION}.linux-amd64.tar.gz" \ + "https://mirrors.ustc.edu.cn/golang/go${GO_VERSION}.linux-amd64.tar.gz" \ + "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" +do + echo "[Go] Trying $URL" + if curl -fsSL -L -o go.tar.gz "$URL"; then + DOWNLOADED=1 + break + fi + rm -f go.tar.gz +done +[ "$DOWNLOADED" -eq 1 ] || { echo "[Go] all mirrors failed"; exit 1; } +tar xzf go.tar.gz +[ -d go ] || { echo "[Go] extract dir not found"; exit 1; } +rm -rf /usr/local/go +mv go /usr/local/go +mkdir -p /usr/local/bin +ln -sf /usr/local/go/bin/go /usr/local/bin/go +ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt +mkdir -p /etc/profile.d +cat > /etc/profile.d/golang.sh << 'GO_PROFILE' +export GOROOT=/usr/local/go +export PATH=$GOROOT/bin:$PATH +export GOPROXY=https://goproxy.cn,direct +export GOTOOLCHAIN=local +GO_PROFILE +chmod +x /etc/profile.d/golang.sh +export GOROOT=/usr/local/go +export PATH=$GOROOT/bin:$PATH +export GOPROXY=https://goproxy.cn,direct +export GOTOOLCHAIN=local +echo "[Go] Installed: $(/usr/local/bin/go version)" +`, + SortOrder: 3, + }, // ──── NVM — Node Version Manager // Logan.Li ──── { Name: "nvm", @@ -135,26 +248,30 @@ echo "[Maven] Installed: $(mvn --version 2>&1 | head -1)" InstallMethod: ToolLazy, BinaryName: "nvm", InstallPath: "/usr/local/share/nvm/nvm.sh", - CheckCommand: "source /usr/local/share/nvm/nvm.sh 2>/dev/null && nvm --version 2>/dev/null", + CheckCommand: ". /usr/local/share/nvm/nvm.sh 2>/dev/null && nvm --version 2>/dev/null", VersionURL: "https://api.github.com/repos/nvm-sh/nvm/releases/latest", DefaultVersion: "0.40.1", InstallScript: `#!/bin/sh -# NVM lazy install +# NVM lazy install — 使用 bash 执行官方脚本 // Logan.Li NVM_VERSION="${TOOL_VERSION:-0.40.1}" NVM_DIR="/usr/local/share/nvm" -echo "[NVM] Downloading ${NVM_VERSION}..." +echo "[NVM] Downloading v${NVM_VERSION}..." mkdir -p "$NVM_DIR" -curl -sL "https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh" | NVM_DIR="$NVM_DIR" bash -# Make available system-wide via profile.d +curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh" -o /tmp/nvm-install.sh +export NVM_DIR +bash /tmp/nvm-install.sh +rm -f /tmp/nvm-install.sh +[ -s "$NVM_DIR/nvm.sh" ] || { echo "[NVM] nvm.sh not found"; exit 1; } +mkdir -p /etc/profile.d cat > /etc/profile.d/nvm.sh << 'NVM_PROFILE' export NVM_DIR="/usr/local/share/nvm" -[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" -[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" NVM_PROFILE chmod +x /etc/profile.d/nvm.sh -echo "[NVM] Installed: v${NVM_VERSION}" +. "$NVM_DIR/nvm.sh" +echo "[NVM] Installed: $(nvm --version 2>/dev/null || echo v${NVM_VERSION})" `, - SortOrder: 3, + SortOrder: 4, }, // ──── Node.js — 通过 NVM 安装 // Logan.Li ──── { @@ -166,30 +283,74 @@ echo "[NVM] Installed: v${NVM_VERSION}" BinaryName: "node", InstallPath: "/usr/local/bin/node", CheckCommand: "node --version 2>/dev/null", - DefaultVersion: "v22.14.0", + DefaultVersion: "lts", InstallScript: `#!/bin/sh -# Node.js via NVM install -NODE_VERSION="${TOOL_VERSION:-v22.14.0}" -echo "[Node.js] Installing via NVM ${NODE_VERSION}..." -export NVM_DIR="/usr/local/share/nvm" -[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" -if ! command -v nvm >/dev/null 2>&1; then - # NVM not found — install it first - echo "[Node.js] NVM not found, installing NVM first..." - curl -sL "https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh" | NVM_DIR="$NVM_DIR" bash - \. "$NVM_DIR/nvm.sh" -fi -nvm install "${NODE_VERSION#v}" -nvm alias default "${NODE_VERSION#v}" -nvm use "${NODE_VERSION#v}" -# Symlink for system-wide access -ln -sf "$NVM_DIR/versions/node/$(nvm version)/bin/node" /usr/local/bin/node -ln -sf "$NVM_DIR/versions/node/$(nvm version)/bin/npm" /usr/local/bin/npm -ln -sf "$NVM_DIR/versions/node/$(nvm version)/bin/npx" /usr/local/bin/npx -npm config set registry https://registry.npmmirror.com -echo "[Node.js] Installed: $(node --version) / npm $(npm --version)" +# Node.js 安装 — Alpine 用 unofficial-builds musl 包(官方已停 musl 预编译)// Logan.Li +NODE_VERSION="${TOOL_VERSION:-lts}" +NVM_DIR="/usr/local/share/nvm" +NODE_MIRROR="${NODE_MIRROR:-https://npmmirror.com/mirrors/node}" +echo "[Node.js] Installing (${NODE_VERSION})..." +mkdir -p "$NVM_DIR/versions/node" +export NVM_DIR + +resolve_version() { + case "$1" in + lts) + curl -fsSL "https://nodejs.org/dist/index.json" 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(next(x['version'] for x in d if x.get('lts')))" 2>/dev/null || echo "v24.18.0" + ;; + latest|node) + curl -fsSL "https://nodejs.org/dist/index.json" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['version'])" 2>/dev/null || echo "v24.18.0" + ;; + *) + echo "v${1#v}" + ;; + esac +} + +direct_install_binary() { + RESOLVED="$1" + DEST="$NVM_DIR/versions/node/${RESOLVED}" + TMP="/tmp/node-dl-$$" + rm -rf "$TMP" "$DEST" + mkdir -p "$TMP" + if [ -f /etc/alpine-release ]; then + # Node 官方仅 glibc;Alpine musl 走 unofficial-builds // Logan.Li + URLS="https://unofficial-builds.nodejs.org/download/release/${RESOLVED}/node-${RESOLVED}-linux-x64-musl.tar.xz" + else + URLS="${NODE_MIRROR}/${RESOLVED}/node-${RESOLVED}-linux-x64.tar.xz https://nodejs.org/dist/${RESOLVED}/node-${RESOLVED}-linux-x64.tar.xz" + fi + for URL in $URLS; do + echo "[Node.js] Downloading $URL" + rm -rf "$TMP"/* 2>/dev/null || true + if curl -fsSL -L "$URL" | tar -xJ -C "$TMP" 2>/dev/null; then + SUB=$(find "$TMP" -mindepth 1 -maxdepth 1 -type d | head -1) + if [ -n "$SUB" ] && [ -x "$SUB/bin/node" ]; then + mv "$SUB" "$DEST" + rm -rf "$TMP" + echo "[Node.js] Installed $RESOLVED" + return 0 + fi + fi + echo "[Node.js] Failed: $URL" + done + rm -rf "$TMP" + return 1 +} + +RESOLVED=$(resolve_version "$NODE_VERSION") +[ -n "$RESOLVED" ] || { echo "[Node.js] cannot resolve version for $NODE_VERSION"; exit 1; } +echo "[Node.js] Target version: $RESOLVED" +direct_install_binary "$RESOLVED" || { echo "[Node.js] binary install failed"; exit 1; } + +INST="$RESOLVED" +mkdir -p /usr/local/bin +ln -sf "$NVM_DIR/versions/node/${INST}/bin/node" /usr/local/bin/node +ln -sf "$NVM_DIR/versions/node/${INST}/bin/npm" /usr/local/bin/npm +ln -sf "$NVM_DIR/versions/node/${INST}/bin/npx" /usr/local/bin/npx +"$NVM_DIR/versions/node/${INST}/bin/npm" config set registry https://registry.npmmirror.com +echo "[Node.js] Installed: $(/usr/local/bin/node --version) / npm $("$NVM_DIR/versions/node/${INST}/bin/npm" --version)" `, - SortOrder: 4, + SortOrder: 5, }, // ──── Git — 版本控制(懒加载, U4 镜像瘦身)// Logan.Li ──── { @@ -202,7 +363,7 @@ echo "[Node.js] Installed: $(node --version) / npm $(npm --version)" InstallPath: "/usr/bin/git", CheckCommand: "git --version 2>/dev/null", InstallScript: `apk add --no-cache git`, - SortOrder: 5, + SortOrder: 6, }, // ──── Docker CLI — 容器构建(懒加载, U4 镜像瘦身)// Logan.Li ──── { @@ -215,7 +376,7 @@ echo "[Node.js] Installed: $(node --version) / npm $(npm --version)" InstallPath: "/usr/bin/docker", CheckCommand: "docker --version 2>/dev/null", InstallScript: `apk add --no-cache docker-cli`, - SortOrder: 6, + SortOrder: 7, }, } } diff --git a/backend/model/log_scope_role.go b/backend/model/log_scope_role.go new file mode 100644 index 0000000..567dea8 --- /dev/null +++ b/backend/model/log_scope_role.go @@ -0,0 +1,72 @@ +package model + +import "time" + +// LogScopeRole 日志查询范围权限(正则匹配 ES 索引 / Loki label) // Logan.Li +type LogScopeRole struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Engine string `gorm:"size:32;default:*;comment:引擎 loki/elasticsearch/*" json:"engine"` + MatchField string `gorm:"size:32;not null;comment:匹配维度 index/app/namespace/job" json:"match_field"` + Pattern string `gorm:"size:255;not null;comment:正则表达式" json:"pattern"` + DatasourceID *uint `json:"datasource_id,omitempty"` + Role string `gorm:"size:16;default:viewer;comment:viewer/analyst" json:"role"` + Description string `gorm:"size:255" json:"description"` + Status int `gorm:"default:1;comment:0禁用1启用" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (LogScopeRole) TableName() string { return "monitor_log_scope_roles" } + +// UserLogScopeRole 用户 ↔ 日志范围权限 // Logan.Li +type UserLogScopeRole struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_user_log_scope;not null" json:"user_id"` + LogScopeRoleID uint `gorm:"uniqueIndex:idx_user_log_scope;not null" json:"log_scope_role_id"` + CreatedAt time.Time `json:"created_at"` + + LogScopeRole *LogScopeRole `json:"log_scope_role,omitempty" gorm:"foreignKey:LogScopeRoleID"` +} + +func (UserLogScopeRole) TableName() string { return "monitor_user_log_scope_roles" } + +// CreateLogScopeRoleRequest 创建日志权限策略 // Logan.Li +type CreateLogScopeRoleRequest struct { + Name string `json:"name" binding:"required,max=64"` + Code string `json:"code" binding:"required,max=64"` + Engine string `json:"engine" binding:"omitempty,oneof=loki elasticsearch *"` + MatchField string `json:"match_field" binding:"required,oneof=index app namespace job"` + Pattern string `json:"pattern" binding:"required,max=255"` + DatasourceID *uint `json:"datasource_id"` + Role string `json:"role" binding:"omitempty,oneof=viewer analyst"` + Description string `json:"description"` +} + +// UpdateLogScopeRoleRequest 更新日志权限策略 // Logan.Li +type UpdateLogScopeRoleRequest struct { + Name string `json:"name"` + Code string `json:"code"` + Engine string `json:"engine"` + MatchField string `json:"match_field"` + Pattern string `json:"pattern"` + DatasourceID *uint `json:"datasource_id"` + Role string `json:"role"` + Description string `json:"description"` + Status *int `json:"status"` +} + +// AssignLogScopeRoleRequest 用户授权(user_id 取自 URL 路径)// Logan.Li +type AssignLogScopeRoleRequest struct { + LogScopeRoleID uint `json:"log_scope_role_id" binding:"required"` +} + +// PreviewLogScopeRequest 命中预览 // Logan.Li +type PreviewLogScopeRequest struct { + Engine string `json:"engine" binding:"required,oneof=loki elasticsearch"` + DatasourceID uint `json:"datasource_id"` + Index string `json:"index"` + Query string `json:"query"` + SampleValue string `json:"sample_value"` +} \ No newline at end of file diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index b0ce5ac..377aa50 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -99,6 +99,7 @@ func AutoMigrate(db *gorm.DB) error { &model.MetricData{}, &model.Pipeline{}, &model.PipelineRun{}, + &model.BuildNode{}, &model.AppRelease{}, &model.CronJob{}, &model.Script{}, @@ -157,6 +158,9 @@ func AutoMigrate(db *gorm.DB) error { &model.DeployUserGroupMember{}, // 用户组成员 // Logan.Li &model.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li &model.UserItemRole{}, // User ↔ ItemRole association // Logan.Li + &model.LogScopeRole{}, // 日志查询范围权限(正则匹配索引/Label)// Logan.Li + &model.UserLogScopeRole{}, // 用户 ↔ 日志范围权限 // Logan.Li + &model.BuildMasterConfig{}, // Master 构建节点并发配置 // Logan.Li &model.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li &model.AlertCondition{}, // 告警条件 // Logan.Li &model.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li @@ -216,4 +220,15 @@ func applySchemaPatches(db *gorm.DB) { patchLogDSColumn("AuthType", "auth_type") patchLogDSColumn("Token", "token") patchLogDSColumn("Headers", "headers") + + // 日志权限关联表:AutoMigrate 在 MySQL 索引冲突时可能只创建策略表未创建用户关联表 // Logan.Li + ensureTable := func(tbl interface{}, name string) { + if !m.HasTable(tbl) { + if err := db.AutoMigrate(tbl); err != nil { + log.Printf("[DB] 补表 %s 失败: %v", name, err) + } + } + } + ensureTable(&model.LogScopeRole{}, "monitor_log_scope_roles") + ensureTable(&model.UserLogScopeRole{}, "monitor_user_log_scope_roles") } diff --git a/backend/pkg/logaccess/access.go b/backend/pkg/logaccess/access.go new file mode 100644 index 0000000..e1524eb --- /dev/null +++ b/backend/pkg/logaccess/access.go @@ -0,0 +1,227 @@ +package logaccess + +import ( + "fmt" + "regexp" + "strings" + + "ezops/model" + + "gorm.io/gorm" +) + +// QueryContext 日志查询授权上下文 // Logan.Li +type QueryContext struct { + Engine string + DatasourceID uint + Index string + Query string +} + +var ( + lokiSelectorRe = regexp.MustCompile(`\{([^}]*)\}`) + lokiLabelRe = regexp.MustCompile(`([a-zA-Z_][\w.-]*)\s*(=|!=|=~|!~)\s*("([^"]*)"|'([^']*)'|([^\s,}]+))`) + esAppRe = regexp.MustCompile(`(?i)\bapp:(\*|"[^"]+"|'[^']+'|[^\s)]+)`) +) + +// GetUserLogScopeRoles 获取用户已启用的日志权限策略 // Logan.Li +func GetUserLogScopeRoles(db *gorm.DB, userID uint) ([]model.LogScopeRole, error) { + var roles []model.LogScopeRole + err := db.Table("monitor_log_scope_roles"). + Joins("JOIN monitor_user_log_scope_roles ON monitor_user_log_scope_roles.log_scope_role_id = monitor_log_scope_roles.id"). + Where("monitor_user_log_scope_roles.user_id = ? AND monitor_log_scope_roles.status = 1", userID). + Find(&roles).Error + return roles, err +} + +// UserHasAssignments 用户是否已分配日志权限(未分配则保持兼容:不拦截) // Logan.Li +func UserHasAssignments(db *gorm.DB, userID uint) bool { + var n int64 + db.Model(&model.UserLogScopeRole{}).Where("user_id = ?", userID).Count(&n) + return n > 0 +} + +// CanQuery 校验用户是否允许执行该日志查询 // Logan.Li +func CanQuery(db *gorm.DB, userID uint, isAdmin bool, qc QueryContext) (bool, string) { + if isAdmin { + return true, "" + } + if !UserHasAssignments(db, userID) { + return true, "" + } + roles, err := GetUserLogScopeRoles(db, userID) + if err != nil || len(roles) == 0 { + return false, "未分配可用的日志查询权限,请联系管理员" + } + for _, r := range roles { + if roleAllowsQuery(r, qc) { + return true, "" + } + } + return false, "无权查询该日志范围(索引/Label 未命中授权策略)" +} + +// FilterDataSources 按授权过滤可见数据源 // Logan.Li +func FilterDataSources(db *gorm.DB, userID uint, isAdmin bool, sources []model.LogDataSource) []model.LogDataSource { + if isAdmin || !UserHasAssignments(db, userID) { + return sources + } + roles, _ := GetUserLogScopeRoles(db, userID) + if len(roles) == 0 { + return []model.LogDataSource{} + } + out := make([]model.LogDataSource, 0, len(sources)) + for _, ds := range sources { + if datasourceAllowed(roles, ds) { + out = append(out, ds) + } + } + return out +} + +// MatchRolesForPreview 返回命中预览的策略列表 // Logan.Li +func MatchRolesForPreview(roles []model.LogScopeRole, qc QueryContext, sample string) []model.LogScopeRole { + matched := make([]model.LogScopeRole, 0) + for _, r := range roles { + if roleAllowsQuery(r, qc) { + matched = append(matched, r) + continue + } + if sample != "" && regexpMatch(r.Pattern, sample) && engineMatches(r.Engine, qc.Engine) { + if r.DatasourceID == nil || *r.DatasourceID == 0 || *r.DatasourceID == qc.DatasourceID { + if fieldMatchesEngine(r.MatchField, qc.Engine) { + matched = append(matched, r) + } + } + } + } + return matched +} + +func datasourceAllowed(roles []model.LogScopeRole, ds model.LogDataSource) bool { + for _, r := range roles { + if !engineMatches(r.Engine, ds.Type) { + continue + } + if r.DatasourceID == nil || *r.DatasourceID == 0 || *r.DatasourceID == ds.ID { + return true + } + } + return false +} + +func roleAllowsQuery(r model.LogScopeRole, qc QueryContext) bool { + if !engineMatches(r.Engine, qc.Engine) { + return false + } + if r.DatasourceID != nil && *r.DatasourceID > 0 && *r.DatasourceID != qc.DatasourceID { + return false + } + switch r.MatchField { + case "index": + if qc.Engine != "elasticsearch" { + return false + } + idx := strings.TrimSpace(qc.Index) + if idx == "" { + idx = "*" + } + return regexpMatch(r.Pattern, idx) + case "app", "namespace", "job": + values := extractFieldValues(qc.Engine, qc.Query, r.MatchField) + if len(values) == 0 { + return false + } + for _, v := range values { + if regexpMatch(r.Pattern, v) { + return true + } + } + return false + default: + return false + } +} + +func engineMatches(roleEngine, queryEngine string) bool { + re := strings.ToLower(strings.TrimSpace(roleEngine)) + qe := strings.ToLower(strings.TrimSpace(queryEngine)) + if re == "" || re == "*" { + return true + } + return re == qe +} + +func fieldMatchesEngine(field, engine string) bool { + if field == "index" { + return engine == "elasticsearch" + } + return engine == "loki" +} + +func regexpMatch(pattern, value string) bool { + if pattern == "" || value == "" { + return false + } + matched, err := regexp.MatchString(pattern, value) + return err == nil && matched +} + +func extractFieldValues(engine, query, field string) []string { + if engine == "loki" { + return extractLokiLabelValues(query, field) + } + if field == "app" { + return extractESAppValues(query) + } + return nil +} + +func extractLokiLabelValues(query, field string) []string { + m := lokiSelectorRe.FindStringSubmatch(query) + if len(m) < 2 { + return nil + } + inner := m[1] + var values []string + for _, part := range lokiLabelRe.FindAllStringSubmatch(inner, -1) { + if len(part) < 7 || part[1] != field { + continue + } + op := part[2] + val := part[4] + if val == "" { + val = part[5] + } + if val == "" { + val = part[6] + } + if op == "=" || op == "=~" { + values = append(values, val) + } + } + return values +} + +func extractESAppValues(query string) []string { + var values []string + for _, m := range esAppRe.FindAllStringSubmatch(query, -1) { + if len(m) < 2 { + continue + } + v := strings.Trim(m[1], `"'`) + if v != "" { + values = append(values, v) + } + } + return values +} + +// DescribeRole 策略可读描述 // Logan.Li +func DescribeRole(r model.LogScopeRole) string { + ds := "全部数据源" + if r.DatasourceID != nil && *r.DatasourceID > 0 { + ds = fmt.Sprintf("数据源#%d", *r.DatasourceID) + } + return fmt.Sprintf("%s · %s · %s=%s", r.Engine, ds, r.MatchField, r.Pattern) +} \ No newline at end of file diff --git a/backend/pkg/remoteexec/executor.go b/backend/pkg/remoteexec/executor.go index 76c68dc..3349a42 100644 --- a/backend/pkg/remoteexec/executor.go +++ b/backend/pkg/remoteexec/executor.go @@ -6,6 +6,7 @@ package remoteexec import ( "bytes" "fmt" + "io" "log" "net" "os" @@ -191,6 +192,112 @@ func UploadFile(cfg SSHConfig, localPath, remotePath string) (*UploadResult, err return result, nil } +// DownloadFile 从远程主机下载文件到本地(二进制安全,供流水线回拉产物) // Logan.Li +func DownloadFile(cfg SSHConfig, remotePath, localPath string) error { + client, err := connect(cfg) + if err != nil { + return err + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("创建SSH会话失败: %v", err) + } + defer session.Close() + + stdout, err := session.StdoutPipe() + if err != nil { + return fmt.Errorf("获取stdout失败: %v", err) + } + + remoteDir := filepath.Dir(remotePath) + mkdirCmd := fmt.Sprintf("mkdir -p %s", remoteDir) + _, _ = execWithClient(client, cfg, mkdirCmd, 30*time.Second) + + if err := session.Start(fmt.Sprintf("cat %s", shellQuote(remotePath))); err != nil { + return fmt.Errorf("启动远程读取失败: %v", err) + } + + if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil { + return err + } + f, err := os.Create(localPath) + if err != nil { + return err + } + defer f.Close() + + if _, err := io.Copy(f, stdout); err != nil { + return fmt.Errorf("写入本地文件失败: %v", err) + } + if err := session.Wait(); err != nil { + return fmt.Errorf("远程读取失败: %v", err) + } + return nil +} + +// StreamRemoteTar 将远程目录打包为 tar.gz 流式写入本地文件 // Logan.Li +func StreamRemoteTar(cfg SSHConfig, remoteDir, localTarPath string, timeout time.Duration) error { + if timeout <= 0 { + timeout = 30 * time.Minute + } + client, err := connect(cfg) + if err != nil { + return err + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("创建SSH会话失败: %v", err) + } + defer session.Close() + + stdout, err := session.StdoutPipe() + if err != nil { + return fmt.Errorf("获取stdout失败: %v", err) + } + + cmd := fmt.Sprintf("tar czf - -C %s .", shellQuote(remoteDir)) + if err := session.Start(cmd); err != nil { + return fmt.Errorf("启动远程打包失败: %v", err) + } + + if err := os.MkdirAll(filepath.Dir(localTarPath), 0755); err != nil { + return err + } + f, err := os.Create(localTarPath) + if err != nil { + return err + } + defer f.Close() + + done := make(chan error, 1) + go func() { + _, copyErr := io.Copy(f, stdout) + done <- copyErr + }() + + select { + case copyErr := <-done: + if copyErr != nil { + return fmt.Errorf("写入 tar 失败: %v", copyErr) + } + case <-time.After(timeout): + return fmt.Errorf("下载工作区超时") + } + if err := session.Wait(); err != nil { + return fmt.Errorf("远程打包失败: %v", err) + } + return nil +} + +// shellQuote 简单 shell 路径转义 // Logan.Li +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + // BatchUpload 批量上传文件到多台服务器(并发)。 func BatchUpload(servers []SSHConfig, localPath, remotePath string, maxConcurrency int) []UploadResult { if maxConcurrency <= 0 { diff --git a/backend/pkg/siteprobe/alert_resolve.go b/backend/pkg/siteprobe/alert_resolve.go new file mode 100644 index 0000000..d78ca51 --- /dev/null +++ b/backend/pkg/siteprobe/alert_resolve.go @@ -0,0 +1,24 @@ +package siteprobe + +import ( + "time" + + "ezops/model" + + "gorm.io/gorm" +) + +// ResolveRecoveredProbeAlerts 拨测恢复后立即关闭探测类活跃告警 // Logan.Li +func ResolveRecoveredProbeAlerts(db *gorm.DB, taskID uint, lastResult string) { + if lastResult != "success" { + return + } + now := time.Now() + db.Model(&model.SiteAlert{}). + Where("task_id = ? AND status = ? AND trigger_type IN ?", + taskID, model.SiteAlertStatusActive, + []string{"probe_fail", "probe_timeout", "http_status_error"}). + Updates(map[string]interface{}{ + "status": model.SiteAlertStatusResolved, "resolved_at": now, + }) +} \ No newline at end of file diff --git a/backend/pkg/siteprobe/defaults.go b/backend/pkg/siteprobe/defaults.go new file mode 100644 index 0000000..31db38e --- /dev/null +++ b/backend/pkg/siteprobe/defaults.go @@ -0,0 +1,41 @@ +package siteprobe + +import "strings" + +// DefaultProbeFrequency 各协议推荐默认拨测间隔(秒)// Logan.Li +func DefaultProbeFrequency(taskType string) int { + switch strings.ToLower(taskType) { + case "ssl": + return 3600 // 证书日级变化,1 小时足够 + case "dns": + return 300 // 5 分钟 + case "tcp", "ping": + return 120 // 2 分钟 + default: + return 60 // HTTP 1 分钟 + } +} + +// MinProbeFrequency 各协议最小拨测间隔(秒),防止频率过高压垮平台 // Logan.Li +func MinProbeFrequency(taskType string) int { + switch strings.ToLower(taskType) { + case "ssl": + return 600 // 最少 10 分钟 + case "dns": + return 60 + default: + return 30 + } +} + +// NormalizeProbeFrequency 规范化拨测频率 // Logan.Li +func NormalizeProbeFrequency(taskType string, freq int) int { + if freq <= 0 { + return DefaultProbeFrequency(taskType) + } + min := MinProbeFrequency(taskType) + if freq < min { + return min + } + return freq +} \ No newline at end of file diff --git a/backend/pkg/siteprobe/worker.go b/backend/pkg/siteprobe/worker.go index 992474b..57952d2 100644 --- a/backend/pkg/siteprobe/worker.go +++ b/backend/pkg/siteprobe/worker.go @@ -67,15 +67,22 @@ func (w *Worker) runOnce() { var tasks []model.SiteTask w.db.Where("status = ?", model.SiteTaskStatusRunning).Find(&tasks) now := time.Now() + sem := make(chan struct{}, 8) // 最多 8 路并发拨测,避免串行阻塞 // Logan.Li + var wg sync.WaitGroup for _, task := range tasks { - if task.Frequency <= 0 { - task.Frequency = 60 - } + task.Frequency = NormalizeProbeFrequency(string(task.TaskType), task.Frequency) if task.LastCheckAt != nil && now.Sub(*task.LastCheckAt) < time.Duration(task.Frequency)*time.Second { continue } - w.probeTask(task) + wg.Add(1) + go func(t model.SiteTask) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + w.probeTask(t) + }(task) } + wg.Wait() } func (w *Worker) probeTask(task model.SiteTask) { @@ -110,9 +117,11 @@ func (w *Worker) probeTask(task model.SiteTask) { avg = (task.AvgResponse + outcome.ResponseTime) / 2 } w.db.Model(&model.SiteTask{}).Where("id = ?", task.ID).Updates(map[string]interface{}{ - "task_type": task.TaskType, - "last_result": lastResult, + "task_type": task.TaskType, + "last_result": lastResult, "last_check_at": now, - "avg_response": avg, + "avg_response": avg, }) + // 拨测恢复后即时关闭探测类告警,与统计栏 down_count 同步 // Logan.Li + ResolveRecoveredProbeAlerts(w.db, task.ID, lastResult) } \ No newline at end of file diff --git a/backend/pkg/toolinstaller/installer.go b/backend/pkg/toolinstaller/installer.go index 8b04ce6..2efcbb1 100644 --- a/backend/pkg/toolinstaller/installer.go +++ b/backend/pkg/toolinstaller/installer.go @@ -1,6 +1,7 @@ package toolinstaller import ( + "bytes" "fmt" "io" "log" @@ -45,15 +46,26 @@ func (ti *ToolInstaller) ensureOnce(name string) *sync.Once { return o } -// ForceInstall unconditionally installs a tool (bypasses sync.Once). -// Used by the manual "安装" button in the tool config UI. -// The version parameter overrides DefaultVersion if non-empty. -func (ti *ToolInstaller) ForceInstall(toolName string, version string) error { +// InstallResult 工具安装结果(含脚本输出,供审计日志)// Logan.Li +type InstallResult struct { + Output string + VerifyOut string + Installed bool + Version string + Err error +} + +// ForceInstall unconditionally installs a tool(重置 Once,允许重复安装)// Logan.Li +func (ti *ToolInstaller) ForceInstall(toolName string, version string) InstallResult { var tool model.DeployTool if err := ti.db.Where("name = ?", toolName).First(&tool).Error; err != nil { - return fmt.Errorf("tool %s not found in DB: %w", toolName, err) + return InstallResult{Err: fmt.Errorf("tool %s not found in DB: %w", toolName, err)} } + ti.mu.Lock() + delete(ti.onces, toolName) + ti.mu.Unlock() + // Override version if specified if version != "" && version != tool.DefaultVersion { ti.db.Model(&tool).Update("default_version", version) @@ -101,7 +113,8 @@ func (ti *ToolInstaller) EnsureAvailable(toolName string) error { var installErr error once := ti.ensureOnce(toolName) once.Do(func() { - installErr = ti.installTool(&tool) + res := ti.installTool(&tool) + installErr = res.Err }) // If install failed, reset the Once so next call can retry if installErr != nil { @@ -115,70 +128,206 @@ func (ti *ToolInstaller) EnsureAvailable(toolName string) error { } } -// checkInstalled runs the tool's check command and returns the version string. -func (ti *ToolInstaller) checkInstalled(tool model.DeployTool) (string, error) { - // First try LookPath - if tool.BinaryName != "" { +// VerifyTool 检测工具是否已安装(文件存在 + 可执行验证,不依赖 PATH)// Logan.Li +func (ti *ToolInstaller) VerifyTool(tool model.DeployTool) (bool, string) { + switch tool.Name { + case "jdk": + for _, p := range []string{"/usr/local/bin/java", "/usr/local/share/jdk/bin/java"} { + if st, err := os.Stat(p); err == nil && !st.IsDir() { + cmd := exec.Command(p, "-version") + out, _ := cmd.CombinedOutput() + ver := strings.TrimSpace(strings.Split(string(out), "\n")[0]) + if ver != "" { + return true, ver + } + return true, "installed" + } + } + case "maven": + for _, p := range []string{"/usr/local/bin/mvn", "/usr/local/share/maven/bin/mvn"} { + if st, err := os.Stat(p); err == nil && !st.IsDir() { + cmd := exec.Command(p, "--version") + out, err := cmd.Output() + if err == nil { + return true, strings.TrimSpace(strings.Split(string(out), "\n")[0]) + } + return true, "installed" + } + } + case "nvm": + nvmSh := "/usr/local/share/nvm/nvm.sh" + if st, err := os.Stat(nvmSh); err == nil && !st.IsDir() { + cmd := exec.Command("bash", "-c", fmt.Sprintf(`. "%s" && nvm --version`, nvmSh)) + out, err := cmd.Output() + if err == nil { + return true, strings.TrimSpace(string(out)) + } + return true, tool.DefaultVersion + } + case "golang": + for _, p := range []string{"/usr/local/bin/go", "/usr/local/go/bin/go"} { + if st, err := os.Stat(p); err == nil && !st.IsDir() { + cmd := exec.Command(p, "version") + out, _ := cmd.CombinedOutput() + ver := strings.TrimSpace(string(out)) + if ver != "" { + return true, ver + } + return true, "installed" + } + } + case "nodejs": + for _, p := range []string{"/usr/local/bin/node"} { + if st, err := os.Stat(p); err == nil && !st.IsDir() { + cmd := exec.Command(p, "--version") + out, _ := cmd.CombinedOutput() + ver := strings.TrimSpace(string(out)) + if ver != "" { + return true, ver + } + return true, "installed" + } + } + default: + if ver, err := ti.runToolCheck(tool); err == nil { + return true, ver + } + } + return false, "" +} + +// SyncToolStatus 根据磁盘实际状态刷新 DB(修复审计成功但页面仍失败/安装中)// Logan.Li +func (ti *ToolInstaller) SyncToolStatus(toolID uint) model.DeployTool { + var tool model.DeployTool + if err := ti.db.First(&tool, toolID).Error; err != nil { + return tool + } + ok, ver := ti.VerifyTool(tool) + if ok { + ti.updateStatus(tool.ID, model.ToolInstalled, ver, "") + tool.Status = model.ToolInstalled + tool.InstalledVer = ver + tool.ErrorMsg = "" + } else if tool.Status == model.ToolInstalling { + // 保持安装中,由超时逻辑处理 + } else if tool.Status == model.ToolInstalled { + // 已标记安装但文件不存在 — 降为未安装 + ti.updateStatus(tool.ID, model.ToolNotInstalled, "", "") + tool.Status = model.ToolNotInstalled + } + ti.db.First(&tool, toolID) + return tool +} + +// runToolCheck 执行 PATH/检测命令(不回调 VerifyTool,避免递归栈溢出)// Logan.Li +func (ti *ToolInstaller) runToolCheck(tool model.DeployTool) (string, error) { + if tool.BinaryName != "" && tool.BinaryName != "nvm" { if _, err := exec.LookPath(tool.BinaryName); err != nil { return "", fmt.Errorf("binary %s not found", tool.BinaryName) } } - - // Run check command if provided if tool.CheckCommand != "" { - cmd := exec.Command("sh", "-c", tool.CheckCommand) + shell := "sh" + if tool.Name == "nvm" || tool.Name == "nodejs" { + shell = "bash" + } + cmd := exec.Command(shell, "-c", tool.CheckCommand) out, err := cmd.Output() if err != nil { return "", err } - return strings.TrimSpace(strings.Split(string(out), "\n")[0]), nil + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 0 { + return "", fmt.Errorf("tool %s check empty output", tool.Name) + } + return strings.TrimSpace(lines[0]), nil } + return "", fmt.Errorf("tool %s not available", tool.Name) +} - return "unknown", nil +// checkInstalled runs the tool's check command and returns the version string. +func (ti *ToolInstaller) checkInstalled(tool model.DeployTool) (string, error) { + if ok, ver := ti.VerifyTool(tool); ok { + return ver, nil + } + return ti.runToolCheck(tool) } // installTool performs the actual installation. -func (ti *ToolInstaller) installTool(tool *model.DeployTool) error { +func (ti *ToolInstaller) installTool(tool *model.DeployTool) InstallResult { log.Printf("[ToolInstaller] Installing %s (method=%s)...", tool.Name, tool.InstallMethod) // Mark as installing ti.updateStatus(tool.ID, model.ToolInstalling, "", "") + var installOut string var err error if tool.InstallScript != "" { - err = ti.runInstallScript(tool) + installOut, err = ti.runInstallScript(tool) } else if tool.DownloadURL != "" { - err = ti.downloadBinary(tool) + installOut, err = ti.downloadBinary(tool) } else { err = fmt.Errorf("no install script or download URL configured for %s", tool.Name) } if err != nil { errMsg := err.Error() + if installOut != "" { + errMsg = installOut + "\n---\n" + errMsg + } log.Printf("[ToolInstaller] ERROR installing %s: %s", tool.Name, errMsg) - ti.updateStatus(tool.ID, model.ToolFailed, "", errMsg) - return fmt.Errorf("failed to install %s: %w", tool.Name, err) + ti.updateStatus(tool.ID, model.ToolFailed, "", truncateInstallLog(errMsg, 512)) + return InstallResult{Output: installOut, Err: fmt.Errorf("failed to install %s: %w", tool.Name, err)} } - // Verify installation - ver, checkErr := ti.checkInstalled(*tool) - if checkErr != nil { - log.Printf("[ToolInstaller] WARNING: %s installed but check failed: %v", tool.Name, checkErr) - ti.updateStatus(tool.ID, model.ToolInstalled, "unknown", "") - } else { + // 安装后验证(优先文件检测,避免 PATH/source 问题)// Logan.Li + verifyOut := ti.captureVerifyOutput(*tool) + if ok, ver := ti.VerifyTool(*tool); ok { log.Printf("[ToolInstaller] %s installed successfully: %s", tool.Name, ver) ti.updateStatus(tool.ID, model.ToolInstalled, ver, "") + return InstallResult{Output: installOut, VerifyOut: verifyOut, Installed: true, Version: ver} } + log.Printf("[ToolInstaller] WARNING: %s script ok but verify failed", tool.Name) + failMsg := "安装脚本执行完成但验证未通过,请点击「测试」重新检测" + ti.updateStatus(tool.ID, model.ToolFailed, "", failMsg) + return InstallResult{ + Output: installOut, VerifyOut: verifyOut, + Err: fmt.Errorf("verify failed after install %s", tool.Name), + } +} - return nil +// captureVerifyOutput 执行验证命令并捕获输出(供审计)// Logan.Li +func (ti *ToolInstaller) captureVerifyOutput(tool model.DeployTool) string { + if tool.CheckCommand == "" { + return "" + } + shell := "sh" + if tool.Name == "nvm" || tool.Name == "nodejs" { + shell = "bash" + } + cmd := exec.Command(shell, "-c", tool.CheckCommand) + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(out)) + if err != nil && text == "" { + return err.Error() + } + return text +} + +func truncateInstallLog(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) > n { + return s[:n] + "..." + } + return s } // runInstallScript executes the tool's install script in a subshell. -func (ti *ToolInstaller) runInstallScript(tool *model.DeployTool) error { +func (ti *ToolInstaller) runInstallScript(tool *model.DeployTool) (string, error) { // Write script to temp file tmpFile, err := os.CreateTemp("", fmt.Sprintf("tool-install-%s-*.sh", tool.Name)) if err != nil { - return fmt.Errorf("cannot create temp script: %w", err) + return "", fmt.Errorf("cannot create temp script: %w", err) } defer os.Remove(tmpFile.Name()) @@ -189,21 +338,28 @@ func (ti *ToolInstaller) runInstallScript(tool *model.DeployTool) error { script := fmt.Sprintf("#!/bin/sh\nset -e\nTOOL_VERSION=%s\n%s\n", version, tool.InstallScript) if _, err := tmpFile.WriteString(script); err != nil { - return fmt.Errorf("cannot write temp script: %w", err) + return "", fmt.Errorf("cannot write temp script: %w", err) } tmpFile.Close() os.Chmod(tmpFile.Name(), 0755) - cmd := exec.Command("sh", tmpFile.Name()) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + shell := "sh" + if tool.Name == "nvm" || tool.Name == "nodejs" { + shell = "bash" + } + cmd := exec.Command(shell, tmpFile.Name()) + var buf bytes.Buffer + // 同时输出到容器日志便于排障 // Logan.Li + cmd.Stdout = io.MultiWriter(os.Stdout, &buf) + cmd.Stderr = io.MultiWriter(os.Stderr, &buf) + err = cmd.Run() + return buf.String(), err } // downloadBinary downloads a binary file to the install path. // Uses multi-mirror fallback: tries each mirror URL in order, with HEAD verification // to skip dead mirrors before attempting a full download. -func (ti *ToolInstaller) downloadBinary(tool *model.DeployTool) error { +func (ti *ToolInstaller) downloadBinary(tool *model.DeployTool) (string, error) { version := tool.DefaultVersion if version == "" { // Try to get latest version @@ -223,24 +379,28 @@ func (ti *ToolInstaller) downloadBinary(tool *model.DeployTool) error { // Resolve mirror URLs — domestic mirrors prioritized when in China mirrorURLs := GetMirrorsForTool(tool.Name, tool.DownloadURL) if len(mirrorURLs) == 0 { - return fmt.Errorf("no download URLs configured for %s", tool.Name) + return "", fmt.Errorf("no download URLs configured for %s", tool.Name) } // Try each mirror in order var lastErr error + var logBuf strings.Builder for i, urlTemplate := range mirrorURLs { url := strings.ReplaceAll(urlTemplate, "{version}", version) log.Printf("[ToolInstaller] Downloading %s from mirror[%d]: %s", tool.Name, i, url) + logBuf.WriteString(fmt.Sprintf("[%d] %s\n", i, url)) err := ti.tryDownload(url, tool) if err == nil { - return nil // success + logBuf.WriteString(fmt.Sprintf("OK: downloaded from mirror[%d]\n", i)) + return logBuf.String(), nil } lastErr = err + logBuf.WriteString(fmt.Sprintf("FAIL mirror[%d]: %v\n", i, err)) log.Printf("[ToolInstaller] Mirror[%d] failed for %s: %v, trying next...", i, tool.Name, err) } - return fmt.Errorf("all %d mirrors exhausted for %s, last error: %w", len(mirrorURLs), tool.Name, lastErr) + return logBuf.String(), fmt.Errorf("all %d mirrors exhausted for %s, last error: %w", len(mirrorURLs), tool.Name, lastErr) } // tryDownload attempts a single download from a URL. @@ -314,10 +474,12 @@ func (ti *ToolInstaller) updateStatus(toolID uint, status model.ToolStatus, vers "last_check": &now, } if version != "" { - updates["installed_version"] = version + updates["installed_ver"] = version // 与 GORM 列名一致 // Logan.Li } if errMsg != "" { updates["error_msg"] = errMsg + } else if status == model.ToolInstalled { + updates["error_msg"] = "" } ti.db.Model(&model.DeployTool{}).Where("id = ?", toolID).Updates(updates) } @@ -393,4 +555,49 @@ func (ti *ToolInstaller) UpgradeTools() { "description": up.description, }) } + + // 补全新增工具(如 golang)并校正排序 // Logan.Li + now := time.Now() + for _, def := range model.PredefinedTools() { + var existing model.DeployTool + if err := ti.db.Where("name = ?", def.Name).First(&existing).Error; err != nil { + def.CreatedAt = now + def.UpdatedAt = now + def.Status = model.ToolNotInstalled + if err := ti.db.Create(&def).Error; err != nil { + log.Printf("[ToolInstaller] Failed to seed missing tool %s: %v", def.Name, err) + } else { + log.Printf("[ToolInstaller] Added missing tool: %s", def.Name) + } + } + } + sortBumps := map[string]int{"jdk": 1, "maven": 2, "golang": 3, "nvm": 4, "nodejs": 5, "git": 6, "docker-cli": 7} + for name, order := range sortBumps { + ti.db.Model(&model.DeployTool{}).Where("name = ?", name).Update("sort_order", order) + } + + // 同步 JDK/Maven/Go/NVM 安装脚本与检测命令修复 // Logan.Li + type toolPatch struct { + script string + check string + } + patchSync := map[string]toolPatch{} + for _, t := range model.PredefinedTools() { + if t.Name == "jdk" || t.Name == "maven" || t.Name == "golang" || t.Name == "nvm" || t.Name == "nodejs" { + patchSync[t.Name] = toolPatch{script: t.InstallScript, check: t.CheckCommand} + } + } + for name, p := range patchSync { + updates := map[string]interface{}{ + "install_script": p.script, + "check_command": p.check, + } + if name == "nodejs" { + updates["default_version"] = "lts" + } + if name == "golang" { + updates["default_version"] = "1.24.0" + } + ti.db.Model(&model.DeployTool{}).Where("name = ?", name).Updates(updates) + } } diff --git a/backend/pkg/toolinstaller/mirrors.go b/backend/pkg/toolinstaller/mirrors.go index ac97530..c3c1a94 100644 --- a/backend/pkg/toolinstaller/mirrors.go +++ b/backend/pkg/toolinstaller/mirrors.go @@ -46,6 +46,12 @@ var toolMirrors = map[string][]ToolMirror{ {Name: "Helm官方", URL: "https://get.helm.sh/helm-{version}-linux-amd64.tar.gz", Location: "global"}, {Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/kubernetes/helm/{version}/helm-{version}-linux-amd64.tar.gz", Location: "domestic"}, }, + "golang": { + {Name: "Go官方", URL: "https://go.dev/dl/go{version}.linux-amd64.tar.gz", Location: "global"}, + {Name: "Google CN", URL: "https://golang.google.cn/dl/go{version}.linux-amd64.tar.gz", Location: "domestic"}, + {Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/golang/go{version}.linux-amd64.tar.gz", Location: "domestic"}, + {Name: "中科大镜像", URL: "https://mirrors.ustc.edu.cn/golang/go{version}.linux-amd64.tar.gz", Location: "domestic"}, + }, } // GetMirrorsForTool 返回工具的镜像源列表(按网络环境排序)。 diff --git a/backend/pkg/toolinstaller/uninstall.go b/backend/pkg/toolinstaller/uninstall.go new file mode 100644 index 0000000..1a942f9 --- /dev/null +++ b/backend/pkg/toolinstaller/uninstall.go @@ -0,0 +1,193 @@ +package toolinstaller + +import ( + "bytes" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "ezops/model" +) + +// UninstallResult 工具卸载结果(含脚本输出,供审计日志)// Logan.Li +type UninstallResult struct { + Output string + Err error +} + +// Uninstall 卸载指定工具并重置 DB 状态(允许重新安装)// Logan.Li +func (ti *ToolInstaller) Uninstall(toolName string) UninstallResult { + var tool model.DeployTool + if err := ti.db.Where("name = ?", toolName).First(&tool).Error; err != nil { + return UninstallResult{Err: fmt.Errorf("tool %s not found in DB: %w", toolName, err)} + } + + ti.mu.Lock() + delete(ti.onces, toolName) + ti.mu.Unlock() + + var buf bytes.Buffer + if err := ti.uninstallTool(&tool, &buf); err != nil { + out := strings.TrimSpace(buf.String()) + log.Printf("[ToolInstaller] ERROR uninstalling %s: %v", toolName, err) + ti.resetUninstallStatus(tool.ID, out+": "+err.Error()) + return UninstallResult{Output: out, Err: err} + } + + ti.resetUninstallStatus(tool.ID, "") + out := strings.TrimSpace(buf.String()) + log.Printf("[ToolInstaller] %s uninstalled successfully", toolName) + return UninstallResult{Output: out} +} + +func (ti *ToolInstaller) resetUninstallStatus(toolID uint, errMsg string) { + now := time.Now() + updates := map[string]interface{}{ + "status": model.ToolNotInstalled, + "installed_ver": "", + "last_check": &now, + } + if errMsg != "" { + updates["error_msg"] = errMsg + } else { + updates["error_msg"] = "" + } + ti.updateStatusMap(toolID, updates) +} + +func (ti *ToolInstaller) updateStatusMap(toolID uint, updates map[string]interface{}) { + ti.db.Model(&model.DeployTool{}).Where("id = ?", toolID).Updates(updates) +} + +func (ti *ToolInstaller) uninstallTool(tool *model.DeployTool, buf *bytes.Buffer) error { + switch tool.Name { + case "jdk": + return ti.uninstallJDK(buf) + case "maven": + return ti.uninstallMaven(buf) + case "golang": + return ti.uninstallGolang(buf) + case "nvm": + return ti.uninstallNVM(buf) + case "nodejs": + return ti.uninstallNodeJS(buf) + case "git": + return ti.uninstallApkPackage(buf, "git", "git") + case "docker-cli": + return ti.uninstallApkPackage(buf, "docker-cli", "docker") + default: + return ti.uninstallBinary(tool, buf) + } +} + +func (ti *ToolInstaller) uninstallJDK(buf *bytes.Buffer) error { + buf.WriteString("[JDK] Removing symlinks and /usr/local/share/jdk\n") + removeIfExists(buf, "/usr/local/bin/java", "/usr/local/bin/javac", "/usr/local/bin/jar") + removeDirIfExists(buf, "/usr/local/share/jdk") + return nil +} + +func (ti *ToolInstaller) uninstallMaven(buf *bytes.Buffer) error { + buf.WriteString("[Maven] Removing symlinks and /usr/local/share/maven\n") + removeIfExists(buf, "/usr/local/bin/mvn") + removeDirIfExists(buf, "/usr/local/share/maven") + return nil +} + +func (ti *ToolInstaller) uninstallGolang(buf *bytes.Buffer) error { + buf.WriteString("[Go] Removing GOROOT and symlinks\n") + removeIfExists(buf, "/usr/local/bin/go", "/usr/local/bin/gofmt", "/etc/profile.d/golang.sh") + removeDirIfExists(buf, "/usr/local/go") + return nil +} + +func (ti *ToolInstaller) uninstallNVM(buf *bytes.Buffer) error { + buf.WriteString("[NVM] Removing NVM and Node versions\n") + removeIfExists(buf, "/usr/local/bin/node", "/usr/local/bin/npm", "/usr/local/bin/npx", "/etc/profile.d/nvm.sh") + removeDirIfExists(buf, "/usr/local/share/nvm") + return nil +} + +func (ti *ToolInstaller) uninstallNodeJS(buf *bytes.Buffer) error { + buf.WriteString("[Node.js] Removing symlinks and NVM node versions\n") + removeIfExists(buf, "/usr/local/bin/node", "/usr/local/bin/npm", "/usr/local/bin/npx") + + nvmSh := "/usr/local/share/nvm/nvm.sh" + if st, err := os.Stat(nvmSh); err == nil && !st.IsDir() { + script := fmt.Sprintf(`export NVM_DIR="/usr/local/share/nvm" +. "%s" +CUR=$(nvm current 2>/dev/null || true) +if [ -n "$CUR" ] && [ "$CUR" != "none" ] && [ "$CUR" != "system" ]; then + nvm uninstall "$CUR" 2>/dev/null || true +fi +rm -rf "$NVM_DIR/versions/node" +`, nvmSh) + cmd := exec.Command("bash", "-c", script) + out, err := cmd.CombinedOutput() + if len(out) > 0 { + buf.Write(out) + } + if err != nil { + buf.WriteString(fmt.Sprintf("[Node.js] nvm cleanup warning: %v\n", err)) + } + } + removeDirIfExists(buf, "/usr/local/share/nvm/versions/node") + return nil +} + +func (ti *ToolInstaller) uninstallApkPackage(buf *bytes.Buffer, pkg, binary string) error { + buf.WriteString(fmt.Sprintf("[%s] Trying apk del %s\n", binary, pkg)) + if _, err := exec.LookPath("apk"); err != nil { + buf.WriteString(fmt.Sprintf("[%s] apk not found, skip package removal\n", binary)) + return nil + } + cmd := exec.Command("apk", "del", "--no-cache", pkg) + out, err := cmd.CombinedOutput() + if len(out) > 0 { + buf.Write(out) + } + if err != nil { + // 可能未通过 apk 安装,仅标记卸载完成 // Logan.Li + buf.WriteString(fmt.Sprintf("[%s] apk del skipped: %v\n", binary, err)) + } + return nil +} + +func (ti *ToolInstaller) uninstallBinary(tool *model.DeployTool, buf *bytes.Buffer) error { + path := tool.InstallPath + if path == "" && tool.BinaryName != "" { + path = filepath.Join("/usr/local/bin", tool.BinaryName) + } + if path == "" { + return fmt.Errorf("no install path configured for %s", tool.Name) + } + buf.WriteString(fmt.Sprintf("[%s] Removing %s\n", tool.Name, path)) + removeIfExists(buf, path) + return nil +} + +func removeIfExists(buf *bytes.Buffer, paths ...string) { + for _, p := range paths { + if err := os.Remove(p); err != nil { + if !os.IsNotExist(err) { + buf.WriteString(fmt.Sprintf("remove %s: %v\n", p, err)) + } + } else { + buf.WriteString(fmt.Sprintf("removed %s\n", p)) + } + } +} + +func removeDirIfExists(buf *bytes.Buffer, dir string) { + if err := os.RemoveAll(dir); err != nil { + if !os.IsNotExist(err) { + buf.WriteString(fmt.Sprintf("remove dir %s: %v\n", dir, err)) + } + } else { + buf.WriteString(fmt.Sprintf("removed dir %s\n", dir)) + } +} \ No newline at end of file diff --git a/backend/router/routes.go b/backend/router/routes.go index 0d77498..b8eeb51 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -215,6 +215,17 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c admin.POST("/users/:id/item-roles", itemRoleHandler.AssignItemRole) admin.DELETE("/users/:id/item-roles/:roleId", itemRoleHandler.RemoveItemRole) + // 日志查询范围权限(正则匹配 ES 索引 / Loki Label)// Logan.Li + logScopeRoleHandler := controller.NewLogScopeRoleHandler(db) + admin.GET("/log-scope-roles", logScopeRoleHandler.ListLogScopeRoles) + admin.POST("/log-scope-roles", logScopeRoleHandler.CreateLogScopeRole) + admin.PUT("/log-scope-roles/:id", logScopeRoleHandler.UpdateLogScopeRole) + admin.DELETE("/log-scope-roles/:id", logScopeRoleHandler.DeleteLogScopeRole) + admin.GET("/log-scope-roles/users/:id", logScopeRoleHandler.ListUserLogScopeRoles) + admin.POST("/log-scope-roles/users/:id", logScopeRoleHandler.AssignLogScopeRole) + admin.DELETE("/log-scope-roles/users/:userId/:assignId", logScopeRoleHandler.RemoveLogScopeRole) + admin.POST("/log-scope-roles/preview", logScopeRoleHandler.PreviewLogScope) + // 审计策略(保留天数 + 立即清理) // Logan.Li auditSettingsHandler := controller.NewAuditSettingsHandler(db) admin.GET("/audit-settings", auditSettingsHandler.GetAuditSettings) @@ -653,6 +664,17 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c environments.DELETE("/:id", pipelineHandler.DeleteEnvironment) } + // 构建节点路由(Jenkins Agent 风格 SSH 节点) // Logan.Li + buildNodes := authorized.Group("/deploy/build-nodes") + { + buildNodes.GET("", pipelineHandler.ListBuildNodes) + buildNodes.PUT("/master", pipelineHandler.UpdateBuildMaster) + buildNodes.POST("", pipelineHandler.CreateBuildNode) + buildNodes.PUT("/:id", pipelineHandler.UpdateBuildNode) + buildNodes.DELETE("/:id", pipelineHandler.DeleteBuildNode) + buildNodes.POST("/:id/test", pipelineHandler.TestBuildNode) + } + // 应用发布路由 releases := authorized.Group("/releases") { @@ -690,6 +712,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c tools.GET("/:id", toolConfigHandler.GetTool) tools.PUT("/:id", toolConfigHandler.UpdateTool) tools.POST("/:id/install", toolConfigHandler.InstallTool) + tools.POST("/:id/uninstall", toolConfigHandler.UninstallTool) tools.POST("/:id/check", toolConfigHandler.CheckToolStatus) tools.GET("/:id/versions", toolConfigHandler.GetToolVersions) } @@ -745,7 +768,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // 日志监控路由 // Logan.Li logHandler := controller.NewLogHandler(db) - logProxyHandler := controller.NewLogProxyHandler(logDSHandler) + logProxyHandler := controller.NewLogProxyHandler(logDSHandler, db) logs := authorized.Group("/logs") { logs.GET("", logHandler.QueryLogs) @@ -754,11 +777,17 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c logs.DELETE("/sources/:id", logHandler.DeleteSource) logs.POST("/loki/query", logProxyHandler.QueryLoki) logs.POST("/elasticsearch/query", logProxyHandler.QueryES) + logs.GET("/loki/labels", logProxyHandler.ListLokiLabels) + logs.GET("/loki/labels/:name/values", logProxyHandler.ListLokiLabelValues) + logs.POST("/loki/histogram", logProxyHandler.LokiHistogram) + logs.POST("/elasticsearch/histogram", logProxyHandler.ESHistogram) + logs.GET("/elasticsearch/mapping", logProxyHandler.ESMapping) } logDSGroup := authorized.Group("/log-datasources") { logDSGroup.GET("", logDSHandler.List) logDSGroup.GET("/indices", logDSHandler.ListIndices) + logDSGroup.GET("/:id/indices", logDSHandler.ListIndicesByID) logDSGroup.GET("/:id", logDSHandler.Get) logDSGroup.POST("", logDSHandler.Create) logDSGroup.PUT("/:id", logDSHandler.Update) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 97bb4a9..eac7029 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,7 +50,7 @@ const DeployCredentials = lazy(() => import('@/components/deploy/DeployCredentia const PipelineEditor = lazy(() => import('@/components/deploy/PipelineEditor').then(m => ({ default: m.default }))); const PipelineRunDetail = lazy(() => import('@/components/deploy/PipelineRunDetail').then(m => ({ default: m.default }))); const DeployRBAC = lazy(() => import('@/components/deploy/DeployRBAC').then(m => ({ default: m.default }))); -const UserGroupsManager = lazy(() => import('@/components/deploy/UserGroupsManager').then(m => ({ default: m.default }))); + const CronJobManager = lazy(() => import('@/components/task/CronJobManager').then(m => ({ default: m.CronJobManager }))); const RemoteExecPage = lazy(() => import('@/components/task/RemoteExecPage').then(m => ({ default: m.RemoteExecPage }))); const AIOpsAssistant = lazy(() => import('@/components/aiops/AIOpsAssistant').then(m => ({ default: m.AIOpsAssistant }))); @@ -172,8 +172,9 @@ function App() { } /> } /> } /> + } /> } /> - } /> + } /> } /> {/* Monitor */} diff --git a/frontend/src/api/deploy/build-nodes.ts b/frontend/src/api/deploy/build-nodes.ts new file mode 100644 index 0000000..f8321d0 --- /dev/null +++ b/frontend/src/api/deploy/build-nodes.ts @@ -0,0 +1,34 @@ +// 构建节点 API(Jenkins Agent 风格) // Logan.Li + +import { http } from '@/utils/request'; + +export interface BuildNode { + id: number; + name: string; + description?: string; + server_id: number; + labels?: string; + max_executors: number; + workspace_path: string; + enabled: boolean; + running_count?: number; + hostname?: string; + ip?: string; +} + +export interface MasterNodeInfo { + name: string; + max_executors: number; + running_count: number; +} + +export const buildNodeApi = { + list: () => http.get<{ list: BuildNode[]; total: number; master: MasterNodeInfo }>('/deploy/build-nodes'), + updateMaster: (data: { max_executors: number }) => + http.put('/deploy/build-nodes/master', data), + create: (data: Partial) => http.post('/deploy/build-nodes', data), + update: (id: number, data: Partial & { enabled?: boolean }) => + http.put(`/deploy/build-nodes/${id}`, data), + delete: (id: number) => http.delete(`/deploy/build-nodes/${id}`), + test: (id: number) => http.post<{ success: boolean; message: string }>(`/deploy/build-nodes/${id}/test`, {}), +}; \ No newline at end of file diff --git a/frontend/src/api/deploy/config.ts b/frontend/src/api/deploy/config.ts index 82633ec..5255752 100644 --- a/frontend/src/api/deploy/config.ts +++ b/frontend/src/api/deploy/config.ts @@ -42,6 +42,7 @@ export const deployApi = { updateTool: (id: number | string, data: Record) => http.put(`/deploy/tools/${id}`, data), installTool: (id: number | string, version?: string) => http.post(`/deploy/tools/${id}/install`, version ? { version } : {}), + uninstallTool: (id: number | string) => http.post(`/deploy/tools/${id}/uninstall`), checkTool: (id: number | string) => http.post(`/deploy/tools/${id}/check`), getToolVersions: (id: number | string) => http.get(`/deploy/tools/${id}/versions`), }; \ No newline at end of file diff --git a/frontend/src/api/deploy/index.ts b/frontend/src/api/deploy/index.ts index 876fc0c..33e2c98 100644 --- a/frontend/src/api/deploy/index.ts +++ b/frontend/src/api/deploy/index.ts @@ -1,6 +1,7 @@ // CI/CD API 聚合出口 // Logan.Li export { pipelineApi } from './pipeline'; +export { buildNodeApi, type BuildNode } from './build-nodes'; export { releaseApi } from './release'; export { deployApi } from './config'; export { credentialApi, serverCredentialApi } from './credential'; \ No newline at end of file diff --git a/frontend/src/api/log/index.ts b/frontend/src/api/log/index.ts index 1f3ea58..7a246a9 100644 --- a/frontend/src/api/log/index.ts +++ b/frontend/src/api/log/index.ts @@ -4,5 +4,15 @@ export { logCenterApi, type LogDataSource, type LogQueryEntry, + type LogQueryResult, + type HistogramBucket, + type ESMappingField, type TestConnectionResult, } from './log-center'; + +export { + logRbacApi, + type LogScopeRole, + type UserLogScopeRole, + type PreviewLogScopeResult, +} from './log-rbac'; diff --git a/frontend/src/api/log/log-center.ts b/frontend/src/api/log/log-center.ts index 5528081..bd890a7 100644 --- a/frontend/src/api/log/log-center.ts +++ b/frontend/src/api/log/log-center.ts @@ -43,9 +43,22 @@ export const logCenterApi = { deleteDatasource: (id: number | string) => http.delete(`/log-datasources/${id}`), setDefaultDatasource: (id: number | string) => http.post(`/log-datasources/${id}/default`), testDatasource: (data: Record) => http.post('/log-datasources/test', data), - /** 通过代理查询 ES 集群索引列表 // Logan.Li */ + /** 通过代理查询 ES 集群索引列表(配置弹窗用,传 url)// Logan.Li */ listIndices: (params: Record) => http.get('/log-datasources/indices', params), + /** 按数据源 ID 拉取索引列表(查询面板用)// Logan.Li */ + listIndicesByDatasource: (id: number | string) => + http.get(`/log-datasources/${id}/indices`), + listLokiLabels: (datasourceId: number | string, params?: { start?: string; end?: string }) => + http.get('/logs/loki/labels', { datasource_id: String(datasourceId), ...params }), + listLokiLabelValues: ( + datasourceId: number | string, + label: string, + params?: { start?: string; end?: string; query?: string }, + ) => http.get(`/logs/loki/labels/${encodeURIComponent(label)}/values`, { + datasource_id: String(datasourceId), + ...params, + }), queryLoki: (data: { datasource_id: number; query: string; @@ -53,7 +66,8 @@ export const logCenterApi = { start?: string; end?: string; direction?: string; - }) => http.post<{ entries: LogQueryEntry[]; total: number }>('/logs/loki/query', data), + cursor?: string; + }) => http.post('/logs/loki/query', data), queryElasticsearch: (data: { datasource_id: number; index?: string; @@ -62,5 +76,43 @@ export const logCenterApi = { size?: number; start?: string; end?: string; - }) => http.post<{ entries: LogQueryEntry[]; total: number }>('/logs/elasticsearch/query', data), -}; \ No newline at end of file + search_after?: unknown[]; + }) => http.post('/logs/elasticsearch/query', data), + lokiHistogram: (data: { + datasource_id: number; + query: string; + start?: string; + end?: string; + }) => http.post<{ buckets: HistogramBucket[] }>('/logs/loki/histogram', data), + esHistogram: (data: { + datasource_id: number; + index?: string; + query?: string; + start?: string; + end?: string; + }) => http.post<{ buckets: HistogramBucket[] }>('/logs/elasticsearch/histogram', data), + /** ES 索引 mapping 字段列表(Kibana 风格侧栏)// Logan.Li */ + esMapping: (params: { datasource_id: number | string; index?: string }) => + http.get('/logs/elasticsearch/mapping', { + datasource_id: String(params.datasource_id), + ...(params.index ? { index: params.index } : {}), + }), +}; + +export interface ESMappingField { + name: string; + type: string; +} + +export interface HistogramBucket { + timestamp: string; + count: number; +} + +export interface LogQueryResult { + entries: LogQueryEntry[]; + total: number; + has_more?: boolean; + next_cursor?: string; + search_after?: unknown[]; +} \ No newline at end of file diff --git a/frontend/src/api/log/log-rbac.ts b/frontend/src/api/log/log-rbac.ts new file mode 100644 index 0000000..aab576c --- /dev/null +++ b/frontend/src/api/log/log-rbac.ts @@ -0,0 +1,49 @@ +// 日志查询范围权限 API — 正则匹配 ES 索引 / Loki Label // Logan.Li + +import { http, type ListResponse } from '@/utils/request'; + +export interface LogScopeRole { + id: number; + name: string; + code: string; + engine: 'loki' | 'elasticsearch' | '*'; + match_field: 'index' | 'app' | 'namespace' | 'job'; + pattern: string; + datasource_id?: number; + role: 'viewer' | 'analyst'; + description: string; + status: number; + created_at: string; +} + +export interface UserLogScopeRole { + id: number; + user_id: number; + log_scope_role_id: number; + log_scope_role?: LogScopeRole; +} + +export interface PreviewLogScopeResult { + allowed: boolean; + matched: LogScopeRole[]; + total: number; +} + +export const logRbacApi = { + getRoles: () => http.get>('/admin/log-scope-roles'), + createRole: (data: Record) => http.post('/admin/log-scope-roles', data), + updateRole: (id: number | string, data: Record) => http.put(`/admin/log-scope-roles/${id}`, data), + deleteRole: (id: number | string) => http.delete(`/admin/log-scope-roles/${id}`), + getUserRoles: (userId: number | string) => http.get>(`/admin/log-scope-roles/users/${userId}`), + assignRole: (userId: number | string, data: { log_scope_role_id: number }) => + http.post(`/admin/log-scope-roles/users/${userId}`, data), + removeRole: (userId: number | string, assignId: number | string) => + http.delete(`/admin/log-scope-roles/users/${userId}/${assignId}`), + preview: (data: { + engine: 'loki' | 'elasticsearch'; + datasource_id?: number; + index?: string; + query?: string; + sample_value?: string; + }) => http.post('/admin/log-scope-roles/preview', data), +}; \ No newline at end of file diff --git a/frontend/src/components/dashboard/Dashboard.tsx b/frontend/src/components/dashboard/Dashboard.tsx index 831a7c4..1ae1265 100644 --- a/frontend/src/components/dashboard/Dashboard.tsx +++ b/frontend/src/components/dashboard/Dashboard.tsx @@ -160,7 +160,7 @@ export function Dashboard() { /> - {/* 运维概览 → K8s 集群 → Top5 资源使用 */} + {/* 运维概览 → K8s 集群 → 发布历史 */}
- +
- {/* 费用概览 & 发布历史 */} + {/* 费用概览 & Top5 资源使用 */}
- +
{/* 告警消息 & 证书到期提醒 — 置底 */} diff --git a/frontend/src/components/dashboard/DashboardCharts.tsx b/frontend/src/components/dashboard/DashboardCharts.tsx index d82020e..f67ef98 100644 --- a/frontend/src/components/dashboard/DashboardCharts.tsx +++ b/frontend/src/components/dashboard/DashboardCharts.tsx @@ -1,7 +1,9 @@ +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import i18n from '@/i18n'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { AreaChart, Area, @@ -16,7 +18,7 @@ import { BarChart, Bar, } from 'recharts'; -import { Cpu, HardDrive, Activity, MemoryStick, Database } from 'lucide-react'; +import { Cpu, HardDrive, Activity, MemoryStick, Database, ArrowRight } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { JsonData } from '@/types/common'; @@ -207,6 +209,7 @@ export function CostOverviewCard({ costStats }: { costStats: JsonData }) { export function TopResourceUsageCard({ topUsage }: { topUsage: JsonData}) { const { t } = useTranslation(); + const navigate = useNavigate(); const topCpuServers = (topUsage?.cpu ?? []).slice(0, 5); const topMemoryServers = (topUsage?.memory ?? []).slice(0, 5); const topDiskServers = (topUsage?.disk ?? []).slice(0, 5); @@ -260,9 +263,20 @@ export function TopResourceUsageCard({ topUsage }: { topUsage: JsonData}) { return ( -
- {t('dashboard.topUsage.title')} - {t('dashboard.topUsage.description')} +
+
+ {t('dashboard.topUsage.title')} + {t('dashboard.topUsage.description')} +
+
diff --git a/frontend/src/components/dashboard/DashboardRecent.tsx b/frontend/src/components/dashboard/DashboardRecent.tsx index 7d5ae76..a318ffc 100644 --- a/frontend/src/components/dashboard/DashboardRecent.tsx +++ b/frontend/src/components/dashboard/DashboardRecent.tsx @@ -391,8 +391,8 @@ export function OpsOverviewCard({ {t('dashboard.ops.title')} {t('dashboard.ops.description')}
- diff --git a/frontend/src/components/deploy/BuildNodesManager.tsx b/frontend/src/components/deploy/BuildNodesManager.tsx new file mode 100644 index 0000000..485e934 --- /dev/null +++ b/frontend/src/components/deploy/BuildNodesManager.tsx @@ -0,0 +1,350 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Plus, Pencil, Trash2, RefreshCw, Server, Cpu } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { PageShell } from '@/components/layout/PageShell'; +import { ContentPanel } from '@/components/layout/ContentPanel'; +import { PageEmpty } from '@/components/layout/PageEmpty'; + + +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { toast } from 'sonner'; +import { useConfirm } from '@/components/ui/confirm-dialog'; +import { buildNodeApi, serverApi, type BuildNode } from '@/api'; + +interface ServerItem { + id: number; + hostname: string; + ip: string; +} + +// 构建节点管理(Jenkins Agent 风格 SSH 节点) // Logan.Li +export default function BuildNodesManager({ embedded = false }: { embedded?: boolean }) { + const confirm = useConfirm(); + const [nodes, setNodes] = useState([]); + const [master, setMaster] = useState({ name: 'Master', max_executors: 2, running_count: 0 }); + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [editNode, setEditNode] = useState(null); + const [testingId, setTestingId] = useState(null); + const [masterMax, setMasterMax] = useState('2'); + const [savingMaster, setSavingMaster] = useState(false); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [serverId, setServerId] = useState(''); + const [labels, setLabels] = useState(''); + const [maxExecutors, setMaxExecutors] = useState('2'); + const [workspacePath, setWorkspacePath] = useState('/data/aiops-workspace'); + const [submitting, setSubmitting] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const [nodeRes, srvRes] = await Promise.all([ + buildNodeApi.list(), + serverApi.getServers({ page_size: 200 }).catch(() => ({ list: [] })), + ]); + const data = nodeRes as { list?: BuildNode[]; master?: typeof master }; + setNodes(Array.isArray(data?.list) ? data.list : []); + if (data?.master) { + setMaster(data.master); + setMasterMax(String(data.master.max_executors || 2)); + } + const srvList = (srvRes as { list?: ServerItem[] })?.list || []; + setServers(srvList); + } catch { + toast.error('加载构建节点失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const openCreate = () => { + setEditNode(null); + setName(''); + setDescription(''); + setServerId(''); + setLabels(''); + setMaxExecutors('2'); + setWorkspacePath('/data/aiops-workspace'); + setDialogOpen(true); + }; + + const openEdit = (node: BuildNode) => { + setEditNode(node); + setName(node.name); + setDescription(node.description || ''); + setServerId(String(node.server_id)); + setLabels(node.labels || ''); + setMaxExecutors(String(node.max_executors || 2)); + setWorkspacePath(node.workspace_path || '/data/aiops-workspace'); + setDialogOpen(true); + }; + + const handleSubmit = async () => { + if (!name.trim() || !serverId) { + toast.error('请填写名称并选择服务器'); + return; + } + setSubmitting(true); + try { + const payload = { + name: name.trim(), + description, + server_id: Number(serverId), + labels, + max_executors: Number(maxExecutors) || 2, + workspace_path: workspacePath || '/data/aiops-workspace', + }; + if (editNode) { + await buildNodeApi.update(editNode.id, payload); + toast.success('已更新'); + } else { + await buildNodeApi.create(payload); + toast.success('已创建'); + } + setDialogOpen(false); + load(); + } catch { + toast.error('操作失败'); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (node: BuildNode) => { + const ok = await confirm({ title: '删除节点', description: `确定删除构建节点「${node.name}」?` }); + if (!ok) return; + try { + await buildNodeApi.delete(node.id); + toast.success('已删除'); + load(); + } catch { + toast.error('删除失败,请确认无流水线绑定'); + } + }; + + const handleTest = async (node: BuildNode) => { + setTestingId(node.id); + try { + const res = await buildNodeApi.test(node.id) as { success?: boolean; message?: string }; + if (res?.success) { + toast.success(`连通成功: ${res.message || ''}`); + } else { + toast.error(res?.message || '连接失败'); + } + } catch { + toast.error('测试失败'); + } finally { + setTestingId(null); + } + }; + + const handleToggle = async (node: BuildNode) => { + try { + await buildNodeApi.update(node.id, { enabled: !node.enabled }); + load(); + } catch { + toast.error('更新失败'); + } + }; + + const handleSaveMaster = async () => { + const n = Number(masterMax); + if (!n || n < 1) { + toast.error('并发数至少为 1'); + return; + } + setSavingMaster(true); + try { + const res = await buildNodeApi.updateMaster({ max_executors: n }); + const m = res as { max_executors?: number; running_count?: number }; + setMaster((prev) => ({ + ...prev, + max_executors: m?.max_executors ?? n, + running_count: m?.running_count ?? prev.running_count, + })); + toast.success('Master 并发已更新'); + } catch { + toast.error('更新失败'); + } finally { + setSavingMaster(false); + } + }; + + const panel = ( +
+ + 构建节点 +
+ + +
+
+ )} + > +
+
+
+ + Master(内置) + 默认调度 + + 占用 {master.running_count}/{master.max_executors} + +
+
+ + setMasterMax(e.target.value)} + className="h-7 w-16 text-xs dg-input" + /> + +
+
+

+ 未指定节点时优先在 Master 执行;并发设为 1 时按触发顺序串行排队;满载后自动调度到空闲远程节点 +

+
+ + {nodes.length === 0 ? ( + 添加节点} + /> + ) : ( +
+ {nodes.map((node) => ( +
+
+
+ {node.name} + + {node.enabled ? '启用' : '禁用'} + + + {node.hostname || node.ip || `server#${node.server_id}`} + +
+
+ 执行器 {node.running_count || 0}/{node.max_executors} + {node.labels && 标签 {node.labels}} + {node.workspace_path} +
+
+
+ + + + +
+
+ ))} +
+ )} + + + ); + + const dialog = ( + + + + {editNode ? '编辑节点' : '添加节点'} + + 基于服务器 SSH 凭据注册构建 Agent,流水线可绑定或自动调度 + + +
+
+ + setName(e.target.value)} className="h-8 text-xs dg-input" placeholder="build-node-01" /> +
+
+ + +
+
+
+ + setMaxExecutors(e.target.value)} type="number" min={1} className="h-8 text-xs dg-input" /> +
+
+ + setWorkspacePath(e.target.value)} className="h-8 text-xs font-mono dg-input" /> +
+
+
+ + setLabels(e.target.value)} className="h-8 text-xs dg-input" placeholder="linux,docker,high-mem" /> +
+
+ + setDescription(e.target.value)} className="h-8 text-xs dg-input" /> +
+
+ + +
+
+
+
+ ); + + if (embedded) { + return ( + <> + {panel} + {dialog} + + ); + } + + return ( + + {panel} + {dialog} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/deploy/DeployCredentials.tsx b/frontend/src/components/deploy/DeployCredentials.tsx index 39cd625..c41db13 100644 --- a/frontend/src/components/deploy/DeployCredentials.tsx +++ b/frontend/src/components/deploy/DeployCredentials.tsx @@ -6,7 +6,9 @@ import { Badge } from '@/components/ui/badge'; import { PageShell } from '@/components/layout/PageShell'; import { ContentPanel } from '@/components/layout/ContentPanel'; import { PageEmpty } from '@/components/layout/PageEmpty'; -import { StatStrip } from '@/components/ui/stat-strip'; + +import { useSearchParams } from 'react-router-dom'; +import BuildNodesManager from './BuildNodesManager'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; @@ -98,8 +100,12 @@ function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; on ); } +const CONFIG_TABS = ['repos', 'registries', 'environments', 'tools', 'build-nodes'] as const; + export default function DeployCredentials() { const _confirm = useConfirm(); + const [searchParams] = useSearchParams(); + const tabFromUrl = searchParams.get('tab'); const [repos, setRepos] = useState([]); const [registries, setRegistries] = useState([]); const [environments, setEnvironments] = useState([]); @@ -109,7 +115,9 @@ export default function DeployCredentials() { const [editingId, setEditingId] = useState(null); const [envDialogOpen, setEnvDialogOpen] = useState(false); const [editEnv, setEditEnv] = useState(null); - const [activeTab, setActiveTab] = useState('repos'); + const [activeTab, setActiveTab] = useState( + tabFromUrl && (CONFIG_TABS as readonly string[]).includes(tabFromUrl) ? tabFromUrl : 'repos', + ); const fetchAll = async () => { setLoading(true); @@ -142,29 +150,21 @@ export default function DeployCredentials() { { id: 'repos', label: 'Git 仓库', badge: repos.length || undefined }, { id: 'registries', label: '镜像仓库', badge: registries.length || undefined }, { id: 'environments', label: '环境管理', badge: environments.length || undefined }, + { id: 'build-nodes', label: '构建节点' }, { id: 'tools', label: '工具配置' }, ]; return ( } loading={loading} tabs={pageTabs} activeTab={activeTab} onTabChange={setActiveTab} - metrics={( - - )} - actions={( + actions={activeTab === 'build-nodes' ? undefined : ( <> )} + contentClassName="space-y-4" > {activeTab === 'repos' && ( repos.length === 0 ? ( @@ -325,6 +326,8 @@ export default function DeployCredentials() { )} + {activeTab === 'build-nodes' && } + {activeTab === 'tools' && } + {NAV_ITEMS.map((item) => { + const Icon = item.icon; + return ( + cn( + 'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors', + isActive + ? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] border border-[var(--dg-border)] shadow-sm' + : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]', + )} + > + + {item.label} + + ); + })} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/deploy/DeployRBAC.tsx b/frontend/src/components/deploy/DeployRBAC.tsx index 851076e..a01444f 100644 --- a/frontend/src/components/deploy/DeployRBAC.tsx +++ b/frontend/src/components/deploy/DeployRBAC.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import { Shield, Plus, Trash2, Save, Loader2, Users, Regex, Check, X, GitBranch, UserPlus} from 'lucide-react'; +import { Shield, Plus, Trash2, Save, Loader2, Users, GitBranch, UserPlus, Check, X } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -11,45 +11,62 @@ import { deployApi, userApi, pipelineApi } from '@/api'; import { useConfirm } from '@/components/ui/confirm-dialog'; import { PageShell } from '@/components/layout/PageShell'; import { PageEmpty } from '@/components/layout/PageEmpty'; -import { StatStrip } from '@/components/ui/stat-strip'; + +import { useSearchParams } from 'react-router-dom'; +import UserGroupsManager from './UserGroupsManager'; +import { JobRoleCreateDialog } from './deploy-rbac/JobRoleCreateDialog'; +import { matchPipelines, pipelineDisplayLabel, type PipelineMatchItem } from './deploy-rbac/matchPipeline'; import type { JsonData } from '@/types/common'; interface ItemRole { - id: number; name: string; code: string; pattern: string; role: string; - description: string; status: number; created_at: string; + id: number; + name: string; + code: string; + pattern: string; + role: string; + description: string; + status: number; + created_at: string; } interface AssignedUser { - id: number; user_id: number; item_role_id: number; - user?: { id: number; username: string; real_name: string }; + id: number; + user_id: number; + item_role_id: number; item_role?: ItemRole; } +const ROLE_LABEL: Record = { + owner: '拥有者', + editor: '编辑者', + viewer: '查看者', +}; + +const RBAC_TABS = ['roles', 'users', 'preview', 'groups'] as const; + +// 发布 Job 权限 — 正则匹配 code / job_name,Jenkins Role Strategy 风格 // Logan.Li export default function DeployRBAC() { + const [searchParams] = useSearchParams(); + const tabFromUrl = searchParams.get('tab'); const [roles, setRoles] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - - // Pipeline list for live preview - const [pipelines, setPipelines] = useState([]); + const [activeTab, setActiveTab] = useState( + tabFromUrl && (RBAC_TABS as readonly string[]).includes(tabFromUrl) ? tabFromUrl : 'roles', + ); + const [pipelines, setPipelines] = useState([]); const [previewRoleId, setPreviewRoleId] = useState(null); - - // User assignment const [users, setUsers] = useState([]); const [assignments, setAssignments] = useState([]); - const [selectedUserId, setSelectedUserId] = useState(''); - const [assignRoleId, setAssignRoleId] = useState(''); + const [selectedUserId, setSelectedUserId] = useState(''); + const [assignRoleId, setAssignRoleId] = useState(''); const [assignLoading, setAssignLoading] = useState(false); - - // Create - const [showCreate, setShowCreate] = useState(false); + const [createOpen, setCreateOpen] = useState(false); const [newName, setNewName] = useState(''); const [newCode, setNewCode] = useState(''); const [newPattern, setNewPattern] = useState(''); const [newRole, setNewRole] = useState('editor'); const [newDesc, setNewDesc] = useState(''); - - // Edit const [editing, setEditing] = useState(null); const [editPattern, setEditPattern] = useState(''); const [editRole, setEditRole] = useState(''); @@ -64,11 +81,10 @@ export default function DeployRBAC() { pipelineApi.getPipelines({ page_size: 200 }), userApi.getUsers({ page_size: 500 }), ]); - setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as JsonData)?.list || (rolesRes as JsonData)?.data || []); - setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as JsonData)?.list || (pipesRes as JsonData)?.data || []); - const uList = Array.isArray(usersRes) ? usersRes : (usersRes as JsonData)?.list || (usersRes as JsonData)?.data || []; - setUsers(uList); - } catch { /* */ } + setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as JsonData)?.list || []); + setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as JsonData)?.list || []); + setUsers(Array.isArray(usersRes) ? usersRes : (usersRes as JsonData)?.list || []); + } catch { /* silent */ } finally { setLoading(false); } }; @@ -78,40 +94,40 @@ export default function DeployRBAC() { if (!selectedUserId) { setAssignments([]); return; } try { const res: JsonData = await deployApi.getUserItemRoles(Number(selectedUserId)); - setAssignments(Array.isArray(res) ? res : res?.data || res?.list || []); + setAssignments(Array.isArray(res) ? res : res?.list || []); } catch { setAssignments([]); } }; useEffect(() => { loadAssignments(); }, [selectedUserId]); - // Compute matched pipeline codes for each role const matchCount = useMemo(() => { - const map: Record = {}; - roles.forEach(r => { - try { - const re = new RegExp(r.pattern); - map[r.id] = pipelines.filter(p => re.test(p.code)).map(p => p.code); - } catch { map[r.id] = []; } - }); + const map: Record = {}; + roles.forEach((r) => { map[r.id] = matchPipelines(r.pattern, pipelines); }); return map; }, [roles, pipelines]); - const previewRole = previewRoleId ? roles.find(r => r.id === previewRoleId) : null; - const previewMatches = previewRole ? (matchCount[previewRoleId!] || []) : []; + const previewMatches = previewRoleId ? (matchCount[previewRoleId] || []) : pipelines; const handleCreate = async () => { if (!newName.trim() || !newCode.trim() || !newPattern.trim()) { - toast.error('名称、代码、正则表达式不能为空'); return; + toast.error('请填写名称、标识与正则'); return; } setSaving(true); try { - await deployApi.createItemRole({ name: newName.trim(), code: newCode.trim(), pattern: newPattern.trim(), role: newRole, description: newDesc }); + await deployApi.createItemRole({ + name: newName.trim(), + code: newCode.trim(), + pattern: newPattern.trim(), + role: newRole, + description: newDesc, + }); toast.success('已创建'); - setShowCreate(false); + setCreateOpen(false); setNewName(''); setNewCode(''); setNewPattern(''); setNewRole('editor'); setNewDesc(''); loadData(); - } catch (e) { toast.error(e?.message || '创建失败'); } - finally { setSaving(false); } + } catch (e: unknown) { + toast.error((e as Error)?.message || '创建失败'); + } finally { setSaving(false); } }; const handleUpdate = async (id: number) => { @@ -122,12 +138,17 @@ export default function DeployRBAC() { toast.success('已更新'); setEditing(null); loadData(); - } catch (e) { toast.error(e?.message || '更新失败'); } - finally { setSaving(false); } + } catch (e: unknown) { + toast.error((e as Error)?.message || '更新失败'); + } finally { setSaving(false); } }; const handleDelete = async (role: ItemRole) => { - const ok = await _confirm({ title: '删除角色', description: `确定删除「${role.name}」?匹配 ${matchCount[role.id]?.length || 0} 条流水线`, variant: 'destructive' }); + const ok = await _confirm({ + title: '删除权限', + description: `确定删除「${role.name}」?将影响 ${matchCount[role.id]?.length || 0} 个发布任务`, + variant: 'destructive', + }); if (!ok) return; try { await deployApi.deleteItemRole(role.id); toast.success('已删除'); loadData(); } catch { toast.error('删除失败'); } @@ -142,15 +163,19 @@ export default function DeployRBAC() { }; const handleAssign = async () => { - if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和角色'); return; } + if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和权限'); return; } setAssignLoading(true); try { - await deployApi.assignItemRole(Number(selectedUserId), { user_id: Number(selectedUserId), item_role_id: Number(assignRoleId) }); + await deployApi.assignItemRole(Number(selectedUserId), { + user_id: Number(selectedUserId), + item_role_id: Number(assignRoleId), + }); toast.success('已分配'); setAssignRoleId(''); loadAssignments(); - } catch (e) { toast.error(e?.message || '分配失败'); } - finally { setAssignLoading(false); } + } catch (e: unknown) { + toast.error((e as Error)?.message || '分配失败'); + } finally { setAssignLoading(false); } }; const handleRevoke = async (id: number) => { @@ -161,257 +186,211 @@ export default function DeployRBAC() { } catch { toast.error('撤销失败'); } }; - const roleBadgeClass = (role: string) => - role === 'owner' ? 'bg-amber-500/20 dg-text-yellow border-amber-500/30' : - role === 'editor' ? 'bg-blue-500/20 dg-text-blue border-blue-500/30' : - 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'; - - const selectedUser = users.find(u => String(u.id) === selectedUserId); - - const activeRoles = roles.filter(r => r.status === 1).length; + const selectedUser = users.find((u) => String(u.id) === selectedUserId); + const pageTabs = [ + { id: 'roles', label: '权限策略', badge: roles.length || undefined }, + { id: 'users', label: '用户授权' }, + { id: 'groups', label: '用户组' }, + { id: 'preview', label: '命中预览' }, + ]; return ( } loading={loading} - metrics={( - - )} - actions={( - )} + contentClassName="space-y-4" > - {/* ====== Pipeline Preview ====== */} - - -
- - 流水线命中预览 - — 选择一个角色查看哪些流水线会被匹配 -
-
- - {roles.map(r => ( - + {activeTab === 'roles' && ( + roles.length === 0 ? ( + } + title="暂无 Job 权限策略" + description="创建策略后,用户将按正则自动获得匹配流水线的访问权限" + action={} + /> + ) : ( +
+ {roles.map((role) => ( + + +
+
+
+ {role.name} + + {ROLE_LABEL[role.role] || role.role} + + {role.status === 0 && 已禁用} + { setActiveTab('preview'); setPreviewRoleId(role.id); }} + > + {matchCount[role.id]?.length || 0} 个 Job + +
+

+ {role.pattern} + {role.description ? ` · ${role.description}` : ''} +

+
+
+ {editing === role.id ? ( + <> + setEditPattern(e.target.value)} className="h-7 w-32 text-xs font-mono dg-input" /> + + + + + ) : ( + <> + + + + + )} +
+
+
+
))}
-
- {previewRoleId === null - ? pipelines.map(p => {p.code}) - : pipelines.map(p => { - const matched = previewMatches.includes(p.code); - return ( - - {p.code} - - ); - }) - } - {previewRoleId !== null && previewMatches.length === 0 && ( - 无匹配 — 请调整正则表达式 - )} -
- - + ) + )} - {/* Create form */} - {showCreate && ( - - -
- 新建 Item Role - -
-
-
setNewName(e.target.value)} placeholder="如: backend" className="h-8 text-sm" />
-
setNewCode(e.target.value)} placeholder="如: backend" className="h-8 text-sm font-mono" />
-
-
-
- - { setNewPattern(e.target.value); }} placeholder='如: ^cex-.*' className="h-8 text-sm font-mono" /> - {newPattern && ( -

- 实时匹配: {(() => { try { const n = pipelines.filter(p => new RegExp(newPattern).test(p.code)).length; return n > 0 ? `✅ 命中 ${n} 条流水线` : '⚠️ 无匹配'; } catch { return '❌ 正则语法错误'; } })()} -

- )} -
-
- - + + + {users.map((u) => ( + + {(u.real_name as string) || (u.username as string)} ({u.username as string}) + + ))}
-
-
setNewDesc(e.target.value)} placeholder="简要说明此角色的用途" className="h-8 text-sm" />
- -
-
- )} - - {/* Role list */} - {roles.length === 0 ? ( - } title="暂无 Item Role" description="创建角色后按正则匹配流水线并分配给用户" /> - ) : ( -
- {roles.map(role => ( - - -
-
-
- -
-
-
- {role.name} - {role.role === 'owner' ? '拥有者' : role.role === 'editor' ? '编辑者' : '查看者'} - {role.status === 0 && 已禁用} - setPreviewRoleId(previewRoleId === role.id ? null : role.id)}> - {matchCount[role.id]?.length || 0} 条 - -
-
- {role.code} - → - {role.pattern} - {role.description && {role.description}} -
-
-
-
- {editing === role.id ? ( - <> - setEditPattern(e.target.value)} className="h-7 w-36 text-xs font-mono" placeholder="正则表达式" /> - - - - - ) : ( - <> - - - - - )} -
-
-
-
- ))} -
- )} - - {/* ====== User Assignment ====== */} - - -
- - 分配角色给用户 -
-
-
- -
- {users.map(u => ( - - {u.real_name || u.username} ({u.username}) - {assignments.some(a => a.user_id === u.id) && 已分配} - - ))} - - -
-
- - -
- -
- - {/* Assigned roles for selected user */} - {selectedUser && ( -
-
- - {selectedUser.real_name || selectedUser.username} - - 已分配角色: +
+ +
- {assignments.length === 0 ? ( -

该用户尚未分配任何 Item Role

+ +
+ {selectedUser ? ( + assignments.length === 0 ? ( +

该用户尚未分配 Job 权限

) : ( -
- {assignments.map(a => { - const roleInfo = a.item_role || roles.find(r => r.id === a.item_role_id); +
+ {assignments.map((a) => { + const roleInfo = a.item_role || roles.find((r) => r.id === a.item_role_id); return ( -
-
- - {roleInfo?.role === 'owner' ? '拥有者' : roleInfo?.role === 'editor' ? '编辑者' : '查看者'} - - {roleInfo?.name || `#${a.item_role_id}`} - {roleInfo && ( - - {roleInfo.pattern} - - )} +
+
+ {ROLE_LABEL[roleInfo?.role || ''] || roleInfo?.role} + {roleInfo?.name} + {roleInfo?.pattern}
-
); })}
+ ) + ) : ( +

选择用户后查看已分配权限

+ )} + + + )} + + {activeTab === 'groups' && } + + {activeTab === 'preview' && ( + + +
+ + {roles.map((r) => ( + + ))} +
+
+ {previewMatches.map((p) => ( + + + {pipelineDisplayLabel(p)} + + ))} + {previewRoleId !== null && previewMatches.length === 0 && ( + 无匹配 — 请调整正则 )}
- )} -
-
+ + + )} + + ); -} +} \ No newline at end of file diff --git a/frontend/src/components/deploy/PipelineEditor.tsx b/frontend/src/components/deploy/PipelineEditor.tsx index 9da7328..bedc4d7 100644 --- a/frontend/src/components/deploy/PipelineEditor.tsx +++ b/frontend/src/components/deploy/PipelineEditor.tsx @@ -42,7 +42,7 @@ import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { toast } from 'sonner'; -import { pipelineApi, deployApi } from '@/api'; +import { pipelineApi, deployApi, buildNodeApi, type BuildNode } from '@/api'; import { useConfirm } from '@/components/ui/confirm-dialog'; import { PageShell } from '@/components/layout/PageShell'; import { PageEmpty } from '@/components/layout/PageEmpty'; @@ -182,6 +182,8 @@ export default function PipelineEditor() { const [webhookToken, setWebhookToken] = useState(''); const [variables, setVariables] = useState<[string, string][]>([]); const [environmentId, setEnvironmentId] = useState(''); + const [buildNodes, setBuildNodes] = useState([]); + const [buildNodeId, setBuildNodeId] = useState(''); // member add dialog const [newMemberUser, setNewMemberUser] = useState(''); @@ -230,6 +232,7 @@ export default function PipelineEditor() { setCronExpr(p.cron_expr || ''); setWebhookToken(p.webhook_token || ''); setEnvironmentId(p.environment_id || ''); + setBuildNodeId(p.build_node_id ? String(p.build_node_id) : ''); try { const parsed = p.variables ? JSON.parse(p.variables) : {}; setVariables(Object.entries(parsed).map(([k, v]) => [k, String(v)])); @@ -249,6 +252,10 @@ export default function PipelineEditor() { loadEnvironments(); loadMembers(); loadRuns(); + buildNodeApi.list().then((res) => { + const data = res as { list?: BuildNode[] }; + setBuildNodes(Array.isArray(data?.list) ? data.list.filter((n) => n.enabled) : []); + }).catch(() => { /* silent */ }); }, [pipelineCode, loadEnvironments, loadMembers, loadRuns]); const selectedTask = stages.flatMap(s => s.tasks).find(t => t.id === selectedTaskId) || null; @@ -273,6 +280,7 @@ export default function PipelineEditor() { webhook_token: webhookToken, environment_id: environmentId ? Number(environmentId) : null, variables: JSON.stringify(Object.fromEntries(variables.filter(([k]) => k.trim()))), + build_node_id: buildNodeId ? Number(buildNodeId) : null, }); toast.success('已保存'); } catch { toast.error('保存失败'); } @@ -386,7 +394,20 @@ export default function PipelineEditor() { ))} - 选择后流水线执行记录会关联到对应环境 + + 构建节点: + + Master 满载时自动调度到空闲远程节点 @@ -491,6 +512,7 @@ export default function PipelineEditor() { 状态 触发方式 触发人 + 节点 分支 耗时 时间 @@ -516,6 +538,7 @@ export default function PipelineEditor() { {!r.trigger_mode && (r.triggered_by || '手动')} {r.triggered_by || 'admin'} + {r.build_node_name || 'Master'} {r.branch || '-'} {fmtDuration(r.duration)} {r.created_at ? new Date(r.created_at).toLocaleString('zh-CN') : '-'} diff --git a/frontend/src/components/deploy/PipelineManager.tsx b/frontend/src/components/deploy/PipelineManager.tsx index 5ad71a5..c35fcc4 100644 --- a/frontend/src/components/deploy/PipelineManager.tsx +++ b/frontend/src/components/deploy/PipelineManager.tsx @@ -5,21 +5,13 @@ import { Input } from '@/components/ui/input'; import { Plus, Search, - GitBranch, Clock, - CheckCircle, - Rocket, - Container, - ArrowRight, Layers, - ShieldCheck, - Globe, - Code, Workflow, Server, Network, } from 'lucide-react'; -import { pipelineApi } from '@/api'; +import { pipelineApi, deployApi } from '@/api'; import { usePipelines, usePipelineMutations } from '@/hooks/api'; import { useConfirm } from '@/components/ui/confirm-dialog'; import { toast } from 'sonner'; @@ -27,7 +19,16 @@ import { PipelineCard } from './pipeline-manager/PipelineCard'; import { PipelineCreateDialog } from './pipeline-manager/PipelineCreateDialog'; import { PageShell } from '@/components/layout/PageShell'; import { PageEmpty } from '@/components/layout/PageEmpty'; -import { StatStrip } from '@/components/ui/stat-strip'; +import type { JsonData } from '@/types/common'; + +interface DeployEnvironment { + id: number; + name: string; + display_name: string; + type: string; + color: string; + enabled: boolean; +} // 流水线列表页编排器 // Logan.Li export function PipelineManager() { @@ -39,18 +40,20 @@ export function PipelineManager() { const [runs, setRuns] = useState[]>>({}); const [loadingRuns, setLoadingRuns] = useState>({}); const [envFilter, setEnvFilter] = useState('all'); + const [environments, setEnvironments] = useState([]); const [runningRunMap, setRunningRunMap] = useState>>({}); const [newOpen, setNewOpen] = useState(false); const confirm = useConfirm(); - const ENV_TYPES = [ - { key: 'all', label: t('deploy.env.all'), icon: Layers, color: '#6B7280' }, - { key: 'production', label: t('deploy.env.production'), icon: ShieldCheck, color: '#EF4444' }, - { key: 'testing', label: t('deploy.env.testing'), icon: CheckCircle, color: '#F59E0B' }, - { key: 'staging', label: t('deploy.env.staging'), icon: Globe, color: '#8B5CF6' }, - { key: 'development', label: t('deploy.env.development'), icon: Code, color: '#3B82F6' }, - ]; + useEffect(() => { + deployApi.getEnvironments() + .then((res) => { + const list = ((res as JsonData)?.list || []) as DeployEnvironment[]; + setEnvironments(list.filter((e) => e.enabled !== false)); + }) + .catch(() => { /* 环境列表加载失败时仅显示「全部」 */ }); + }, []); // 同步运行中的任务 ID // Logan.Li useEffect(() => { @@ -152,12 +155,19 @@ export function PipelineManager() { const filtered = pipelines.filter((p) => { const matchSearch = String(p.name || '').toLowerCase().includes(searchQuery.toLowerCase()) || String(p.description || '').toLowerCase().includes(searchQuery.toLowerCase()); - const env = p.environment as Record | undefined; - const matchEnv = envFilter === 'all' || (env?.type || '') === envFilter; + const envId = p.environment_id as number | undefined; + const matchEnv = envFilter === 'all' || String(envId || '') === envFilter; return matchSearch && matchEnv; }); - const runningCount = pipelines.filter(p => p.last_status === 'running').length; + const envTabs = [ + { key: 'all', label: t('deploy.env.all'), color: undefined as string | undefined }, + ...environments.map((e) => ({ + key: String(e.id), + label: e.display_name || e.name, + color: e.color, + })), + ]; const toolbar = (
@@ -170,92 +180,67 @@ export function PipelineManager() { className="pl-10 dg-input" />
-
- {ENV_TYPES.map((et) => { - const active = envFilter === et.key; - const Icon = et.icon; - return ( - - ); - })} -
+ {envTabs.length > 1 && ( +
+ {envTabs.map((et) => { + const active = envFilter === et.key; + return ( + + ); + })} +
+ )}
); return ( } loading={loading} - metrics={( - - )} toolbar={toolbar} actions={( <> )} + contentClassName="space-y-4" > - {/* 全流程可视化横幅 — 双路径 // Logan.Li */} -
-
-
- - {t('deploy.banner.build')} - - - {t('deploy.banner.deploy')} - -
- - {t('deploy.banner.sshHost')} -
-
-

{t('deploy.banner.sshDesc')}

-
-
-
-
- - {t('deploy.banner.build')} - - - {t('deploy.banner.imageRegistry')} - - - {t('deploy.banner.deploy')} - -
- - {t('deploy.banner.kubernetes')} -
-
-

{t('deploy.banner.k8sDesc')}

-
- {t('deploy.banner.fullFlow')} +
+ + + 主机发布:构建 → SSH 关联主机 + + | + + + Kubernetes 发布:构建 → 镜像 → 关联 Deployment 滚动更新 +
{!loading && filtered.length === 0 ? ( @@ -294,4 +279,4 @@ export function PipelineManager() { ); -} +} \ No newline at end of file diff --git a/frontend/src/components/deploy/ToolConfigTab.tsx b/frontend/src/components/deploy/ToolConfigTab.tsx index 7cb64b7..0d7e1a9 100644 --- a/frontend/src/components/deploy/ToolConfigTab.tsx +++ b/frontend/src/components/deploy/ToolConfigTab.tsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; -import { RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2, ChevronDown} from 'lucide-react'; +import { RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2, ChevronDown, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { useConfirm } from '@/components/ui/confirm-dialog'; import { toast } from 'sonner'; import { deployApi } from '@/api'; import type { JsonData } from '@/types/common'; @@ -29,22 +30,15 @@ const categoryColor: Record = { automation: 'bg-purple-500/20 dg-text-purple border-purple-500/30', other: 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]', }; -const _methodLabel: Record = { - preinstall: '预装', lazy: '懒加载', disabled: '禁用', -}; -const _methodColor: Record = { - preinstall: 'bg-emerald-500/20 dg-text-green border-emerald-500/30', - lazy: 'bg-violet-500/20 dg-text-purple border-violet-500/30', - disabled: 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]', -}; - -// Tools that support version selection -const VERSIONED_TOOLS = ['jdk', 'nodejs', 'maven']; +// 支持版本选择的工具 // Logan.Li +const VERSIONED_TOOLS = ['jdk', 'golang', 'nodejs', 'maven', 'nvm']; export default function ToolConfigTab() { + const confirm = useConfirm(); const [tools, setTools] = useState([]); const [loading, setLoading] = useState(false); const [installing, setInstalling] = useState(null); + const [uninstalling, setUninstalling] = useState(null); // Version dialog state const [versionDialog, setVersionDialog] = useState<{ tool: ToolConfig; versions: VersionOption[] } | null>(null); @@ -64,6 +58,14 @@ export default function ToolConfigTab() { useEffect(() => { fetchTools(); }, []); + // 安装中/失败状态轮询,与磁盘实际状态同步 // Logan.Li + useEffect(() => { + const pending = tools.filter((t) => t.status === 'installing' || t.status === 'failed'); + if (pending.length === 0) return undefined; + const timer = setInterval(() => { void fetchTools(); }, 4000); + return () => clearInterval(timer); + }, [tools]); + const openVersionDialog = async (tool: ToolConfig) => { try { const res: JsonData = await deployApi.getToolVersions(tool.id); @@ -74,7 +76,12 @@ export default function ToolConfigTab() { return; } setVersionDialog({ tool, versions }); - setSelectedVersion(tool.default_version || versions[0]?.version || ''); + const preferred = versions.find((v) => v.version === 'lts')?.version + || versions.find((v) => v.label.includes('推荐'))?.version + || tool.default_version + || versions[0]?.version + || ''; + setSelectedVersion(preferred); } catch { // Fallback: install with default version handleInstall(tool); @@ -86,18 +93,37 @@ export default function ToolConfigTab() { try { const v = version || tool.default_version; await deployApi.installTool(tool.id, v); - toast.success(`${tool.display_name}${v ? ` (${v})` : ''} 安装已触发`); + toast.success(`${tool.display_name}${v ? ` (${v})` : ''} 安装已触发,可在审计日志查看结果`); setVersionDialog(null); - setTimeout(async () => { - try { await deployApi.checkTool(tool.id); fetchTools(); } catch { /* ignore */ } - setInstalling(null); - }, 3000); + setInstalling(null); + void fetchTools(); } catch { toast.error('触发安装失败'); setInstalling(null); } }; + const handleUninstall = async (tool: ToolConfig) => { + const ok = await confirm({ + title: '卸载工具', + description: `确定卸载 ${tool.display_name}?卸载后可重新选择版本安装。`, + confirmText: '卸载', + variant: 'destructive', + }); + if (!ok) return; + + setUninstalling(tool.id); + try { + await deployApi.uninstallTool(tool.id); + toast.success(`${tool.display_name} 已卸载,可重新安装`); + void fetchTools(); + } catch { + toast.error('卸载失败'); + } finally { + setUninstalling(null); + } + }; + const handleCheckStatus = async (tool: ToolConfig) => { try { const res: JsonData = await deployApi.checkTool(tool.id); @@ -106,30 +132,22 @@ export default function ToolConfigTab() { } catch { toast.error('检测失败'); } }; - const handleMethodChange = async (tool: ToolConfig, method: 'preinstall' | 'lazy' | 'disabled') => { - try { - await deployApi.updateTool(tool.id, { install_method: method }); - toast.success('已更新'); - fetchTools(); - } catch { toast.error('更新失败'); } - }; - - // Download speed test + // 检测工具安装状态(磁盘验证)// Logan.Li const handleTestDownload = async (tool: ToolConfig) => { setTesting(tool.id); const start = Date.now(); try { - // Simulate a quick reachability test by fetching the version URL - const res: JsonData = await deployApi.getToolVersions(tool.id); + const res: JsonData = await deployApi.checkTool(tool.id); const elapsed = ((Date.now() - start) / 1000).toFixed(1); - const available = (res as JsonData)?.versions?.length > 0; - if (available) { - toast.success(`${tool.display_name} 镜像可访问 (${elapsed}s)`); + if (res?.available) { + toast.success(`${tool.display_name} 已就绪${res.version ? ` (${res.version})` : ''} (${elapsed}s)`); } else { - toast(`${tool.display_name} 无法获取版本列表`, { description: '请检查网络连接' }); + const errMsg = (res?.tool as ToolConfig | undefined)?.error_msg; + toast(`${tool.display_name} 未安装或不可用`, { description: errMsg || '可点击「安装」触发懒加载' }); } + fetchTools(); } catch { - toast.error(`${tool.display_name} 网络不可达`); + toast.error(`${tool.display_name} 检测失败`); } setTesting(null); }; @@ -160,7 +178,6 @@ export default function ToolConfigTab() { 工具 分类 - 安装方式 状态 操作 @@ -188,21 +205,6 @@ export default function ToolConfigTab() { {categoryLabel[tool.category] || tool.category} - - - {tool.status === 'installed' ? ( @@ -224,7 +226,21 @@ export default function ToolConfigTab() {
- {tool.install_method === 'lazy' && tool.status !== 'installed' && ( + {tool.install_method === 'lazy' && (tool.status === 'installed' || tool.status === 'failed') && ( + + )} + {tool.install_method === 'lazy' && tool.status !== 'installed' && tool.status !== 'installing' && ( hasVersions ? ( - - - )} - > - {!loading && groups.length === 0 ? ( + const toolbar = embedded ? ( +
+ + +
+ ) : null; + + const listBody = !loading && groups.length === 0 ? ( } title="暂无用户组" @@ -171,9 +157,9 @@ export default function UserGroupsManager() {
))}
- )} + ); - {/* Form Dialog */} + const formDialog = ( {editId ? '编辑用户组' : '新建用户组'} @@ -187,6 +173,47 @@ export default function UserGroupsManager() {
+ ); + + if (embedded) { + return ( + <> + {toolbar} + {listBody} + {formDialog} + + ); + } + + return ( + } + loading={loading} + metrics={( + + )} + actions={( + <> + + + + )} + contentClassName="space-y-4" + > + {listBody} + {formDialog} ); } diff --git a/frontend/src/components/deploy/deploy-rbac/JobRoleCreateDialog.tsx b/frontend/src/components/deploy/deploy-rbac/JobRoleCreateDialog.tsx new file mode 100644 index 0000000..cc51a59 --- /dev/null +++ b/frontend/src/components/deploy/deploy-rbac/JobRoleCreateDialog.tsx @@ -0,0 +1,121 @@ +import { useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Loader2, Save } from 'lucide-react'; +import { matchPipelines, type PipelineMatchItem } from './matchPipeline'; + +interface JobRoleCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + pipelines: PipelineMatchItem[]; + saving: boolean; + name: string; + code: string; + pattern: string; + role: string; + description: string; + onNameChange: (v: string) => void; + onCodeChange: (v: string) => void; + onPatternChange: (v: string) => void; + onRoleChange: (v: string) => void; + onDescriptionChange: (v: string) => void; + onSubmit: () => void; +} + +// 新建 Job 权限角色弹窗 // Logan.Li +export function JobRoleCreateDialog({ + open, + onOpenChange, + pipelines, + saving, + name, + code, + pattern, + role, + description, + onNameChange, + onCodeChange, + onPatternChange, + onRoleChange, + onDescriptionChange, + onSubmit, +}: JobRoleCreateDialogProps) { + const preview = useMemo(() => matchPipelines(pattern, pipelines), [pattern, pipelines]); + const patternHint = useMemo(() => { + if (!pattern.trim()) return '输入正则,匹配流水线 code 或 Job 名称'; + try { + // eslint-disable-next-line no-new + new RegExp(pattern); + return preview.length > 0 ? `命中 ${preview.length} 个发布任务` : '无匹配任务'; + } catch { + return '正则语法错误'; + } + }, [pattern, preview.length]); + + return ( + + + + 新建 Job 权限 + + 按正则匹配流水线 code 或 Job 名称,分配 owner / editor / viewer 权限 + + +
+
+
+ + onNameChange(e.target.value)} placeholder="后端发布" className="h-8 text-sm dg-input" /> +
+
+ + onCodeChange(e.target.value)} placeholder="backend-jobs" className="h-8 text-sm font-mono dg-input" /> +
+
+
+ + onPatternChange(e.target.value)} + placeholder="^cex-.* 或 ^api-server$" + className="h-8 text-sm font-mono dg-input" + /> +

{patternHint}

+
+
+ + +
+
+ + onDescriptionChange(e.target.value)} placeholder="可选" className="h-8 text-sm dg-input" /> +
+
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/deploy/deploy-rbac/matchPipeline.ts b/frontend/src/components/deploy/deploy-rbac/matchPipeline.ts new file mode 100644 index 0000000..5bb85d2 --- /dev/null +++ b/frontend/src/components/deploy/deploy-rbac/matchPipeline.ts @@ -0,0 +1,29 @@ +// 流水线 / Job 正则命中工具(code + job_name) // Logan.Li + +export interface PipelineMatchItem { + id: number | string; + code?: string; + job_name?: string; + name?: string; +} + +export function pipelineMatchesPattern(pattern: string, pipeline: PipelineMatchItem): boolean { + try { + const re = new RegExp(pattern); + if (pipeline.code && re.test(String(pipeline.code))) return true; + if (pipeline.job_name && re.test(String(pipeline.job_name))) return true; + return false; + } catch { + return false; + } +} + +export function matchPipelines(pattern: string, pipelines: PipelineMatchItem[]): PipelineMatchItem[] { + if (!pattern.trim()) return []; + return pipelines.filter((p) => pipelineMatchesPattern(pattern, p)); +} + +export function pipelineDisplayLabel(p: PipelineMatchItem): string { + if (p.job_name) return `${p.code} · ${p.job_name}`; + return String(p.code || p.name || p.id); +} \ No newline at end of file diff --git a/frontend/src/components/deploy/pipeline-manager/DeployTargetFields.tsx b/frontend/src/components/deploy/pipeline-manager/DeployTargetFields.tsx new file mode 100644 index 0000000..a212eac --- /dev/null +++ b/frontend/src/components/deploy/pipeline-manager/DeployTargetFields.tsx @@ -0,0 +1,373 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Search, Server, Network, Loader2, Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { serverApi, kubernetesApi } from '@/api'; +import { getPublicApiBase } from '@/utils/request'; + +export interface DeployTargetValue { + deployTarget: 'ssh' | 'k8s'; + serverIds: number[]; + deployRemotePath: string; + deployScript: string; + k8sClusterId: number | ''; + k8sNamespace: string; + k8sDeployment: string; + k8sContainerName: string; +} + +interface DeployTargetFieldsProps { + value: DeployTargetValue; + onChange: (patch: Partial) => void; + requireTarget?: boolean; +} + +interface ServerItem { + id: number; + hostname: string; + ip?: string; + status?: string; +} + +interface ClusterItem { + id: number; + name: string; + code: string; +} + +interface DeploymentItem { + namespace: string; + name: string; + image?: string; + ready_replicas?: number; + replicas?: number; +} + +// 发布目标选择:SSH 关联主机 / K8s 关联 Deployment // Logan.Li +export function DeployTargetFields({ value, onChange, requireTarget = true }: DeployTargetFieldsProps) { + const [servers, setServers] = useState([]); + const [clusters, setClusters] = useState([]); + const [deployments, setDeployments] = useState([]); + const [serverSearch, setServerSearch] = useState(''); + const [depSearch, setDepSearch] = useState(''); + const [loadingServers, setLoadingServers] = useState(false); + const [loadingDeps, setLoadingDeps] = useState(false); + + const loadServers = useCallback(async () => { + setLoadingServers(true); + try { + const res = await serverApi.getServers({ page_size: 200 }); + const list = (res?.list ?? []) as ServerItem[]; + setServers(list); + } catch { + setServers([]); + } finally { + setLoadingServers(false); + } + }, []); + + const loadClusters = useCallback(async () => { + try { + const res = await kubernetesApi.getClusters(); + setClusters((res?.list ?? []) as ClusterItem[]); + } catch { + setClusters([]); + } + }, []); + + const loadDeployments = useCallback(async (clusterId: number | '') => { + if (!clusterId) { + setDeployments([]); + return; + } + const cluster = clusters.find((c) => c.id === clusterId); + if (!cluster?.code) { + setDeployments([]); + return; + } + setLoadingDeps(true); + try { + const res = await kubernetesApi.getAllDeployments(cluster.code); + const list = Array.isArray(res) ? res : []; + setDeployments(list as DeploymentItem[]); + } catch { + setDeployments([]); + } finally { + setLoadingDeps(false); + } + }, [clusters]); + + useEffect(() => { + loadServers(); + loadClusters(); + }, [loadServers, loadClusters]); + + useEffect(() => { + if (value.deployTarget === 'k8s' && value.k8sClusterId) { + loadDeployments(value.k8sClusterId); + } + }, [value.deployTarget, value.k8sClusterId, loadDeployments]); + + const filteredServers = useMemo(() => { + const q = serverSearch.trim().toLowerCase(); + if (!q) return servers; + return servers.filter( + (s) => s.hostname?.toLowerCase().includes(q) || s.ip?.toLowerCase().includes(q), + ); + }, [servers, serverSearch]); + + const filteredDeployments = useMemo(() => { + const q = depSearch.trim().toLowerCase(); + if (!q) return deployments; + return deployments.filter( + (d) => d.name.toLowerCase().includes(q) || d.namespace.toLowerCase().includes(q), + ); + }, [deployments, depSearch]); + + const toggleServer = (id: number) => { + const next = value.serverIds.includes(id) + ? value.serverIds.filter((x) => x !== id) + : [...value.serverIds, id]; + onChange({ serverIds: next }); + }; + + const selectDeployment = (dep: DeploymentItem) => { + onChange({ + k8sNamespace: dep.namespace, + k8sDeployment: dep.name, + }); + }; + + const selectedCluster = clusters.find((c) => c.id === value.k8sClusterId); + + return ( +
+
+ {([ + { key: 'ssh' as const, label: '主机发布', icon: Server }, + { key: 'k8s' as const, label: 'Kubernetes 发布', icon: Network }, + ]).map((item) => { + const active = value.deployTarget === item.key; + const Icon = item.icon; + return ( + + ); + })} +
+ + {value.deployTarget === 'ssh' ? ( +
+
+ + {value.serverIds.length > 0 && ( + + 已选 {value.serverIds.length} 台 + + )} +
+
+ + setServerSearch(e.target.value)} + placeholder="搜索主机名或 IP" + className="pl-8 h-8 text-xs dg-input" + /> +
+ + {loadingServers ? ( +
+ 加载中 +
+ ) : filteredServers.length === 0 ? ( +

暂无可用主机

+ ) : ( +
+ {filteredServers.map((srv) => { + const selected = value.serverIds.includes(srv.id); + return ( + + ); + })} +
+ )} +
+

构建完成后 SCP 上传 jar/tar.gz 到远程目录并执行部署命令

+
+
+ + onChange({ deployRemotePath: e.target.value })} + placeholder="/opt/apps/my-app" + className="h-8 text-xs font-mono dg-input" + /> +
+
+ + onChange({ deployScript: e.target.value })} + placeholder="./restart.sh" + className="h-8 text-xs font-mono dg-input" + /> +
+
+
+ ) : ( +
+
+
+ + +
+
+ + +
+
+ {selectedCluster && ( + <> +
+ + setDepSearch(e.target.value)} + placeholder="搜索 Deployment 或命名空间" + className="pl-8 h-8 text-xs dg-input" + /> +
+ + {loadingDeps ? ( +
+ 加载 Deployment +
+ ) : filteredDeployments.length === 0 ? ( +

暂无 Deployment

+ ) : ( +
+ {filteredDeployments.map((dep) => { + const selected = value.k8sDeployment === dep.name && value.k8sNamespace === dep.namespace; + return ( + + ); + })} +
+ )} +
+
+ + onChange({ k8sContainerName: e.target.value })} + placeholder="默认首个容器" + className="h-8 text-xs font-mono dg-input" + /> +
+ {value.k8sDeployment && selectedCluster && ( +

+ 流水线部署将调用 CI 接口: + + POST {getPublicApiBase()}/kubernetes/{selectedCluster.code}/namespace/{value.k8sNamespace}/deployments/{value.k8sDeployment}/update-image + +

+ )} + + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/deploy/pipeline-manager/PipelineCard.tsx b/frontend/src/components/deploy/pipeline-manager/PipelineCard.tsx index b71356f..ce575ab 100644 --- a/frontend/src/components/deploy/pipeline-manager/PipelineCard.tsx +++ b/frontend/src/components/deploy/pipeline-manager/PipelineCard.tsx @@ -56,6 +56,14 @@ export function PipelineCard({ const registry = pipeline.registry as Record | undefined; const environment = pipeline.environment as Record | undefined; const code = (pipeline.code || pipeline.id) as string; + const deployTarget = String(pipeline.deploy_target || 'ssh'); + let serverCount = 0; + try { + const ids = pipeline.server_ids ? JSON.parse(String(pipeline.server_ids)) : []; + serverCount = Array.isArray(ids) ? ids.length : 0; + } catch { /* ignore */ } + const k8sDep = pipeline.k8s_deployment ? String(pipeline.k8s_deployment) : ''; + const k8sNs = pipeline.k8s_namespace ? String(pipeline.k8s_namespace) : ''; return ( @@ -82,6 +90,11 @@ export function PipelineCard({ {pipeline.job_name && ( {String(pipeline.job_name)} )} + {pipeline.build_node_id && ( + + 指定节点 + + )}

{String(pipeline.description || '暂无描述')} @@ -124,10 +137,16 @@ export function PipelineCard({ {pipeline.deploy_mode === 'tag' ? 'Tag发版' : String(pipeline.branch || 'main')} - {(pipeline.deploy_target || 'ssh') === 'k8s' ? ( - <>Kubernetes + {deployTarget === 'k8s' ? ( + <> + + {k8sDep ? `${k8sNs}/${k8sDep}` : 'Kubernetes Deployment'} + ) : ( - <>SSH 主机 + <> + + {serverCount > 0 ? `${serverCount} 台主机` : 'SSH 主机'} + )} {repo && {String(repo.name)}} @@ -180,8 +199,20 @@ export function PipelineCard({ const isSuccess = runStatus === 'success' || runStatus === 'completed'; const isRunning = runStatus === 'running' || runStatus === 'in_progress'; const isFailed = runStatus === 'failed' || runStatus === 'error'; - const color = isFailed ? '#EF4444' : isRunning ? '#3B82F6' : isSuccess ? '#10B981' : '#6B7280'; - const bgColor = isFailed ? '#EF444428' : isRunning ? '#3B82F628' : isSuccess ? '#10B98128' : '#6B728018'; + const color = isFailed + ? 'var(--dg-red)' + : isRunning + ? 'var(--dg-blue)' + : isSuccess + ? 'var(--dg-green)' + : 'var(--dg-text-muted)'; + const bgColor = isFailed + ? 'color-mix(in srgb, var(--dg-red) 12%, transparent)' + : isRunning + ? 'color-mix(in srgb, var(--dg-blue) 12%, transparent)' + : isSuccess + ? 'color-mix(in srgb, var(--dg-green) 12%, transparent)' + : 'var(--dg-bg-tertiary)'; const icon = isFailed ? ( ) : isRunning ? ( @@ -197,7 +228,7 @@ export function PipelineCard({ className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-semibold border shadow-sm" style={{ backgroundColor: bgColor, - borderColor: `${color}70`, + borderColor: 'var(--dg-border)', color, borderRadius: i === 0 ? '8px 0 0 8px' : i === stages.length - 1 ? '0 8px 8px 0' : '0', }} diff --git a/frontend/src/components/deploy/pipeline-manager/PipelineCreateDialog.tsx b/frontend/src/components/deploy/pipeline-manager/PipelineCreateDialog.tsx index 50ee7b3..86aa68c 100644 --- a/frontend/src/components/deploy/pipeline-manager/PipelineCreateDialog.tsx +++ b/frontend/src/components/deploy/pipeline-manager/PipelineCreateDialog.tsx @@ -1,28 +1,36 @@ import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Rocket, Plus, GitBranch, - Tag, - Server, - Network, Globe, Container, Loader2, Trash2, - ChevronRight as ChevronRightSmall, + ChevronDown, + Settings2, } from 'lucide-react'; -import { credentialApi, deployApi, pipelineApi } from '@/api'; +import { buildNodeApi, credentialApi, deployApi, pipelineApi, type BuildNode } from '@/api'; import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; import { nameToCode } from '../pipeline-helpers'; import { STAGE_DEFS } from './constants'; +import { DeployTargetFields, type DeployTargetValue } from './DeployTargetFields'; interface PipelineCreateDialogProps { open: boolean; @@ -30,45 +38,79 @@ interface PipelineCreateDialogProps { onCreated: () => void; } -// 新建流水线对话框(含表单状态) // Logan.Li +const PRESETS = [ + { + key: 'ssh', + label: '主机发布', + desc: 'SCP 上传构建产物到关联主机', + stages: ['checkout', 'build', 'deploy'], + deployTarget: 'ssh' as const, + }, + { + key: 'k8s', + label: 'Kubernetes 发布', + desc: '镜像打包后 CI 更新 Deployment', + stages: ['checkout', 'build', 'image', 'deploy'], + deployTarget: 'k8s' as const, + }, +]; + +const DEFAULT_TARGET: DeployTargetValue = { + deployTarget: 'ssh', + serverIds: [], + deployRemotePath: '', + deployScript: '', + k8sClusterId: '', + k8sNamespace: '', + k8sDeployment: '', + k8sContainerName: '', +}; + +// 新建流水线对话框 — 精简分步 + 双发布目标 // Logan.Li export function PipelineCreateDialog({ open, onOpenChange, onCreated }: PipelineCreateDialogProps) { + const { t } = useTranslation(); + const [step, setStep] = useState<1 | 2>(1); + const [preset, setPreset] = useState('ssh'); const [newName, setNewName] = useState(''); - const [newJobName, setNewJobName] = useState(''); const [newDesc, setNewDesc] = useState(''); const [newSource, setNewSource] = useState(''); const [newBranch, setNewBranch] = useState('main'); const [newDockerfile, setNewDockerfile] = useState(''); - const [selectedStages, setSelectedStages] = useState(['checkout', 'build', 'image', 'deploy']); - const [newDeployMode, setNewDeployMode] = useState('branch'); - const [newDeployTarget, setNewDeployTarget] = useState('ssh'); + const [selectedStages, setSelectedStages] = useState(['checkout', 'build', 'deploy']); + const [deployTarget, setDeployTarget] = useState(DEFAULT_TARGET); const [newEnvironmentId, setNewEnvironmentId] = useState(''); const [saving, setSaving] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(false); const [gitCredentials, setGitCredentials] = useState[]>([]); const [dockerCredentials, setDockerCredentials] = useState[]>([]); const [newGitCredentialId, setNewGitCredentialId] = useState(''); const [newRegistryCredentialId, setNewRegistryCredentialId] = useState(''); + const [newJobName, setNewJobName] = useState(''); const [newTriggerMode, setNewTriggerMode] = useState('manual'); const [newWatchBranches, setNewWatchBranches] = useState(''); - const [newExcludeBranch, setNewExcludeBranch] = useState(''); const [newCronExpr, setNewCronExpr] = useState(''); - const [newWebhookToken, setNewWebhookToken] = useState(''); - const [newEnvVars, setNewEnvVars] = useState<{ key: string; value: string; desc: string }[]>([]); - const [newCachePaths, setNewCachePaths] = useState(''); - const [newMaxExecutors, setNewMaxExecutors] = useState(2); + const [newEnvVars, setNewEnvVars] = useState<{ key: string; value: string }[]>([]); const [environments, setEnvironments] = useState[]>([]); + const [buildNodes, setBuildNodes] = useState([]); + const [buildNodeId, setBuildNodeId] = useState(''); const loadRefs = useCallback(async () => { try { - const [rEnv, rCred] = await Promise.all([ + const [rEnv, rCred, rNodes] = await Promise.all([ deployApi.getEnvironments().catch(() => ({ list: [] })), credentialApi.list({ page_size: 100 }).catch(() => ({ list: [] })), + buildNodeApi.list().catch(() => ({ list: [] })), ] as const); - setEnvironments(Array.isArray((rEnv as Record)?.list) ? (rEnv as Record).list as Record[] : []); + setEnvironments(Array.isArray((rEnv as Record)?.list) + ? (rEnv as Record).list as Record[] + : []); const allCreds: Record[] = (rCred as Record)?.list as Record[] || []; setGitCredentials(allCreds.filter((c) => c.secret_type === 'api')); setDockerCredentials(allCreds.filter((c) => c.secret_type === 'docker')); + const nodeData = rNodes as { list?: BuildNode[] }; + setBuildNodes(Array.isArray(nodeData?.list) ? nodeData.list.filter((n) => n.enabled) : []); } catch { /* silent */ } }, []); @@ -76,33 +118,71 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline if (open) loadRefs(); }, [open, loadRefs]); + const applyPreset = (key: string) => { + const p = PRESETS.find((x) => x.key === key); + if (!p) return; + setPreset(key); + setSelectedStages(p.stages); + setDeployTarget({ + ...DEFAULT_TARGET, + deployTarget: p.deployTarget, + }); + }; + const resetForm = () => { + setStep(1); + setPreset('ssh'); setNewName(''); - setNewJobName(''); setNewDesc(''); setNewSource(''); setNewBranch('main'); setNewDockerfile(''); - setSelectedStages(['checkout', 'build', 'image', 'deploy']); - setNewDeployMode('branch'); - setNewDeployTarget('ssh'); + setSelectedStages(['checkout', 'build', 'deploy']); + setDeployTarget(DEFAULT_TARGET); setNewEnvironmentId(''); setNewGitCredentialId(''); setNewRegistryCredentialId(''); + setNewJobName(''); setNewTriggerMode('manual'); setNewWatchBranches(''); - setNewExcludeBranch(''); setNewCronExpr(''); - setNewWebhookToken(''); setNewEnvVars([]); - setNewCachePaths(''); - setNewMaxExecutors(2); + setAdvancedOpen(false); + setBuildNodeId(''); + }; + + const validateStep1 = () => { + if (!newName.trim()) { + toast.error('请输入流水线名称'); + return false; + } + return true; + }; + + const validateStep2 = () => { + if (selectedStages.includes('checkout') && !newSource.trim()) { + toast.error(t('deploy.stage.checkoutNeedGit')); + return false; + } + if (selectedStages.includes('image') && !newRegistryCredentialId) { + toast.error(t('deploy.stage.imageNeedDocker')); + return false; + } + if (selectedStages.includes('deploy')) { + if (deployTarget.deployTarget === 'ssh' && deployTarget.serverIds.length === 0) { + toast.error('主机发布需至少关联一台主机'); + return false; + } + if (deployTarget.deployTarget === 'k8s' && (!deployTarget.k8sClusterId || !deployTarget.k8sDeployment)) { + toast.error('Kubernetes 发布需选择集群与 Deployment'); + return false; + } + } + return true; }; const handleCreate = async () => { - if (!newName.trim()) { toast.error('请输入流水线名称'); return; } - if (selectedStages.includes('checkout') && !newSource.trim()) { toast.error('代码检出阶段需要填写 Git URL'); return; } - if (selectedStages.includes('image') && !newRegistryCredentialId) { toast.error('镜像打包阶段需要关联 Docker 凭证'); return; } + if (!validateStep1() || !validateStep2()) return; setSaving(true); try { const stageNames = selectedStages.map((k) => STAGE_DEFS.find((s) => s.key === k)?.label || k); @@ -122,381 +202,358 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline dockerfile: newDockerfile, type: 'deploy', stages, - deploy_mode: newDeployMode, - deploy_target: newDeployTarget, + deploy_mode: 'branch', + deploy_target: deployTarget.deployTarget, + server_ids: deployTarget.serverIds.length > 0 ? JSON.stringify(deployTarget.serverIds) : undefined, + deploy_remote_path: deployTarget.deployRemotePath || undefined, + deploy_script: deployTarget.deployScript || undefined, + k8s_cluster_id: deployTarget.k8sClusterId ? Number(deployTarget.k8sClusterId) : null, + k8s_namespace: deployTarget.k8sNamespace || undefined, + k8s_deployment: deployTarget.k8sDeployment || undefined, + k8s_container_name: deployTarget.k8sContainerName || undefined, environment_id: newEnvironmentId ? Number(newEnvironmentId) : null, - repo_id: null, - registry_id: null, git_credential_id: newGitCredentialId ? Number(newGitCredentialId) : null, registry_credential_id: newRegistryCredentialId ? Number(newRegistryCredentialId) : null, trigger_mode: newTriggerMode, watch_branches: newWatchBranches || undefined, - exclude_branch: newExcludeBranch || undefined, cron_expr: newCronExpr || undefined, - webhook_token: newWebhookToken || undefined, variables: newEnvVars.length > 0 ? JSON.stringify(newEnvVars) : undefined, - cache_paths: newCachePaths || undefined, - max_executors: newMaxExecutors, + build_node_id: buildNodeId ? Number(buildNodeId) : null, }); - toast.success('流水线已创建'); + toast.success(t('deploy.pipeline.created')); onOpenChange(false); resetForm(); onCreated(); } catch { - toast.error('创建失败'); + toast.error(t('deploy.pipeline.createFailed')); } finally { setSaving(false); } }; - return ( -

- - - 新建流水线 - -
-
-
- - setNewName(e.target.value)} placeholder="例如: demo-jar 后端部署" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9" /> -
-
- - setNewJobName(e.target.value)} placeholder="cex-admin-server" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9 font-mono text-sm" /> - {newJobName &&

Webhook: POST .../webhook/job/{newJobName}

} -
-
-
- -