diff --git a/backend/internal/api/handlers/agent_install.go b/backend/internal/api/handlers/agent_install.go index 30d6d0d..99e902c 100644 --- a/backend/internal/api/handlers/agent_install.go +++ b/backend/internal/api/handlers/agent_install.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "strconv" + "strings" "time" "ezops/internal/models" @@ -423,9 +424,9 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { binaryFile = "aiops-agent-linux-arm64" } - // 下载新二进制 + 重启服务 + // 下载新二进制 + 获取版本号 + 重启服务 upgradeScript := fmt.Sprintf( - `curl -fsSL -o /tmp/aiops-agent %s/api/agent/bin/%s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent && echo "UPGRADE_OK"`, + `curl -fsSL -o /tmp/aiops-agent %s/api/agent/bin/%s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent output 2>/dev/null | grep 'agent.version' | awk -F'[|[|]' '{print $2}' | tr -d ' '); echo "UPGRADE_OK ver=$ver"`, serverURL, binaryFile, ) @@ -437,9 +438,15 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { continue } - // 更新版本号 + // 从输出提取版本号 + agentVersion := "unknown" + parts := strings.Split(out, "ver=") + if len(parts) > 1 { + agentVersion = strings.TrimSpace(parts[1]) + } + h.db.Model(&server).Updates(map[string]interface{}{ - "agent_version": "v0.1.0", + "agent_version": agentVersion, "last_check_at": time.Now(), }) log.Printf("[BatchUpgrade] %s Agent 升级成功", server.Hostname) diff --git a/backend/internal/api/handlers/agent_task.go b/backend/internal/api/handlers/agent_task.go index 8e42c35..76be103 100644 --- a/backend/internal/api/handlers/agent_task.go +++ b/backend/internal/api/handlers/agent_task.go @@ -191,7 +191,7 @@ func (h *AgentTaskHandler) QuickSetupRules(c *gin.Context) { {Name: "CPU使用率(Warn)", Metric: "cpu", Operator: ">", Threshold: 80}, {Name: "内存使用率(Warn)", Metric: "mem", Operator: ">", Threshold: 80}, {Name: "磁盘使用率(Warn)", Metric: "disk", Operator: ">", Threshold: 80}, - {Name: "网络带宽使用率(Warn)", Metric: "net", Operator: ">", Threshold: 80}, + {Name: "FD使用率(Warn)", Metric: "fd", Operator: ">", Threshold: 80}, } created := 0 diff --git a/backend/internal/api/handlers/alert_engine.go b/backend/internal/api/handlers/alert_engine.go index cdb9169..2b14ed9 100644 --- a/backend/internal/api/handlers/alert_engine.go +++ b/backend/internal/api/handlers/alert_engine.go @@ -30,7 +30,7 @@ func (e *AlertEngine) Check() { // 检查 CPU/内存/磁盘/进程/TCP连接/端口 阈值 func (e *AlertEngine) checkResourceAlerts() { var rules []models.AgentRule - e.db.Where("enabled = ? AND metric IN ?", true, []string{"cpu", "mem", "disk", "service_process", "tcp_time_wait", "tcp_close_wait"}).Find(&rules) + e.db.Where("enabled = ? AND metric IN ?", true, []string{"cpu", "mem", "disk", "service_process", "tcp_time_wait", "tcp_close_wait", "fd"}).Find(&rules) for _, rule := range rules { // 获取目标主机最新上报 @@ -81,6 +81,10 @@ func (e *AlertEngine) checkResourceAlerts() { continue // 进程存在,不触发 } value = 0 // 进程不存在时触发 + case "fd": + var fdStat models.FDMetric + json.Unmarshal([]byte(report.FDInfo), &fdStat) + value = fdStat.UsagePct } // 检查是否超阈值 @@ -288,7 +292,7 @@ func (e *AlertEngine) autoResolveAlerts() { if report.ID > 0 && time.Since(report.ReportTime) < 3*time.Minute { resolved = true } - case "cpu", "mem", "disk", "tcp_time_wait", "tcp_close_wait": + case "cpu", "mem", "disk", "tcp_time_wait", "tcp_close_wait", "fd": // 获取对应规则,检查是否恢复正常 var rule models.AgentRule e.db.Where("name = ?", alert.RuleName).First(&rule) @@ -326,6 +330,10 @@ func (e *AlertEngine) autoResolveAlerts() { var netStat models.NetworkStatModel json.Unmarshal([]byte(report.NetInfo), &netStat) value = float64(netStat.TCPCloseWait) + case "fd": + var fdStat models.FDMetric + json.Unmarshal([]byte(report.FDInfo), &fdStat) + value = fdStat.UsagePct } if !e.compareOperator(rule.Operator, value, rule.Threshold) { resolved = true diff --git a/backend/internal/api/handlers/dashboard.go b/backend/internal/api/handlers/dashboard.go index 3996fed..bdf5da0 100644 --- a/backend/internal/api/handlers/dashboard.go +++ b/backend/internal/api/handlers/dashboard.go @@ -294,28 +294,24 @@ func (h *DashboardHandler) GetMetrics(c *gin.Context) { } } - // 从 agent_reports 表查最近 N 小时的真实数据(MySQL 聚合) + // 从 agent_reports 表查最近 N 小时的真实数据(整数分组,可用索引) since := time.Now().Add(-time.Duration(hours) * time.Hour) type avgPoint struct { - Bucket string - AvgCPU float64 - AvgMem float64 + BucketTS int64 + AvgCPU float64 + AvgMem float64 } var rows []avgPoint h.db.Model(&models.AgentReport{}). - Select("DATE_FORMAT(report_time, '%Y-%m-%d %H:00') as bucket, ROUND(AVG(cpu_usage),2) as avg_cpu, ROUND(AVG(mem_usage),2) as avg_mem"). + Select("UNIX_TIMESTAMP(report_time) DIV 3600 as bucket_ts, ROUND(AVG(cpu_usage),2) as avg_cpu, ROUND(AVG(mem_usage),2) as avg_mem"). Where("report_time >= ?", since). - Group("bucket"). - Order("bucket ASC"). + Group("bucket_ts"). + Order("bucket_ts ASC"). Scan(&rows) var metrics []models.MetricData for _, r := range rows { - // 手动解析 SQLite strftime 字符串为 time.Time(GORM 无法自动转换) // Logan.Li - ts, err := time.ParseInLocation("2006-01-02 15:00", r.Bucket, time.Local) - if err != nil { - ts = time.Now() - } + ts := time.Unix(r.BucketTS*3600, 0) metrics = append(metrics, models.MetricData{Name: "cpu", Value: r.AvgCPU, Timestamp: ts}, models.MetricData{Name: "memory", Value: r.AvgMem, Timestamp: ts}, diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index 4867c2f..68471fd 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -1806,6 +1806,32 @@ func (h *KubernetesHandler) resolveClusterCode(code string) int { return int(cluster.ID) } +// formatAge converts a K8s creationTimestamp (RFC3339) to a human-readable relative age +func formatAge(creationTimestamp string) string { + if creationTimestamp == "" { + return "-" + } + t, err := time.Parse(time.RFC3339, creationTimestamp) + if err != nil { + return creationTimestamp + } + d := time.Since(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + case d < 30*24*time.Hour: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + case d < 365*24*time.Hour: + return fmt.Sprintf("%dM", int(d.Hours()/(24*30))) + default: + return fmt.Sprintf("%dy", int(d.Hours()/(24*365))) + } +} + // getK8sClientByCode resolves cluster by code, falls back to ID func (h *KubernetesHandler) getK8sClientByCode(c *gin.Context) (*k8sclient.Client, *models.KubernetesCluster, error) { clusterID, _ := strconv.Atoi(c.Param("id")) @@ -2571,6 +2597,12 @@ func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) { apiPath = fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers/%s", ns, name) case "cronjobs", "cronjob": apiPath = fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", ns, name) + case "serviceaccounts", "serviceaccount": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts/%s", ns, name) + case "roles", "role": + apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles/%s", ns, name) + case "rolebindings", "rolebinding": + apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name) default: c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return } @@ -2627,6 +2659,12 @@ func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) { apiPath = fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers/%s", ns, name) case "cronjobs", "cronjob": apiPath = fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", ns, name) + case "serviceaccounts", "serviceaccount": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts/%s", ns, name) + case "roles", "role": + apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles/%s", ns, name) + case "rolebindings", "rolebinding": + apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name) default: c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return } @@ -2663,6 +2701,12 @@ func resourceAPIPath(ns, resource, name string) (string, error) { return fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers/%s", ns, name), nil case "cronjobs", "cronjob": return fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", ns, name), nil + case "serviceaccounts", "serviceaccount": + return fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts/%s", ns, name), nil + case "roles", "role": + return fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles/%s", ns, name), nil + case "rolebindings", "rolebinding": + return fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name), nil default: return "", fmt.Errorf("unsupported resource type: %s", resource) } @@ -3744,3 +3788,151 @@ func (h *KubernetesHandler) GetMultiClusterComparison(c *gin.Context) { }, }) } + +// ============================================================================ +// RBAC — ServiceAccount / Role / RoleBinding (code-based) +// ============================================================================ + +// ListServiceAccountsNS lists ServiceAccounts in a namespace +func (h *KubernetesHandler) ListServiceAccountsNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { + c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}) + return + } + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: err.Error()}) + return + } + sas, err := client.ListServiceAccounts(c.Param("namespace")) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + type SASummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + SecretCount int `json:"secret_count"` + ImagePullSecrets []string `json:"image_pull_secrets"` + Age string `json:"age"` + } + result := make([]SASummary, 0, len(sas)) + for _, sa := range sas { + ipsNames := make([]string, 0, len(sa.ImagePullSecrets)) + for _, ips := range sa.ImagePullSecrets { + ipsNames = append(ipsNames, ips.Name) + } + result = append(result, SASummary{ + Namespace: sa.Metadata.Namespace, + Name: sa.Metadata.Name, + SecretCount: len(sa.Secrets), + ImagePullSecrets: ipsNames, + Age: formatAge(sa.Metadata.CreationTimestamp), + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ListRolesNS lists Roles in a namespace +func (h *KubernetesHandler) ListRolesNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { + c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}) + return + } + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: err.Error()}) + return + } + roles, err := client.ListRoles(c.Param("namespace")) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + type RoleSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + RuleCount int `json:"rule_count"` + Verbs []string `json:"verbs"` + Resources []string `json:"resources"` + Age string `json:"age"` + } + result := make([]RoleSummary, 0, len(roles.Items)) + for _, r := range roles.Items { + allVerbs := []string{} + allResources := []string{} + seen := map[string]bool{} + for _, rule := range r.Rules { + for _, v := range rule.Verbs { + if !seen[v] { + allVerbs = append(allVerbs, v) + seen[v] = true + } + } + for _, res := range rule.Resources { + if !seen[res] { + allResources = append(allResources, res) + seen[res] = true + } + } + } + result = append(result, RoleSummary{ + Namespace: r.Metadata.Namespace, + Name: r.Metadata.Name, + RuleCount: len(r.Rules), + Verbs: allVerbs, + Resources: allResources, + Age: formatAge(r.Metadata.CreationTimestamp), + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ListRoleBindingsNS lists RoleBindings in a namespace +func (h *KubernetesHandler) ListRoleBindingsNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { + c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}) + return + } + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: err.Error()}) + return + } + rbs, err := client.ListRoleBindings(c.Param("namespace")) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + type RBSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + RoleRefKind string `json:"role_ref_kind"` + RoleRefName string `json:"role_ref_name"` + Subjects []string `json:"subjects"` + Age string `json:"age"` + } + result := make([]RBSummary, 0, len(rbs.Items)) + for _, rb := range rbs.Items { + subjStrs := make([]string, 0, len(rb.Subjects)) + for _, s := range rb.Subjects { + if s.Namespace != "" && s.Namespace != rb.Metadata.Namespace { + subjStrs = append(subjStrs, fmt.Sprintf("%s/%s (%s)", s.Kind, s.Name, s.Namespace)) + } else { + subjStrs = append(subjStrs, fmt.Sprintf("%s/%s", s.Kind, s.Name)) + } + } + result = append(result, RBSummary{ + Namespace: rb.Metadata.Namespace, + Name: rb.Metadata.Name, + RoleRefKind: rb.RoleRef.Kind, + RoleRefName: rb.RoleRef.Name, + Subjects: subjStrs, + Age: formatAge(rb.Metadata.CreationTimestamp), + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +}