From 732974d1525a0307575161cabd09011dd6da4985 Mon Sep 17 00:00:00 2001 From: attacker Date: Fri, 19 Jun 2026 15:01:54 +0000 Subject: [PATCH] refactor: unify kubernetes_ table prefix, merge CI/CD models, LLM session support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename container_clusters → kubernetes_clusters, container_topology_layouts → kubernetes_topology_layouts - LLM: add session_id to chat history for multi-turn conversation support - CI/CD: add pipeline_id, deploy_type, artifact_url, k8s_cluster_id to AppRelease - Cleanup: remove deprecated playbook handler, simplify automation models - Database: optimize auto-migrate model list - Frontend: AIOps assistant improvements, api.ts updates --- .../internal/api/handlers/automation_cron.go | 5 +- .../api/handlers/automation_playbook.go | 358 ------------------ backend/internal/api/handlers/llm_chat.go | 18 +- backend/internal/api/handlers/llm_history.go | 22 +- .../internal/api/handlers/pipeline_release.go | 31 +- backend/internal/api/routes/routes.go | 16 +- backend/internal/models/automation.go | 40 +- backend/internal/models/cicd.go | 38 +- backend/internal/models/kubernetes.go | 4 +- backend/internal/models/llm.go | 16 +- backend/pkg/database/database.go | 14 - docker-compose.yml | 4 + .../src/components/aiops/AIOpsAssistant.tsx | 12 +- frontend/src/services/api.ts | 17 +- 14 files changed, 121 insertions(+), 474 deletions(-) delete mode 100644 backend/internal/api/handlers/automation_playbook.go diff --git a/backend/internal/api/handlers/automation_cron.go b/backend/internal/api/handlers/automation_cron.go index 1ffec75..ccafcc0 100644 --- a/backend/internal/api/handlers/automation_cron.go +++ b/backend/internal/api/handlers/automation_cron.go @@ -304,7 +304,10 @@ func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) { // Resolve the content to execute. For type=script, load the linked Script. content := job.Content - scriptType := "shell" + scriptType := job.Type // shell | python | command | script + if scriptType == "command" { + scriptType = "shell" // one-liner commands run via bash + } if job.Type == "script" && job.ScriptID != nil { var script models.Script if err := h.db.First(&script, *job.ScriptID).Error; err == nil { diff --git a/backend/internal/api/handlers/automation_playbook.go b/backend/internal/api/handlers/automation_playbook.go deleted file mode 100644 index ebc0c55..0000000 --- a/backend/internal/api/handlers/automation_playbook.go +++ /dev/null @@ -1,358 +0,0 @@ -package handlers - -import ( - "bytes" - "context" - "fmt" - "net/http" - "os" - "os/exec" - "strconv" - "time" - - "ezops/internal/models" - - "github.com/gin-gonic/gin" -) - -// runPlaybookLocal executes an Ansible playbook YAML locally on the AIOps host. -// It writes the content to a temporary file and invokes `ansible-playbook` -// against localhost with a local connection. If the ansible-playbook binary is -// missing the exec error is captured in the output. Returns combined -// stdout+stderr, the exit code (-1 on failure) and duration in milliseconds. -func runPlaybookLocal(content, inventory string, timeoutSec int) (output string, exitCode int, durationMs int) { - start := time.Now() - timeout := time.Duration(timeoutSec) * time.Second - if timeout <= 0 { - timeout = 600 * time.Second - } - - // Write the playbook YAML to a temporary file. - tmpFile, err := os.CreateTemp("", "ezops-playbook-*.yml") - if err != nil { - return fmt.Sprintf("创建临时文件失败: %v", err), -1, 0 - } - defer os.Remove(tmpFile.Name()) - if _, err := tmpFile.WriteString(content); err != nil { - tmpFile.Close() - return fmt.Sprintf("写入临时文件失败: %v", err), -1, 0 - } - tmpFile.Close() - - // Write an optional inventory file. - inventoryArg := "localhost," - if inventory != "" { - invFile, err := os.CreateTemp("", "ezops-inventory-*.ini") - if err == nil { - if _, werr := invFile.WriteString(inventory); werr == nil { - inventoryArg = invFile.Name() - defer os.Remove(invFile.Name()) - } - invFile.Close() - } - } - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - args := []string{"-i", inventoryArg} - if inventoryArg == "localhost," { - args = append(args, "--connection=local") - } - args = append(args, tmpFile.Name()) - - cmd := exec.CommandContext(ctx, "ansible-playbook", args...) - var out bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &out - runErr := cmd.Run() - - durationMs = int(time.Since(start).Milliseconds()) - output = out.String() - - if ctx.Err() == context.DeadlineExceeded { - output += fmt.Sprintf("\n[超时] 执行超过 %d 秒被终止", timeoutSec) - return output, -1, durationMs - } - if runErr != nil { - if exitError, ok := runErr.(*exec.ExitError); ok { - exitCode = exitError.ExitCode() - } else { - // Binary not found or other exec error (e.g. ansible-playbook not - // installed). Provide a clear hint instead of an empty output. - exitCode = -1 - if output == "" { - output = fmt.Sprintf("无法执行 ansible-playbook:%v\n(请确认 AIOps 主机已安装 ansible:yum install -y ansible / pip install ansible)", runErr) - } - } - return output, exitCode, durationMs - } - return output, 0, durationMs -} - -// ListPlaybooks 获取Playbook列表 -// @Summary 获取Playbook列表 -// @Description 获取所有Ansible Playbook列表 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param page query int false "页码" -// @Param page_size query int false "每页数量" -// @Success 200 {object} models.Response{data=models.ListResponse} -// @Router /playbooks [get] -func (h *AutomationHandler) ListPlaybooks(c *gin.Context) { - var query models.PageQuery - c.ShouldBindQuery(&query) - - var total int64 - h.db.Model(&models.Playbook{}).Count(&total) - - var playbooks []models.Playbook - h.db.Order("created_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&playbooks) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: models.ListResponse{ - List: playbooks, - Total: total, - }, - }) -} - -// GetPlaybook 获取Playbook详情 -// @Summary 获取Playbook详情 -// @Description 获取指定Playbook的详细信息 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param id path int true "Playbook ID" -// @Success 200 {object} models.Response{data=models.Playbook} -// @Router /playbooks/{id} [get] -func (h *AutomationHandler) GetPlaybook(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var playbook models.Playbook - if err := h.db.First(&playbook, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "Playbook不存在", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: playbook, - }) -} - -// CreatePlaybook 创建Playbook -// @Summary 创建Playbook -// @Description 创建新的Ansible Playbook -// @Tags 自动化运维 -// @Accept json -// @Produce json -// @Security Bearer -// @Param request body models.CreatePlaybookRequest true "创建请求" -// @Success 200 {object} models.Response{data=models.Playbook} -// @Router /playbooks [post] -func (h *AutomationHandler) CreatePlaybook(c *gin.Context) { - var req models.CreatePlaybookRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - playbook := models.Playbook{ - Name: req.Name, - Code: randomCode("pb"), - Description: req.Description, - Content: req.Content, - Category: req.Category, - Tags: req.Tags, - Variables: req.Variables, - Inventory: req.Inventory, - Timeout: req.Timeout, - } - if playbook.Category == "" { - playbook.Category = "deployment" - } - if playbook.Timeout == 0 { - playbook.Timeout = 600 - } - - if err := h.db.Create(&playbook).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建失败: " + err.Error(), - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "创建成功", - Data: playbook, - }) -} - -// UpdatePlaybook 更新Playbook -// @Summary 更新Playbook -// @Description 更新指定Playbook -// @Tags 自动化运维 -// @Accept json -// @Produce json -// @Security Bearer -// @Param id path int true "Playbook ID" -// @Param request body models.CreatePlaybookRequest true "更新请求" -// @Success 200 {object} models.Response{data=models.Playbook} -// @Router /playbooks/{id} [put] -func (h *AutomationHandler) UpdatePlaybook(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var req models.CreatePlaybookRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - var playbook models.Playbook - if err := h.db.First(&playbook, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "Playbook不存在", - }) - return - } - - updates := map[string]interface{}{ - "name": req.Name, - "description": req.Description, - "content": req.Content, - "category": req.Category, - "tags": req.Tags, - "variables": req.Variables, - "inventory": req.Inventory, - "timeout": req.Timeout, - } - - if err := h.db.Model(&playbook).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "更新失败", - }) - return - } - - h.db.First(&playbook, id) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "更新成功", - Data: playbook, - }) -} - -// DeletePlaybook 删除Playbook -// @Summary 删除Playbook -// @Description 删除指定Playbook -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param id path int true "Playbook ID" -// @Success 200 {object} models.Response -// @Router /playbooks/{id} [delete] -func (h *AutomationHandler) DeletePlaybook(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - if err := h.db.Delete(&models.Playbook{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "删除失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "删除成功", - }) -} - -// ExecutePlaybook 执行Playbook -// @Summary 执行Playbook -// @Description 在AIOps主机上本地执行Ansible Playbook -// @Tags 自动化运维 -// @Accept json -// @Produce json -// @Security Bearer -// @Param id path int true "Playbook ID" -// @Success 200 {object} models.Response{data=models.TaskExecution} -// @Router /playbooks/{id}/execute [post] -func (h *AutomationHandler) ExecutePlaybook(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var playbook models.Playbook - if err := h.db.First(&playbook, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "Playbook不存在", - }) - return - } - - now := time.Now() - execution := models.TaskExecution{ - TaskType: "playbook", - TaskID: uint(id), - TaskName: playbook.Name, - Status: "running", - StartedAt: &now, - } - if err := h.db.Create(&execution).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建执行记录失败", - }) - return - } - - // Execute the playbook locally on the AIOps host. - output, exitCode, duration := runPlaybookLocal(playbook.Content, playbook.Inventory, playbook.Timeout) - finishedAt := time.Now() - status := "success" - if exitCode != 0 { - status = "failed" - } - - h.db.Model(&execution).Updates(map[string]interface{}{ - "status": status, - "output": output, - "duration": duration, - "finished_at": finishedAt, - }) - h.db.Model(&playbook).UpdateColumn("run_count", playbook.RunCount+1) - - execution.Output = output - execution.Status = status - execution.Duration = duration - execution.FinishedAt = &finishedAt - - msg := "执行成功" - if status == "failed" { - msg = "执行失败" - } - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: msg, - Data: execution, - }) -} diff --git a/backend/internal/api/handlers/llm_chat.go b/backend/internal/api/handlers/llm_chat.go index e93c450..ff70eb8 100644 --- a/backend/internal/api/handlers/llm_chat.go +++ b/backend/internal/api/handlers/llm_chat.go @@ -90,9 +90,14 @@ func (h *LLMHandler) Chat(c *gin.Context) { // 取最后一条用户消息(前端已附加当前提问) lastUser := req.Messages[len(req.Messages)-1] now := time.Now() + // session_id:前端传入或后端生成(兼容旧版客户端) // Logan.Li + sid := req.SessionID + if sid == "" { + sid = fmt.Sprintf("sess-%d", now.UnixMilli()) + } h.db.Create(&[]models.ChatHistory{ - {UserID: uid, Role: "user", Content: lastUser.Content, CreatedAt: now}, - {UserID: uid, Role: "assistant", Content: replyContent, Model: cfg.Model, CreatedAt: now.Add(time.Millisecond)}, + {UserID: uid, SessionID: sid, Role: "user", Content: lastUser.Content, CreatedAt: now}, + {UserID: uid, SessionID: sid, Role: "assistant", Content: replyContent, Model: cfg.Model, CreatedAt: now.Add(time.Millisecond)}, }) } @@ -251,9 +256,14 @@ func (h *LLMHandler) ChatStream(c *gin.Context) { if uid != 0 { lastUser := req.Messages[len(req.Messages)-1] now := time.Now() + // session_id:前端传入或后端生成(兼容旧版客户端) // Logan.Li + sid := req.SessionID + if sid == "" { + sid = fmt.Sprintf("sess-%d", now.UnixMilli()) + } h.db.Create(&[]models.ChatHistory{ - {UserID: uid, Role: "user", Content: lastUser.Content, CreatedAt: now}, - {UserID: uid, Role: "assistant", Content: fullContent, Model: cfg.Model, CreatedAt: now.Add(time.Millisecond)}, + {UserID: uid, SessionID: sid, Role: "user", Content: lastUser.Content, CreatedAt: now}, + {UserID: uid, SessionID: sid, Role: "assistant", Content: fullContent, Model: cfg.Model, CreatedAt: now.Add(time.Millisecond)}, }) } // 发送结束标志 diff --git a/backend/internal/api/handlers/llm_history.go b/backend/internal/api/handlers/llm_history.go index ca4b35f..aa9ae04 100644 --- a/backend/internal/api/handlers/llm_history.go +++ b/backend/internal/api/handlers/llm_history.go @@ -9,16 +9,24 @@ import ( "github.com/gin-gonic/gin" ) -// GetChatHistory 获取当前用户的对话历史(按时间正序,仅返回最近 100 条避免过大) // Logan.Li -// GET /api/v1/aiops/chat-history +// GetChatHistory 获取当前用户的对话历史(按 session_id 过滤,默认返回最新 session) // Logan.Li +// GET /api/v1/aiops/chat-history?session_id=xxx func (h *LLMHandler) GetChatHistory(c *gin.Context) { uid := middleware.GetUserID(c) + sid := c.Query("session_id") var rows []models.ChatHistory - // 子查询取最近 100 条后再正序返回,保证前端渲染顺序正确 // Logan.Li - h.db.Where("user_id = ?", uid). - Order("id DESC"). - Limit(100). - Find(&rows) + q := h.db.Where("user_id = ?", uid) + // 指定 session_id 直接过滤;未指定则查最新 session // Logan.Li + if sid != "" { + q = q.Where("session_id = ?", sid) + } else { + // 取该用户最新的 session_id(id 最大那条的 session_id) + var latest models.ChatHistory + if err := h.db.Where("user_id = ?", uid).Order("id DESC").First(&latest).Error; err == nil && latest.SessionID != "" { + q = q.Where("session_id = ?", latest.SessionID) + } + } + q.Order("id DESC").Limit(100).Find(&rows) // 反转为时间正序(最早在前) for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { rows[i], rows[j] = rows[j], rows[i] diff --git a/backend/internal/api/handlers/pipeline_release.go b/backend/internal/api/handlers/pipeline_release.go index 310ec1b..2a11c37 100644 --- a/backend/internal/api/handlers/pipeline_release.go +++ b/backend/internal/api/handlers/pipeline_release.go @@ -1,6 +1,7 @@ package handlers import ( + "encoding/json" "net/http" "strconv" @@ -65,14 +66,28 @@ func (h *PipelineHandler) CreateRelease(c *gin.Context) { } release := models.AppRelease{ - AppName: req.AppName, - Version: req.Version, - Environment: req.Environment, - Status: "pending", - CommitID: req.CommitID, - CommitMsg: req.CommitMsg, - Servers: "[]", - Description: req.Description, + PipelineID: req.PipelineID, + PipelineRunID: req.PipelineRunID, + AppName: req.AppName, + Version: req.Version, + Environment: req.Environment, + DeployType: req.DeployType, + Status: "pending", + CommitID: req.CommitID, + CommitMsg: req.CommitMsg, + ImageTag: req.ImageTag, + K8sClusterID: req.K8sClusterID, + K8sNamespace: req.K8sNamespace, + Servers: "[]", + Description: req.Description, + } + + if req.DeployType == "" { + release.DeployType = "server" + } + if len(req.Servers) > 0 { + data, _ := json.Marshal(req.Servers) + release.Servers = string(data) } if err := h.db.Create(&release).Error; err != nil { diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 25d7f5a..0407cc2 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -419,19 +419,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c scripts.POST("/:id/execute", automationHandler.ExecuteScript) } - playbooks := authorized.Group("/playbooks") - { - playbooks.GET("", automationHandler.ListPlaybooks) - playbooks.POST("", automationHandler.CreatePlaybook) - playbooks.GET("/:id", automationHandler.GetPlaybook) - playbooks.PUT("/:id", automationHandler.UpdatePlaybook) - playbooks.DELETE("/:id", automationHandler.DeletePlaybook) - playbooks.POST("/:id/execute", automationHandler.ExecutePlaybook) - } - - // 任务执行记录(脚本/定时任务执行历史) - authorized.GET("/task-executions", automationHandler.ListExecutions) - // Ansible 自动化路由 // Logan.Li ansibleHandler := handlers.NewAnsibleHandler(db) ansible := authorized.Group("/ansible") @@ -457,6 +444,9 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c ansible.GET("/match-hosts", ansibleHandler.MatchHosts) } + // 任务执行记录(脚本/定时任务执行历史,不含 Ansible) + authorized.GET("/task-executions", automationHandler.ListExecutions) + // 日志监控路由 // Logan.Li logHandler := handlers.NewLogHandler(db) logs := authorized.Group("/logs") diff --git a/backend/internal/models/automation.go b/backend/internal/models/automation.go index 78a3944..121360f 100644 --- a/backend/internal/models/automation.go +++ b/backend/internal/models/automation.go @@ -59,10 +59,10 @@ func (Script) TableName() string { return "ops_scripts" } -// TaskExecution 任务执行记录 +// TaskExecution 任务执行记录(cron / script,不含 Ansible;Ansible 走 AnsibleJob) type TaskExecution struct { ID uint `gorm:"primarykey" json:"id"` - TaskType string `gorm:"size:32;not null" json:"task_type"` // cron, script, playbook + TaskType string `gorm:"size:32;not null" json:"task_type"` // cron, script TaskID uint `json:"task_id"` TaskName string `gorm:"size:128" json:"task_name"` Status string `gorm:"size:32;default:pending" json:"status"` // pending, running, success, failed, cancelled @@ -83,42 +83,6 @@ func (TaskExecution) TableName() string { return "ops_task_executions" } -// Playbook Ansible Playbook模型 -type Playbook struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` - Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` - Description string `gorm:"size:500" json:"description"` - Content string `gorm:"type:text;not null" json:"content"` - Category string `gorm:"size:64" json:"category"` - Tags string `gorm:"type:text" json:"tags"` // JSON 数组字符串,如 ["deploy","docker"] - Variables string `gorm:"type:text" json:"variables"` - Inventory string `gorm:"type:text" json:"inventory"` - Timeout int `gorm:"default:600" json:"timeout"` - RunCount int `gorm:"default:0" json:"run_count"` - CreatedBy uint `json:"created_by"` - UpdatedBy uint `json:"updated_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// TableName 表名 -func (Playbook) TableName() string { - return "ops_playbooks" -} - -// CreatePlaybookRequest 创建Playbook请求 -type CreatePlaybookRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Content string `json:"content" binding:"required"` - Category string `json:"category"` - Tags string `json:"tags"` // JSON 数组字符串 - Variables string `json:"variables"` - Inventory string `json:"inventory"` - Timeout int `json:"timeout"` -} - // CreateCronJobRequest 创建定时任务请求 type CreateCronJobRequest struct { Name string `json:"name" binding:"required"` diff --git a/backend/internal/models/cicd.go b/backend/internal/models/cicd.go index ef954d1..204db2e 100644 --- a/backend/internal/models/cicd.go +++ b/backend/internal/models/cicd.go @@ -32,7 +32,7 @@ func (Pipeline) TableName() string { return "ops_pipelines" } -// PipelineRun 流水线运行记录 +// PipelineRun 流水线运行记录(CI 执行) type PipelineRun struct { ID uint `gorm:"primarykey" json:"id"` PipelineID uint `gorm:"not null" json:"pipeline_id"` @@ -42,6 +42,8 @@ type PipelineRun struct { CommitID string `gorm:"size:64" json:"commit_id"` CommitMsg string `gorm:"size:500" json:"commit_msg"` Author string `gorm:"size:64" json:"author"` + ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签 + ArtifactURL string `gorm:"size:500" json:"artifact_url"` // 构建产物地址 Duration int `json:"duration"` // 秒 Stages string `gorm:"type:text" json:"stages"` // JSON Logs string `gorm:"type:longtext" json:"logs"` @@ -57,16 +59,19 @@ type PipelineRun struct { // TableName 表名 func (PipelineRun) TableName() string { - return "pipeline_runs" + return "ops_pipeline_runs" } -// AppRelease 应用发布模型 +// AppRelease 应用发布模型(CD:从 CI 产物部署到目标环境) type AppRelease struct { ID uint `gorm:"primarykey" json:"id"` + PipelineID *uint `json:"pipeline_id"` // 来源流水线 + PipelineRunID *uint `json:"pipeline_run_id"` // 来源 CI 执行记录 AppName string `gorm:"size:128;not null" json:"app_name"` Version string `gorm:"size:64;not null" json:"version"` Environment string `gorm:"size:32;not null" json:"environment"` // dev, test, staging, prod - Status string `gorm:"size:32;default:pending" json:"status"` // pending, building, deploying, success, failed, rolled_back + DeployType string `gorm:"size:32;default:server" json:"deploy_type"` // server, kubernetes + Status string `gorm:"size:32;default:pending" json:"status"` // pending, approved, rejected, deploying, success, failed, rolled_back CommitID string `gorm:"size:64" json:"commit_id"` CommitMsg string `gorm:"size:500" json:"commit_msg"` Author string `gorm:"size:64" json:"author"` @@ -75,7 +80,10 @@ type AppRelease struct { DeployedAt *time.Time `json:"deployed_at"` RollbackAt *time.Time `json:"rollback_at"` Duration int `json:"duration"` - Servers string `gorm:"type:text" json:"servers"` // JSON + ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签 + K8sClusterID *uint `json:"k8s_cluster_id"` // Kubernetes 部署目标集群 + K8sNamespace string `gorm:"size:128" json:"k8s_namespace"` // Kubernetes 部署目标命名空间 + Servers string `gorm:"type:text" json:"servers"` // JSON(传统服务器部署目标) Description string `gorm:"size:500" json:"description"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -107,13 +115,19 @@ type TriggerPipelineRequest struct { // CreateAppReleaseRequest 创建应用发布请求 type CreateAppReleaseRequest struct { - AppName string `json:"app_name" binding:"required"` - Version string `json:"version" binding:"required"` - Environment string `json:"environment" binding:"required,oneof=dev test staging prod"` - CommitID string `json:"commit_id"` - CommitMsg string `json:"commit_msg"` - Servers []uint `json:"servers"` - Description string `json:"description"` + PipelineID *uint `json:"pipeline_id"` + PipelineRunID *uint `json:"pipeline_run_id"` + AppName string `json:"app_name" binding:"required"` + Version string `json:"version" binding:"required"` + Environment string `json:"environment" binding:"required,oneof=dev test staging prod"` + DeployType string `json:"deploy_type" binding:"omitempty,oneof=server kubernetes"` + CommitID string `json:"commit_id"` + CommitMsg string `json:"commit_msg"` + ImageTag string `json:"image_tag"` + K8sClusterID *uint `json:"k8s_cluster_id"` + K8sNamespace string `json:"k8s_namespace"` + Servers []uint `json:"servers"` + Description string `json:"description"` } // PipelineStats 流水线统计 diff --git a/backend/internal/models/kubernetes.go b/backend/internal/models/kubernetes.go index f57f95d..1d6db60 100644 --- a/backend/internal/models/kubernetes.go +++ b/backend/internal/models/kubernetes.go @@ -32,7 +32,7 @@ type KubernetesCluster struct { // TableName 表名 func (KubernetesCluster) TableName() string { - return "container_clusters" + return "kubernetes_clusters" } // KubernetesNode K8s节点模型 @@ -198,5 +198,5 @@ type TopologyLayout struct { // TableName 指定表名 func (TopologyLayout) TableName() string { - return "container_topology_layouts" + return "kubernetes_topology_layouts" } diff --git a/backend/internal/models/llm.go b/backend/internal/models/llm.go index 8d998df..0f38662 100644 --- a/backend/internal/models/llm.go +++ b/backend/internal/models/llm.go @@ -19,7 +19,7 @@ type LLMConfig struct { } // TableName 表名 // Logan.Li -func (LLMConfig) TableName() string { return "sys_llm_configs" } +func (LLMConfig) TableName() string { return "ai_llm_configs" } // SaveLLMConfigRequest 保存 LLM 配置请求 // Logan.Li type SaveLLMConfigRequest struct { @@ -41,7 +41,8 @@ type ChatMessage struct { // ChatRequest AI 对话请求 // Logan.Li type ChatRequest struct { - Messages []ChatMessage `json:"messages" binding:"required,min=1,dive"` + Messages []ChatMessage `json:"messages" binding:"required,min=1,dive"` + SessionID string `json:"session_id"` // 前端生成,用于隔离多轮对话 } // ChatResponse AI 对话响应 // Logan.Li @@ -51,14 +52,15 @@ type ChatResponse struct { TokensUsed int `json:"tokens_used"` } -// ChatHistory 对话历史记录(按用户隔离) // Logan.Li +// ChatHistory 对话历史记录(按用户隔离,session_id 区分多轮对话) // Logan.Li // 仅持久化成功完成的对话(用户消息 + 助手回复成对写入),错误气泡为瞬时 UI 不入库 type ChatHistory struct { ID uint `gorm:"primarykey" json:"id"` - UserID uint `gorm:"index;not null" json:"user_id"` // 所属用户(JWT 中间件注入) - Role string `gorm:"size:16;not null" json:"role"` // user / assistant - Content string `gorm:"type:text;not null" json:"content"` // 消息内容 - Model string `gorm:"size:100" json:"model"` // 生成回复所用模型(仅 assistant 行有意义) + UserID uint `gorm:"index;not null" json:"user_id"` // 所属用户(JWT 中间件注入) + SessionID string `gorm:"size:64;index;not null;default:''" json:"session_id"` // 对话会话 ID(前端 UUID) + Role string `gorm:"size:16;not null" json:"role"` // user / assistant + Content string `gorm:"type:text;not null" json:"content"` // 消息内容 + Model string `gorm:"size:100" json:"model"` // 生成回复所用模型(仅 assistant 行有意义) CreatedAt time.Time `json:"created_at"` } diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index cedc38e..ba21ba7 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -82,7 +82,6 @@ func AutoMigrate(db *gorm.DB) error { &models.CronJob{}, &models.Script{}, &models.TaskExecution{}, - &models.Playbook{}, &models.ApprovalTemplate{}, &models.ApprovalProcess{}, &models.ApprovalStep{}, @@ -111,25 +110,12 @@ func AutoMigrate(db *gorm.DB) error { &models.MonitorMetric{}, // 监控指标定义 // Logan.Li &models.MetricValue{}, // 指标当前值 // Logan.Li &models.MetricHistory{}, // 指标历史时序 // Logan.Li - &models.MetricTag{}, // 指标标签 // Logan.Li - &models.MonitorDashboard{},// 监控大屏配置 // Logan.Li - &models.DashboardPanel{}, // 大屏面板 // Logan.Li &models.HostGroup{}, // 主机组 // Logan.Li &models.SNMPDevice{}, // SNMP设备 // Logan.Li &models.SNMPTemplate{}, // SNMP模板 // Logan.Li - &models.SNMPTemplateOID{}, // SNMP模板OID // Logan.Li - &models.SNMPMetric{}, // SNMP指标 // Logan.Li - &models.SNMPInterface{}, // SNMP接口 // Logan.Li &models.SiteTask{}, // 站点拨测任务 // Logan.Li &models.SiteResult{}, // 拨测结果 // Logan.Li &models.SiteAlert{}, // 拨测告警 // Logan.Li - &models.AlertRuleV2{}, // 告警规则V2 // Logan.Li - &models.AlertCondition{}, // 告警条件 // Logan.Li - &models.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li - &models.AlertUpgrade{}, // 告警升级 // Logan.Li - &models.AlertUpgradeRecord{},// 告警升级记录 // Logan.Li - &models.AlertSilence{}, // 告警静默 // Logan.Li - &models.AlertEventV2{}, // 告警事件V2 // Logan.Li &models.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li &models.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li &models.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li diff --git a/docker-compose.yml b/docker-compose.yml index c41a96f..1aff1d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: mysql: image: mysql:8.0 container_name: aiops-mysql + hostname: aiops-mysql restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-CHANGE_ME} @@ -31,6 +32,7 @@ services: redis: image: redis:7-alpine container_name: aiops-redis + hostname: aiops-redis restart: unless-stopped ports: - "6379:6379" @@ -54,6 +56,7 @@ services: VERSION: ${VERSION:-dev} BUILD_TIME: ${BUILD_TIME:-unknown} container_name: aiops-backend + hostname: aiops-backend restart: unless-stopped ports: - "8080:8080" @@ -79,6 +82,7 @@ services: context: ./frontend dockerfile: Dockerfile container_name: aiops-frontend + hostname: aiops-frontend restart: unless-stopped ports: - "80:80" diff --git a/frontend/src/components/aiops/AIOpsAssistant.tsx b/frontend/src/components/aiops/AIOpsAssistant.tsx index 1c3ded4..598fdec 100644 --- a/frontend/src/components/aiops/AIOpsAssistant.tsx +++ b/frontend/src/components/aiops/AIOpsAssistant.tsx @@ -157,6 +157,8 @@ export function AIOpsAssistant() { const [config, setConfig] = useState(defaultConfig); const [configReady, setConfigReady] = useState(false); // 已配置且启用 const [contextLoading, setContextLoading] = useState(null); // 快捷操作拉取平台数据中 + // 会话 ID:前端生成 UUID,用于隔离多轮对话 // Logan.Li + const [sessionId, setSessionId] = useState(() => crypto.randomUUID()); const messagesEndRef = useRef(null); // 滚动到底部 // Logan.Li @@ -174,10 +176,10 @@ export function AIOpsAssistant() { } }; - // 加载历史对话(按用户隔离,刷新后仍可见) // Logan.Li + // 加载历史对话(按 session 隔离) // Logan.Li const loadHistory = async () => { try { - const res = await aiApi.getHistory(); + const res = await aiApi.getHistory(sessionId); const items: ChatHistoryItem[] = (res as any)?.list || []; setMessages( items.map((m) => ({ @@ -275,7 +277,7 @@ export function AIOpsAssistant() { m.id === assistantId ? { ...m, content: m.content + delta } : m, ), ); - }, controller.signal); + }, controller.signal, sessionId); } catch (e) { // 用户主动中止:保留已生成的部分内容,不当作错误 // Logan.Li if (controller.signal.aborted) { @@ -340,13 +342,15 @@ export function AIOpsAssistant() { await sendMessage(fullPrompt); }; - // 新对话(清空本地 + 后端持久化历史) // Logan.Li + // 新对话(生成新 session_id,清空本地 + 后端持久化历史) // Logan.Li const handleNewChat = async () => { try { await aiApi.clearHistory(); } catch { // 清除失败不阻塞本地清空 } + const newSid = crypto.randomUUID(); + setSessionId(newSid); setMessages([]); setInput(''); toast.success('已开始新对话'); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6b06f58..6bf8a43 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -892,6 +892,7 @@ export interface ChatResult { export interface ChatHistoryItem { id: number; user_id: number; + session_id: string; role: 'user' | 'assistant'; content: string; model?: string; @@ -905,15 +906,16 @@ export const aiApi = { saveConfig: (data: LLMConfigData) => http.put('/aiops/llm-config', data), // 测试 LLM 连接(发送 ping) testConnection: () => http.post('/aiops/llm-config/test'), - // AI 对话(messages 含历史 + 当前用户消息) - chat: (messages: { role: 'user' | 'assistant'; content: string }[]) => - http.post('/aiops/chat', { messages }), + // AI 对话(messages 含历史 + 当前用户消息,session_id 区分多轮对话) + chat: (messages: { role: 'user' | 'assistant'; content: string }[], sessionId?: string) => + http.post('/aiops/chat', { messages, session_id: sessionId || '' }), // AI 流式对话(SSE)。逐块回调 onChunk,返回完整内容。错误抛出 Error。 // 使用裸 fetch 绕过 http.ts(后者会 await json() 一次性消费) // Logan.Li chatStream: async ( messages: { role: 'user' | 'assistant'; content: string }[], onChunk: (delta: string) => void, signal?: AbortSignal, + sessionId?: string, ): Promise => { const token = useUserStore.getState().token; const res = await fetch('/api/v1/aiops/chat/stream', { @@ -922,7 +924,7 @@ export const aiApi = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, - body: JSON.stringify({ messages }), + body: JSON.stringify({ messages, session_id: sessionId || '' }), signal, }); if (res.status === 401) { @@ -980,8 +982,11 @@ export const aiApi = { } return fullContent; }, - // 获取当前用户对话历史(按时间正序,最近 100 条) - getHistory: () => http.get<{ list: ChatHistoryItem[]; total: number }>('/aiops/chat-history'), + // 获取当前用户对话历史(按 session_id 过滤,默认最新 session) + getHistory: (sessionId?: string) => { + const params = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : ''; + return http.get<{ list: ChatHistoryItem[]; total: number }>(`/aiops/chat-history${params}`); + }, // 清空当前用户对话历史 clearHistory: () => http.delete('/aiops/chat-history'), };