From 1acb0c49afd34af0c19bc035b0bff9b3d1301806 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 1 Jul 2026 11:07:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Agent=20CLI=20=E4=B8=8E=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E5=AE=A1=E8=AE=A1=E5=9B=9E=E6=94=BE=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏 - 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复 - 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示 - 审计历史页只读(移除删除),移除回放弹框录制搜索 --- .gitignore | 1 + AGENTS.md | 87 ++-- README.md | 2 +- backend/controller/agent_cli.go | 208 ++++++-- backend/controller/agent_cli_api.go | 9 +- backend/controller/agent_install.go | 244 ++++++++- backend/controller/audit.go | 1 + backend/controller/dashboard.go | 15 + backend/controller/llm.go | 45 +- backend/controller/llm_chat.go | 34 +- backend/controller/llm_client.go | 26 + backend/controller/log_proxy.go | 2 +- backend/controller/session_command.go | 45 +- backend/controller/terminal_session.go | 62 ++- backend/controller/webshell.go | 98 +++- backend/model/llm.go | 26 +- backend/model/server.go | 6 + backend/model/session_command.go | 5 +- backend/model/session_command_item.go | 7 + backend/model/terminal_session.go | 16 +- backend/pkg/agentcli/agentcli.go | 27 +- backend/pkg/agentcli/core/agent.go | 105 +++- backend/pkg/agentcli/evolver/engine.go | 36 ++ backend/pkg/agentcli/evolver/memory.go | 15 + backend/pkg/agentcli/llm/anthropic.go | 26 + backend/pkg/agentcli/llm/openai.go | 61 ++- backend/pkg/agentcli/llm/types.go | 9 +- backend/pkg/agentcli/tools/registry.go | 11 + backend/pkg/agentcli/tools/ssh_execute.go | 42 +- backend/pkg/database/database.go | 1 + backend/pkg/remoteexec/executor.go | 71 +-- backend/pkg/remoteexec/persistent_client.go | 144 ++++++ backend/router/routes.go | 1 + frontend/package-lock.json | 27 +- frontend/src/App.tsx | 2 + frontend/src/api/aiops/assistant.ts | 13 +- frontend/src/api/audit/terminal.ts | 8 + frontend/src/api/server/server.ts | 6 +- frontend/src/components/agent/AgentWizard.tsx | 244 ++++----- .../src/components/aiops/AIOpsAssistant.tsx | 1 + frontend/src/components/aiops/AgentCLI.tsx | 465 +++++++++--------- .../src/components/aiops/AgentCLIHistory.tsx | 143 ++++++ .../src/components/aiops/AgentCLIMessages.tsx | 179 +++++++ frontend/src/components/aiops/LLMConfig.tsx | 163 +++++- .../src/components/dashboard/Dashboard.tsx | 5 + .../components/dashboard/DashboardCharts.tsx | 28 ++ .../components/deploy/WorkspaceTermDialog.tsx | 2 + .../kubernetes/ClusterOverviewPage.tsx | 102 ++-- .../src/components/kubernetes/EventTable.tsx | 2 +- .../src/components/kubernetes/EventsPage.tsx | 8 +- .../components/kubernetes/KubectlTerminal.tsx | 2 + .../kubernetes/KubernetesClusterDetail.tsx | 294 +++++------ .../kubernetes/KubernetesClusters.tsx | 122 ++--- .../components/kubernetes/KubernetesNodes.tsx | 72 +-- .../kubernetes/NamespaceManagementPage.tsx | 114 ++--- .../kubernetes/NamespaceOverviewPage.tsx | 26 +- .../kubernetes/NamespaceResourcePage.tsx | 252 +++++----- .../components/kubernetes/PodExecTerminal.tsx | 2 + .../kubernetes/WorkloadViewPage.tsx | 208 ++++---- .../monitor/dashboard/GrafanaDashboard.tsx | 113 ++++- .../monitor/log-center/UnifiedQueryPanel.tsx | 24 +- .../src/components/server/TerminalDialog.tsx | 2 + .../src/components/system/ReplayDialog.tsx | 388 +++++++++++---- .../src/components/system/SystemManager.tsx | 16 +- .../src/components/system/SystemUserTab.tsx | 64 +-- .../components/system/TerminalSessionsTab.tsx | 3 +- .../system/replay/ReplayCommandSidebar.tsx | 72 +++ .../components/system/replay/export-audit.ts | 88 ++++ .../components/system/replay/replay-engine.ts | 128 +++++ .../components/system/replay/replay-utils.ts | 116 +++++ frontend/src/hooks/api/useSessionCommands.ts | 20 +- frontend/src/hooks/api/useTerminalSessions.ts | 15 +- frontend/src/locales/en-US.json | 38 +- frontend/src/locales/zh-CN.json | 38 +- .../terminal-audit/ActiveSessionsPanel.tsx | 3 +- .../terminal-audit/HistorySessionsPanel.tsx | 228 +++++---- .../terminal-audit/SessionCommandsPanel.tsx | 195 ++++++-- .../pages/audit/terminal-audit/helpers.tsx | 19 + .../src/pages/audit/terminal-audit/types.ts | 6 +- frontend/vite.config.ts | 4 + run.sh | 13 +- 81 files changed, 3916 insertions(+), 1655 deletions(-) create mode 100644 backend/controller/llm_client.go create mode 100644 backend/model/session_command_item.go create mode 100644 backend/pkg/remoteexec/persistent_client.go create mode 100644 frontend/src/components/aiops/AgentCLIHistory.tsx create mode 100644 frontend/src/components/aiops/AgentCLIMessages.tsx create mode 100644 frontend/src/components/system/replay/ReplayCommandSidebar.tsx create mode 100644 frontend/src/components/system/replay/export-audit.ts create mode 100644 frontend/src/components/system/replay/replay-engine.ts create mode 100644 frontend/src/components/system/replay/replay-utils.ts diff --git a/.gitignore b/.gitignore index 3d48234..76f2fab 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,4 @@ backend/cmd/static/ backend/kubectl opsxsql/ test_monitoring_es.py +aiops-hot diff --git a/AGENTS.md b/AGENTS.md index 1c9008c..d06b93c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ **AIOps** 是一个企业级云原生智能运维平台,聚焦三大核心能力: 1. **资产管理** — 服务器 CRUD、批量导入、WebShell、云账号/域名/账单、Kubernetes 多集群。 -2. **监控告警** — Agent 采集 `/proc` 指标、告警引擎(阈值/离线检测)、告警中心、监控大屏、Prometheus/Zabbix/SNMP/站点拨测。 +2. **监控告警** — Agent 采集 `/proc` 指标、告警引擎(阈值/离线/日志告警)、告警中心、监控大屏、Prometheus/Zabbix/SNMP/站点拨测/中间件监控。 3. **自动化运维 / AIOps** — CI/CD 流水线、定时任务/脚本库、Go-native SSH 远程执行、资源申请审批、AI 智能助手、Agent CLI(ReAct + Evolver)。 项目采用前后端分离架构,但通过 `go:embed` 支持将前端构建产物嵌入后端二进制,实现单文件 / 单容器部署。 @@ -35,7 +35,30 @@ ## 2. 技术栈与关键配置 -### 2.1 后端 +### 2.1 关键配置文件 + +项目不存在 `pyproject.toml`、`Cargo.toml`、`pom.xml` 等配置。核心配置文件为: + +| 文件 | 说明 | +|------|------| +| `backend/go.mod` | 后端 Go 模块定义,模块名 `ezops`,Go 1.24.0 | +| `agent/go.mod` | Agent 独立模块,模块名 `ezops/agent`,Go 1.22(与后端共用 1.24 工具链) | +| `frontend/package.json` | 前端 npm 依赖与脚本 | +| `frontend/vite.config.ts` | Vite 配置:端口 5173,代理 `/api`、`/ws`、`/health`、`/version`、`/agent` 到 `localhost:8080` | +| `frontend/eslint.config.js` | ESLint flat config,禁止显式 `any` | +| `frontend/tailwind.config.js` | CSS 变量主题体系 | +| `config.yaml` | 本地开发默认配置(SQLite + redka,端口 8080) | +| `docker-compose.yml` | 3 容器:MySQL 8.0 + Redis 7-alpine + AIOps(:8080) | +| `docker-compose.k8s.yml` | 单应用容器 + 外部 DB | +| `Dockerfile.embed` | 4-stage Embed 镜像(前端→后端→Agent→Runtime),Compose 与 K8s 共用 | +| `.env.example` | Docker Compose 环境变量示例 | +| `deploy/kubernetes/` | K8s 清单:`devops` 命名空间、NodePort 30080 | +| `run.sh` | 统一入口:dev / build / docker / kubernetes / release / binary | +| `Makefile` | 快捷命令(dev / build / docker-up / lint 等),部分委托给 `run.sh`,部分已陈旧 | + +> 注意:项目根目录**没有**独立的 `Dockerfile`,只有 `Dockerfile.embed`。`docker-compose.yml` 直接引用 `Dockerfile.embed`。 + +### 2.2 后端 - **语言 / 版本**:Go 1.24+ - **Web 框架**:Gin v1.9.1 @@ -52,13 +75,12 @@ 关键文件: -- `backend/go.mod` — Go 模块定义,模块名 `ezops` - `backend/main.go` — 应用入口、CLI 参数解析、配置加载、优雅关闭 - `backend/embed.go` — `go:embed static`,SPA fallback 逻辑 - `backend/config/config.go` — `Config` 结构体与默认配置 -- `backend/router/routes.go` — 所有路由注册(约 1018 行,核心入口) +- `backend/router/routes.go` — 所有路由注册(实际约 1054 行,核心入口) -### 2.2 前端 +### 2.3 前端 - **框架**:React 19.2+ - **语言**:TypeScript 5.9+ @@ -75,34 +97,13 @@ - **国际化**:i18next + react-i18next(中/英双语) - **表单校验**:Zod + React Hook Form -关键文件: - -- `frontend/package.json` -- `frontend/vite.config.ts` — 端口 5173,代理 `/api`、`/ws`、`/health`、`/version` 到 `localhost:8080` -- `frontend/tailwind.config.js` — CSS 变量主题体系 -- `frontend/eslint.config.js` — ESLint flat config,禁止 `any` - -### 2.3 Agent +### 2.4 Agent - **模块名**:`ezops/agent` - **Go 版本**:go 1.22(文件声明),与后端共用 1.24 工具链 - **依赖**:仅 `golang.org/x/sys` - **架构**:`collector/`(采集器)、`task/`(远程任务)、`reporter/`(HTTP 上报)、`client/`(HTTP 客户端)、`model/`(数据结构) -### 2.4 部署与构建配置 - -| 文件 | 用途 | -|------|------| -| `run.sh` | 统一入口:dev / build / docker / kubernetes / release / binary | -| `Makefile` | 快捷命令(dev / build / docker-up / lint 等),实际多委托给 `run.sh` | -| `Dockerfile` | 3-stage 构建镜像(Frontend→Backend+Agent→Alpine Runtime),Docker Compose 使用 | -| `Dockerfile.embed` | 4-stage Embed 镜像(前端→后端→Agent→Runtime),K8s 单容器部署使用 | -| `docker-compose.yml` | 3 容器:MySQL 8.0 + Redis 7-alpine + AIOps(:8080) | -| `docker-compose.k8s.yml` | 单应用容器 + 外部 DB | -| `config.yaml` | 本地开发默认配置(SQLite + redka,端口 8080) | -| `.env.example` | Docker Compose 环境变量示例 | -| `deploy/kubernetes/` | K8s 清单:`devops` 命名空间、NodePort 30080 | - --- ## 3. 项目结构与模块划分 @@ -117,11 +118,13 @@ aiops/ ├── deploy/ # 部署脚本与 K8s 清单 ├── docs/ # 中文开发文档 ├── tests/ # 集成测试脚本 +├── scripts/ # 构建辅助脚本(kubectl 安装、alpine 镜像源等) ├── run.sh # 统一入口 ├── Dockerfile.embed # 推荐生产镜像 ├── docker-compose.yml +├── docker-compose.k8s.yml ├── config.yaml -└── README.md / CLAUDE.md +└── README.md / CLAUDE.md / AGENTS.md ``` ### 3.2 后端目录职责 @@ -129,11 +132,11 @@ aiops/ | 目录 | 职责 | |------|------| | `backend/main.go` / `embed.go` | 应用入口 + 前端嵌入 | -| `backend/controller/` | HTTP 处理器(约 60+ 个文件,按领域拆分) | +| `backend/controller/` | HTTP 处理器(60+ 个文件,按领域拆分;总代码行约 27K) | | `backend/model/` | GORM 数据模型(约 30 个文件,86+ 张表) | -| `backend/dto/` | 请求/响应 DTO | -| `backend/service/` | 业务逻辑层(V2 Services 依赖注入) | -| `backend/repository/` | 数据访问层 | +| `backend/dto/` | 请求/响应 DTO(当前 `dto.go` 一个文件) | +| `backend/service/` | 业务逻辑层(V2 Services,当前 `service.go`) | +| `backend/repository/` | 数据访问层(当前 `repo.go`) | | `backend/router/` | 路由注册 | | `backend/middleware/` | JWT、Admin、PipelineAccess、CORS、日志 | | `backend/config/` | Viper 配置加载 | @@ -145,13 +148,13 @@ aiops/ - 用户/角色/权限:`user.go`, `user_admin.go`, `item_role.go` - LDAP:`ldap.go`, `ldap_users.go`, `ldap_ou.go` -- 资产:`server.go`(V2 `server_handler.go`), `cloud_accounts.go`, `cloud_sync.go`, `cloud_domain.go`, `cloud_platform.go`, `cloud_region.go`, `billing.go`, `billing_alert.go`, `resource_model.go` +- 资产:`server.go`, `cloud_accounts.go`, `cloud_sync.go`, `cloud_domain.go`, `cloud_platform.go`, `cloud_region.go`, `billing.go`, `billing_alert.go`, `resource_model.go` - K8s:`kubernetes.go`, `k8s_cluster.go`, `k8s_node.go`, `k8s_pod.go`, `k8s_workload.go`, `k8s_yaml.go`, `k8s_resource.go`, `k8s_namespace.go`, `k8s_events.go`, `k8s_compare.go`, `k8s_export.go`, `k8s_hpa_cronjob.go`, `k8s_pod_files.go`, `kubectl_shell.go` -- 监控告警:`alert.go`, `alert_rules.go`, `alert_engine.go`, `alert_channels.go`, `alert_contacts.go`, `alert_silence.go`, `metrics.go`, `health_probe.go`, `site.go`, `snmp.go`, `custom_dashboard.go` -- 数据源代理:`prometheus_proxy.go`, `prom_ds.go`, `zabbix_proxy.go`, `zabbix_ds.go`, `log_proxy.go`, `log_datasource.go` +- 监控告警:`alert.go`, `alert_rules.go`, `alert_engine.go`, `alert_channels.go`, `alert_contacts.go`, `alert_silence.go`, `metrics.go`, `health_probe.go`, `site.go`, `snmp.go`, `custom_dashboard.go`, `middleware_monitor.go` +- 数据源代理:`prometheus_proxy.go`, `prom_ds.go`, `zabbix_proxy.go`, `zabbix_ds.go`, `log_proxy.go`, `log_datasource.go`, `trace_datasource.go` - 日志中心:`UnifiedQueryPanel.tsx`, `EsQueryPanel.tsx`, `LokiQueryPanel.tsx`, `LogDatasourcePage.tsx`, `LogDatasourceDialog.tsx`, `TimeRangePicker.tsx` - 应用发布/部署:`pipeline.go`, `pipeline_executor.go`, `deploy.go`, `deploy_groups.go`, `deploy_environments.go`, `tool_config.go` -- 自动化运维:`automation.go`, `automation_cron.go`, `automation_script.go`, `remote_exec.go` +- 自动化运维:`automation.go`, `automation_cron.go`, `automation_script.go`, `remote_exec.go`, `ansible.go` - 审批:`approval.go`, `approval_process.go`, `approval_workflow.go` - 审计:`audit.go`, `audit_hub.go`, `terminal_session.go`, `session_command.go`, `login_log.go` - AI:`llm.go`, `llm_chat.go`, `llm_history.go`, `agent_cli.go`, `agent_cli_api.go` @@ -169,7 +172,7 @@ aiops/ | `frontend/src/store/` | Zustand 状态管理 | | `frontend/src/lib/` | 工具函数 | | `frontend/src/types/` | TypeScript 类型 | -| `frontend/src/i18n/` / `locales/` | 国际化(zh-CN / en-US 双语,120+ 翻译键) | +| `frontend/src/i18n/` / `locales/` | 国际化(zh-CN / en-US 双语) | | `frontend/src/pages/` | 少量页面级组件 | | `frontend/src/assets/` | 静态资源 | @@ -187,11 +190,19 @@ aiops/ | `agent/model/` | 上报数据结构 | | `agent/deploy/` | `install.sh` + systemd 服务文件 | +### 3.5 代码规模(实测) + +| 层级 | 文件数 | 说明 | +|------|--------|------| +| 前端 `frontend/src` | 约 426 个 `.ts` / `.tsx` | 含组件、API、Hooks、Store、类型等 | +| 后端 `backend` | 约 172 个 `.go` | 含 controller、model、pkg、router 等 | +| Agent `agent` | 21 个 `.go` | 独立模块,仅依赖 `golang.org/x/sys` | + --- ## 4. 构建与运行命令 -统一入口是 `./run.sh`,优先使用它。`Makefile` 仅作为快捷别名。 +统一入口是 `./run.sh`,优先使用它。`Makefile` 仅作为快捷别名,且部分命令已落后于 `run.sh`(例如 `make dev` 调用 `./run.sh start` 实际不存在)。 ### 4.1 本地开发 diff --git a/README.md b/README.md index 32412dc..c4dde97 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ curl http://localhost:8080/version # 查看运行版本 ```bash ./run.sh build agent # 交叉编译 Agent (linux-amd64 + linux-arm64) -./agent/aiops-agent-linux-amd64 version +./agent/bin/aiops-agent-linux-amd64 version ``` ### 默认账号 diff --git a/backend/controller/agent_cli.go b/backend/controller/agent_cli.go index c2e762b..afa6aa5 100644 --- a/backend/controller/agent_cli.go +++ b/backend/controller/agent_cli.go @@ -54,9 +54,10 @@ type wsEvent struct { // wsControl 前端→后端控制消息 // Logan.Li type wsControl struct { - Type string `json:"type"` // start/confirm/deny/abort - Query string `json:"query,omitempty"` // start 时任务描述 - ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时 + Type string `json:"type"` // start/continue/confirm/deny/abort + Query string `json:"query,omitempty"` // start 时任务描述 + SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive + ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时 } // writeWSCloseError 在 agentConn 建立前,直接通过 conn 推送一条 error 事件再关闭连接 // Logan.Li @@ -73,15 +74,21 @@ func writeWSCloseError(conn *websocket.Conn, msg string) { // agentConn 单个 WS 连接的运行态 // Logan.Li type agentConn struct { - conn *websocket.Conn - db *gorm.DB - userID uint - server model.Server - session *model.AgentCLISession - cancel context.CancelFunc - writeMu sync.Mutex - confirms map[string]chan bool - cfMu sync.Mutex + conn *websocket.Conn + db *gorm.DB + userID uint + server model.Server + session *model.AgentCLISession + taskCtx context.Context + cancel context.CancelFunc + totalTokens int + tokenMu sync.Mutex + writeMu sync.Mutex + confirms map[string]chan bool + cfMu sync.Mutex + convHistory []llm.Message // 多轮追问对话历史(不含 system) // Logan.Li + running bool + runningMu sync.Mutex } // Run WebSocket 入口:GET /ws/agent-cli?id=&token= // Logan.Li @@ -121,6 +128,7 @@ func (h *AgentCLIHandler) Run(c *gin.Context) { ac := &agentConn{ conn: conn, db: h.db, userID: middleware.GetUserID(c), server: server, + taskCtx: context.Background(), confirms: make(map[string]chan bool), } // 推送主机上下文 @@ -144,7 +152,13 @@ func (h *AgentCLIHandler) Run(c *gin.Context) { ac.write(wsEvent{Type: "error", Message: "任务描述不能为空"}) continue } - ac.startTask(sshCfg, llmCfg, ctrl.Query) + ac.startTask(sshCfg, ctrl.Query, ctrl.SafetyMode) + case "continue": + if ctrl.Query == "" { + ac.write(wsEvent{Type: "error", Message: "追问内容不能为空"}) + continue + } + ac.continueTask(sshCfg, ctrl.Query, ctrl.SafetyMode) case "confirm": ac.resolveConfirm(ctrl.ConfirmID, true) case "deny": @@ -155,11 +169,28 @@ func (h *AgentCLIHandler) Run(c *gin.Context) { } } -// startTask 启动一次任务(新会话 + 新引擎) // Logan.Li -func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConfig, query string) { - ac.abort() // 取消上一次任务 +// parseSafetyMode 解析前端传入的安全模式 // Logan.Li +func parseSafetyMode(mode string) safety.Mode { + switch mode { + case "strict": + return safety.ModeStrict + case "permissive": + return safety.ModePermissive + default: + return safety.ModeBalanced + } +} + +// startTask 启动新任务(新会话,清空对话历史) // Logan.Li +func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) { + ac.abort() + + var llmCfg model.LLMConfig + if err := ac.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled { + ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"}) + return + } - // 创建 DB 会话 title := query if r := []rune(title); len(r) > 60 { title = string(r[:60]) + "..." @@ -175,38 +206,96 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConf return } ac.session = sess + ac.convHistory = nil + ac.setTotalTokens(0) + ac.runTask(sshCfg, sess, query, safetyMode, llmCfg, false) +} - // 存用户消息 - ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0) +// continueTask 在同一会话内追问(保留上下文) // Logan.Li +func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) { + if ac.isRunning() { + ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"}) + return + } + if ac.session == nil { + ac.write(wsEvent{Type: "error", Message: "请先开始新任务"}) + return + } + if ac.session.Status == "running" { + ac.write(wsEvent{Type: "error", Message: "当前会话仍在执行中"}) + return + } + + var llmCfg model.LLMConfig + if err := ac.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled { + ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"}) + return + } + ac.runTask(sshCfg, ac.session, query, safetyMode, llmCfg, true) +} + +// runTask 执行一轮 ReAct(新建或追问共用) // Logan.Li +func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISession, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) { + if ac.isRunning() { + ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"}) + return + } + + prior := ac.convHistory + priorTokens := ac.getTotalTokens() + roundOffset := 0 + if !isContinue { + prior = nil + priorTokens = 0 + } else { + roundOffset = ac.lastRound() + } + + coreCfg := core.DefaultConfig() + coreCfg.RoundOffset = roundOffset - // 构建引擎 engine, err := agentcli.NewEngine(agentcli.EngineConfig{ - LLM: llm.Config{ - Protocol: resolveProtocol(llmCfg.Provider), - Endpoint: resolveEndpoint(llmCfg.Provider, llmCfg.Endpoint), - APIKey: llmCfg.APIKey, Model: llmCfg.Model, - Temperature: llmCfg.Temperature, MaxTokens: llmCfg.MaxTokens, - }, + LLM: buildLLMConfigFromModel(llmCfg), SSH: sshCfg, - SafetyMode: safety.ModeBalanced, + SafetyMode: parseSafetyMode(safetyMode), + MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID), + Core: coreCfg, }) if err != nil { - ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), 0, time.Duration(0)) + ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), ac.lastRound(), 0) return } engine.SetCallback(ac.onEvent) engine.SetConfirmFn(ac.confirmFn) ctx, cancel := context.WithCancel(context.Background()) + ac.taskCtx = ctx ac.cancel = cancel - serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s", ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS) + ac.setRunning(true) + ac.db.Model(sess).Update("status", "running") - ac.write(wsEvent{Type: "session", SessionID: sess.ID, Message: "任务已开始"}) + ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0) + + serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s", + ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS) + + msg := "任务已开始" + if isContinue { + msg = "继续追问" + } + ac.write(wsEvent{Type: "session", SessionID: sess.ID, Message: msg}) go func() { - defer engine.Close() + defer func() { + ac.setRunning(false) + engine.Close() + }() start := time.Now() - _, err := engine.Run(ctx, query, serverInfo) + result, err := engine.Run(ctx, query, serverInfo, prior, priorTokens) + if result != nil && len(result.History) > 0 { + ac.convHistory = result.History + ac.setTotalTokens(result.TotalTokens) + } dur := time.Since(start) if ctx.Err() != nil { ac.finish(sess, "aborted", "任务已中断", ac.lastRound(), dur) @@ -218,8 +307,37 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConf }() } +func (ac *agentConn) isRunning() bool { + ac.runningMu.Lock() + defer ac.runningMu.Unlock() + return ac.running +} + +func (ac *agentConn) setRunning(v bool) { + ac.runningMu.Lock() + ac.running = v + ac.runningMu.Unlock() +} + +// setTotalTokens 更新当前任务 token 计数 // Logan.Li +func (ac *agentConn) setTotalTokens(n int) { + ac.tokenMu.Lock() + ac.totalTokens = n + ac.tokenMu.Unlock() +} + +func (ac *agentConn) getTotalTokens() int { + ac.tokenMu.Lock() + defer ac.tokenMu.Unlock() + return ac.totalTokens +} + // onEvent 引擎事件回调:推 WS + 持久化 // Logan.Li func (ac *agentConn) onEvent(e core.Event) { + if e.Type == core.EventProgress && e.Tokens > 0 { + ac.setTotalTokens(e.Tokens) + } + ev := wsEvent{ Type: string(e.Type), Round: e.Round, Thought: e.Thought, ToolName: e.ToolName, ToolArgs: e.ToolArgs, Risk: e.Risk, @@ -231,7 +349,7 @@ func (ac *agentConn) onEvent(e core.Event) { } ac.write(ev) - // 持久化(progress 太频繁不入库) + // 持久化(progress / thought_delta 太频繁不入库) // Logan.Li if ac.session == nil { return } @@ -245,6 +363,7 @@ func (ac *agentConn) onEvent(e core.Event) { case core.EventToolResult: ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0) case core.EventDone: + ac.setTotalTokens(e.Tokens) ac.persistMsg(sid, e.Round, "done", "assistant", e.Answer, "", "", "", "", true, "", e.Tokens) case core.EventError: ac.persistMsg(sid, e.Round, "error", "assistant", e.Message, "", "", "", "", false, "", 0) @@ -265,6 +384,9 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris select { case approved := <-ch: return approved, nil + case <-ac.taskCtx.Done(): + ac.delConfirm(confirmID) + return false, ac.taskCtx.Err() case <-time.After(5 * time.Minute): ac.delConfirm(confirmID) return false, fmt.Errorf("确认超时") @@ -290,12 +412,21 @@ func (ac *agentConn) delConfirm(id string) { ac.cfMu.Unlock() } -// abort 中断当前任务 // Logan.Li +// abort 中断当前任务并唤醒所有等待中的确认 // Logan.Li func (ac *agentConn) abort() { if ac.cancel != nil { ac.cancel() ac.cancel = nil } + ac.cfMu.Lock() + for id, ch := range ac.confirms { + select { + case ch <- false: + default: + } + delete(ac.confirms, id) + } + ac.cfMu.Unlock() } // lastRound 估算已完成轮次(从 DB 取该会话最大 round) // Logan.Li @@ -309,13 +440,16 @@ func (ac *agentConn) lastRound() int { return maxRound } -// finish 结束会话并更新 DB // Logan.Li +// finish 结束本轮执行并更新会话 DB // Logan.Li func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) { + ac.setRunning(false) + tokens := ac.getTotalTokens() if status == "done" || status == "aborted" || status == "error" { - ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds}) + ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds, Tokens: tokens}) } updates := map[string]interface{}{ "status": status, "rounds": rounds, "duration": dur.Milliseconds(), + "total_tokens": tokens, } if errMsg != "" { updates["error"] = errMsg @@ -342,4 +476,4 @@ func (ac *agentConn) persistMsg(sid uint, round int, typ, role, content, toolNam // 持久化失败不阻断主流程 _ = err } -} +} \ No newline at end of file diff --git a/backend/controller/agent_cli_api.go b/backend/controller/agent_cli_api.go index 50dfde4..e05f655 100644 --- a/backend/controller/agent_cli_api.go +++ b/backend/controller/agent_cli_api.go @@ -43,9 +43,10 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) { // GetSession 会话详情(含完整推理消息,供回放) // Logan.Li // GET /api/v1/aiops/agent-cli/sessions/:id func (h *AgentCLIHandler) GetSession(c *gin.Context) { + uid := middleware.GetUserID(c) id := c.Param("id") var sess model.AgentCLISession - if err := h.db.First(&sess, id).Error; err != nil { + if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"}) return } @@ -57,7 +58,13 @@ func (h *AgentCLIHandler) GetSession(c *gin.Context) { // DeleteSession 删除会话及其消息 // Logan.Li // DELETE /api/v1/aiops/agent-cli/sessions/:id func (h *AgentCLIHandler) DeleteSession(c *gin.Context) { + uid := middleware.GetUserID(c) id := c.Param("id") + var sess model.AgentCLISession + if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil { + c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"}) + return + } if err := h.db.Where("session_id = ?", id).Delete(&model.AgentCLIMessage{}).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除消息失败: " + err.Error()}) return diff --git a/backend/controller/agent_install.go b/backend/controller/agent_install.go index d040afa..2595485 100644 --- a/backend/controller/agent_install.go +++ b/backend/controller/agent_install.go @@ -1,6 +1,7 @@ package controller import ( + "context" "fmt" "log" "net" @@ -25,6 +26,56 @@ func NewAgentInstallHandler(db *gorm.DB) *AgentInstallHandler { return &AgentInstallHandler{db: db} } +// auditCtx 快照请求上下文(用户身份 + 来源 IP),用于在异步 goroutine 中安全记录审计 // Logan.Li +// gin.Context 在请求返回后会被 gin 池复用,goroutine 内禁止直接访问 c,必须提前快照 +type auditCtx struct { + userID uint + username string + ip string + userAgent string +} + +// newAuditCtx 从 gin.Context 提取审计所需上下文快照 // Logan.Li +func newAuditCtx(c *gin.Context) auditCtx { + uid, _ := c.Get("userID") + uname, _ := c.Get("username") + id, _ := uid.(uint) + name, _ := uname.(string) + return auditCtx{ + userID: id, + username: name, + ip: c.ClientIP(), + userAgent: c.Request.UserAgent(), + } +} + +// truncateAuditText 截断过长输出,避免审计 Error 字段膨胀 // Logan.Li +func truncateAuditText(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) > n { + return s[:n] + "..." + } + 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) { + uid := ac.userID + h.db.Create(&model.AuditLog{ + UserID: &uid, + Username: ac.username, + IP: ac.ip, + UserAgent: ac.userAgent, + Module: "agent", + Action: action, + Resource: resource, + ResourceID: resourceID, + Status: status, + Error: errMsg, + }) +} + // sshDial 复用 webshell.go 的 SSH 连接逻辑(TOFU HostKey 验证) func sshDial(server model.Server) (*ssh.Client, error) { config := &ssh.ClientConfig{ @@ -54,16 +105,77 @@ func sshDial(server model.Server) (*ssh.Client, error) { return ssh.Dial("tcp", addr, config) } -// runSSHCommand 在远程主机执行命令并返回输出 +// runSSHCommand 在远程主机执行命令并返回输出(默认 60s 超时,防止 sudo 密码等待导致挂死)// Logan.Li func runSSHCommand(client *ssh.Client, cmd string) (string, error) { + return runSSHCommandWithTimeout(client, cmd, 60*time.Second) +} + +// runSSHCommandWithTimeout 支持自定义超时 +func runSSHCommandWithTimeout(client *ssh.Client, cmd string, timeout time.Duration) (string, error) { session, err := client.NewSession() if err != nil { return "", err } defer session.Close() - output, err := session.CombinedOutput(cmd) - return string(output), err + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + type result struct { + output []byte + err error + } + done := make(chan result, 1) + go func() { + out, err := session.CombinedOutput(cmd) + done <- result{output: out, err: err} + }() + + select { + case <-ctx.Done(): + session.Signal(ssh.SIGTERM) + return "", fmt.Errorf("命令执行超时(%s): %s", timeout, cmd) + case r := <-done: + return string(r.output), r.err + } +} + +// installServerURL 根据请求构造后端可访问地址,兼容 Nginx 等反向代理 // Logan.Li +func installServerURL(c *gin.Context) string { + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { + scheme = proto + } + // 优先使用 X-Forwarded-Host(Nginx 暴露给客户端的真实域名),其次用 Host // Logan.Li + host := c.Request.Host + if fwd := c.GetHeader("X-Forwarded-Host"); fwd != "" { + host = fwd + } + // 去掉 scheme 的默认端口,避免生成 http://example.com:80 这类无效地址 // Logan.Li + if scheme == "http" && strings.HasSuffix(host, ":80") { + host = strings.TrimSuffix(host, ":80") + } + if scheme == "https" && strings.HasSuffix(host, ":443") { + host = strings.TrimSuffix(host, ":443") + } + return fmt.Sprintf("%s://%s", scheme, host) +} + +// detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li +func detectAgentVersion(client *ssh.Client) string { + verOut, _ := runSSHCommand(client, "/usr/local/bin/aiops-agent version 2>/dev/null || /usr/local/bin/aiops-agent -v 2>/dev/null || echo ''") + verOut = strings.TrimSpace(verOut) + // 输出形如: AIOps Agent v2.1.0 (built ...) go=go1.24... + if strings.HasPrefix(verOut, "AIOps Agent ") { + fields := strings.Fields(verOut) + if len(fields) >= 3 { + return fields[2] + } + } + return "" } // InstallAgent 远程安装 Agent 到指定服务器 @@ -98,6 +210,11 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { // 允许安装,不强制 running 状态 } + // 审计上下文 + 资源描述(同步流程,提前快照)// Logan.Li + ac := newAuditCtx(c) + resource := server.Hostname + " (" + server.IP + ")" + resourceID := strconv.Itoa(int(server.ID)) + // 如果请求中提供了凭据,使用请求凭据覆盖服务器存储的凭据进行 SSH effectiveServer := server if req.Username != "" { @@ -118,6 +235,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)) c.JSON(http.StatusOK, model.Response{ Code: 500, Message: "SSH 连接失败: " + formatSSHError(err), @@ -153,21 +271,35 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { } // 3. 构造安装命令(使用 Agent API 服务地址) - // 从请求头获取后端可访问地址 - serverURL := fmt.Sprintf("http://%s", c.Request.Host) - token := c.Query("token") + serverURL := installServerURL(c) + token := req.Token + if token == "" { + token = c.Query("token") + } + // 4. 构造安装命令:root 用户直接 bash,非 root 用 sudo -n(无密码才执行,避免挂死)// Logan.Li installCmd := fmt.Sprintf( - `curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`, + `if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`, + serverURL, serverURL, token, serverURL, serverURL, token, ) - // 4. 执行安装 + // 5. 执行安装 installOut, err := runSSHCommand(client, installCmd) if err != nil { + msg := "安装命令执行失败" + lowerOut := strings.ToLower(installOut) + if strings.Contains(lowerOut, "sudo: a password is required") || strings.Contains(lowerOut, "需要密码") { + msg = "目标主机 SSH 用户没有免密 sudo 权限,请使用 root 账号或配置 passwordless sudo" + } else if strings.Contains(lowerOut, "curl") && (strings.Contains(lowerOut, "not found") || strings.Contains(lowerOut, "未找到")) { + msg = "目标主机未安装 curl" + } 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)) c.JSON(http.StatusOK, model.Response{ Code: 500, - Message: "安装命令执行失败(可能需要 sudo 权限或 curl 未安装)", + Message: msg, Data: installOut, }) return @@ -178,12 +310,22 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { installed := contains(verifyOut, "active") || contains(verifyOut, "running") if installed { - // 更新服务器 Agent 状态(版本号:安装成功后由 Agent 下一次上报自动更新) - h.db.Model(&server).Updates(map[string]interface{}{ + // 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本(等 Agent 首次上报真实数据)// Logan.Li + updates := map[string]interface{}{ "agent_status": "online", - "agent_version": "v0.1.0", "last_check_at": time.Now(), - }) + } + if agentVersion := detectAgentVersion(client); agentVersion != "" { + updates["agent_version"] = agentVersion + } + h.db.Model(&server).Updates(updates) + } + + // 审计:成功或"命令已执行但服务未运行"均记录,便于追溯 // Logan.Li + if installed { + h.recordAgentAudit(ac, "install", resource, resourceID, 1, "") + } else { + h.recordAgentAudit(ac, "install", resource, resourceID, 0, "安装命令已执行但服务未检测到运行: "+truncateAuditText(verifyOut, 300)) } c.JSON(http.StatusOK, model.Response{ @@ -214,7 +356,8 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s // 不强制 server.Status==running:只要 SSH 可达即可安装(DB 状态可能滞后于实际开机状态) // Logan.Li installCmd := fmt.Sprintf( - `curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`, + `if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`, + serverURL, serverURL, token, serverURL, serverURL, token, ) @@ -227,11 +370,15 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s installed := contains(verifyOut, "active") || contains(verifyOut, "running") if installed { - h.db.Model(&server).Updates(map[string]interface{}{ + // 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本 // Logan.Li + updates := map[string]interface{}{ "agent_status": "online", - "agent_version": "v0.1.0", "last_check_at": time.Now(), - }) + } + if agentVersion := detectAgentVersion(client); agentVersion != "" { + updates["agent_version"] = agentVersion + } + h.db.Model(&server).Updates(updates) } return installed, installOut, verifyOut, nil @@ -239,17 +386,25 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s // BatchInstallAgent 批量安装 Agent(异步执行,立即返回)// Logan.Li // POST /servers/install-agent +// 支持传入临时凭据:key 为服务器 ID,首次连接成功后自动保存到服务器记录 func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) { var req struct { - ServerIDs []uint `json:"server_ids"` + ServerIDs []uint `json:"server_ids"` + Credentials map[string]struct { + Username string `json:"username"` + Password string `json:"password"` + SSHKey string `json:"ssh_key"` + AuthType string `json:"auth_type"` + } `json:"credentials"` } if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要安装的服务器 ID 列表"}) return } - serverURL := fmt.Sprintf("http://%s", c.Request.Host) + serverURL := installServerURL(c) token := c.Query("token") + ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li // 异步执行:立即返回,后台 goroutine 逐台安装 go func() { @@ -260,15 +415,39 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) { log.Printf("[BatchInstall] 服务器 %d 不存在,跳过", sid) continue } - installed, _, _, err := h.installOne(server, serverURL, token) + + // 如果请求提供了该服务器的临时凭据,覆盖使用(不立即写入 DB,由 installOne 首次连接成功后保存) // Logan.Li + effectiveServer := server + if cred, ok := req.Credentials[fmt.Sprintf("%d", sid)]; ok { + if cred.Username != "" { + effectiveServer.Username = cred.Username + } + if cred.Password != "" { + effectiveServer.Password = cred.Password + } + if cred.SSHKey != "" { + effectiveServer.SSHKey = cred.SSHKey + effectiveServer.AuthType = "key" + } + if cred.AuthType != "" { + effectiveServer.AuthType = cred.AuthType + } + } + + installed, _, verifyOut, err := h.installOne(effectiveServer, serverURL, token) + res := server.Hostname + " (" + server.IP + ")" + rid := strconv.Itoa(int(server.ID)) 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)) } else { successCount++ if installed { log.Printf("[BatchInstall] %s Agent 安装成功", server.Hostname) + h.recordAgentAudit(ac, "install", res, rid, 1, "") } else { log.Printf("[BatchInstall] %s Agent 安装命令已执行但服务未检测到运行", server.Hostname) + h.recordAgentAudit(ac, "install", res, rid, 0, "服务未检测到运行: "+truncateAuditText(verifyOut, 300)) } } } @@ -310,17 +489,23 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) { return } + ac := newAuditCtx(c) // Logan.Li + resource := server.Hostname + " (" + server.IP + ")" + resourceID := strconv.Itoa(int(server.ID)) + // 无 SSH 凭据的主机(自动注册的)直接改本地状态;注意 SSH key 认证的主机 Password 为空但 SSHKey 非空,仍可远程卸载 // Logan.Li if server.Username == "" || (server.Password == "" && server.SSHKey == "") { h.db.Model(&server).Updates(map[string]interface{}{ "agent_status": "offline", "agent_version": "", }) + h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, "无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)) c.JSON(http.StatusOK, model.Response{Code: 500, Message: "SSH连接失败: " + formatSSHError(err)}) return } @@ -331,6 +516,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, "") c.JSON(http.StatusOK, model.Response{Code: 200, Message: fmt.Sprintf("已卸载 %s (%s) 的 Agent", server.Hostname, server.IP)}) } @@ -346,6 +532,8 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) { return } + ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li + // 异步执行:立即返回,后台逐台卸载 go func() { successCount := 0 @@ -355,21 +543,26 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) { log.Printf("[BatchUninstall] 服务器 %d 不存在,跳过", id) continue } + res := server.Hostname + " (" + server.IP + ")" + rid := strconv.Itoa(int(server.ID)) 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凭据,已标记离线") 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)) 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, "") successCount++ } log.Printf("[BatchUninstall] 批量卸载完成: %d/%d 成功", successCount, len(req.ServerIDs)) @@ -393,7 +586,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { return } - serverURL := fmt.Sprintf("http://%s", c.Request.Host) + serverURL := installServerURL(c) + ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li // 异步执行:立即返回,后台逐台升级 go func() { @@ -409,10 +603,14 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { continue } + res := server.Hostname + " (" + server.IP + ")" + rid := strconv.Itoa(int(server.ID)) + // 检测架构 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)) continue } @@ -424,7 +622,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { // 下载新二进制 + 获取版本号 + 重启服务 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; ver=$(/usr/local/bin/aiops-agent output 2>/dev/null | grep 'agent.version' | awk -F'[|[|]' '{print $2}' | tr -d ' '); echo "UPGRADE_OK ver=$ver"`, + `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 version 2>/dev/null | awk '/AIOps Agent/{print $3}'); echo "UPGRADE_OK ver=$ver"`, serverURL, binaryFile, ) @@ -433,6 +631,7 @@ 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)) continue } @@ -448,6 +647,7 @@ 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, "") successCount++ } log.Printf("[BatchUpgrade] 批量升级完成: %d/%d 成功", successCount, len(req.ServerIDs)) diff --git a/backend/controller/audit.go b/backend/controller/audit.go index 955b19c..1ddead0 100644 --- a/backend/controller/audit.go +++ b/backend/controller/audit.go @@ -145,6 +145,7 @@ func (h *AuditHandler) GetAuditModules(c *gin.Context) { {"value": "navigation", "label": "站内导航"}, {"value": "cloud_account", "label": "云账号管理"}, {"value": "domain", "label": "域名管理"}, + {"value": "agent", "label": "Agent管理"}, } c.JSON(http.StatusOK, model.Response{Code: 200, Data: modules}) // Logan.Li diff --git a/backend/controller/dashboard.go b/backend/controller/dashboard.go index 808b94f..dd1a85e 100644 --- a/backend/controller/dashboard.go +++ b/backend/controller/dashboard.go @@ -73,6 +73,21 @@ func (h *DashboardHandler) GetStats(c *gin.Context) { serverStats.AvgMemory = math.Round(avgMemory*100) / 100 serverStats.AvgDisk = math.Round(avgDisk*100) / 100 + // 总资源概览:按 asset_servers 的 cpu/memory/disk 字段汇总(memory 单位为 MB,需转 GB)// Logan.Li + var totalCPU, totalMemoryMB, totalDiskGB int64 + h.db.Model(&model.Server{}).Select("COALESCE(SUM(cpu),0)").Scan(&totalCPU) + h.db.Model(&model.Server{}).Select("COALESCE(SUM(memory),0)").Scan(&totalMemoryMB) + h.db.Model(&model.Server{}).Select("COALESCE(SUM(disk),0)").Scan(&totalDiskGB) + serverStats.TotalCPU = totalCPU + serverStats.TotalMemory = totalMemoryMB / 1024 // MB -> GB + serverStats.TotalDisk = totalDiskGB + if serverStats.TotalMemory > 0 && serverStats.AvgMemory > 0 { + serverStats.UsedMemory = int64(math.Round(float64(serverStats.TotalMemory) * serverStats.AvgMemory / 100)) + } + if serverStats.TotalDisk > 0 && serverStats.AvgDisk > 0 { + serverStats.UsedDisk = int64(math.Round(float64(serverStats.TotalDisk) * serverStats.AvgDisk / 100)) + } + stats.ServerStats = serverStats // K8s集群统计 diff --git a/backend/controller/llm.go b/backend/controller/llm.go index 3d683a6..79e02db 100644 --- a/backend/controller/llm.go +++ b/backend/controller/llm.go @@ -55,21 +55,22 @@ func (h *LLMHandler) GetLLMConfig(c *gin.Context) { c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ "provider": "", "model": "", "api_key": "", "endpoint": "", "temperature": 0.7, "max_tokens": 2000, - "system_prompt": "", "enabled": false, "configured": false, + "system_prompt": "", "enabled": false, "reasoning_enabled": false, "configured": false, }}) return } c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ - "id": cfg.ID, - "provider": cfg.Provider, - "model": cfg.Model, - "api_key": cfg.APIKey, - "endpoint": cfg.Endpoint, - "temperature": cfg.Temperature, - "max_tokens": cfg.MaxTokens, - "system_prompt": cfg.SystemPrompt, - "enabled": cfg.Enabled, - "configured": true, + "id": cfg.ID, + "provider": cfg.Provider, + "model": cfg.Model, + "api_key": cfg.APIKey, + "endpoint": cfg.Endpoint, + "temperature": cfg.Temperature, + "max_tokens": cfg.MaxTokens, + "system_prompt": cfg.SystemPrompt, + "enabled": cfg.Enabled, + "reasoning_enabled": cfg.ReasoningEnabled, + "configured": true, }}) } @@ -95,6 +96,7 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) { cfg.MaxTokens = req.MaxTokens cfg.SystemPrompt = req.SystemPrompt cfg.Enabled = req.Enabled + cfg.ReasoningEnabled = req.ReasoningEnabled if cfg.ID == 0 { if err := h.db.Create(&cfg).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存失败: " + err.Error()}) @@ -109,16 +111,17 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) { // 返回脱敏后的配置 cfg.APIKey = maskAPIKey(cfg.APIKey) c.JSON(http.StatusOK, model.Response{Code: 200, Message: "保存成功", Data: gin.H{ - "id": cfg.ID, - "provider": cfg.Provider, - "model": cfg.Model, - "api_key": cfg.APIKey, - "endpoint": cfg.Endpoint, - "temperature": cfg.Temperature, - "max_tokens": cfg.MaxTokens, - "system_prompt": cfg.SystemPrompt, - "enabled": cfg.Enabled, - "configured": true, + "id": cfg.ID, + "provider": cfg.Provider, + "model": cfg.Model, + "api_key": cfg.APIKey, + "endpoint": cfg.Endpoint, + "temperature": cfg.Temperature, + "max_tokens": cfg.MaxTokens, + "system_prompt": cfg.SystemPrompt, + "enabled": cfg.Enabled, + "reasoning_enabled": cfg.ReasoningEnabled, + "configured": true, }}) } diff --git a/backend/controller/llm_chat.go b/backend/controller/llm_chat.go index 1f0ffe2..a251b6d 100644 --- a/backend/controller/llm_chat.go +++ b/backend/controller/llm_chat.go @@ -14,18 +14,6 @@ import ( "github.com/gin-gonic/gin" ) -// buildLLMClient 从数据库配置构造 LLM 客户端(按 provider 协议自动路由 OpenAI/Anthropic) // Logan.Li -func (h *LLMHandler) buildLLMClient(cfg model.LLMConfig) llm.Client { - return llm.NewClient(llm.Config{ - Protocol: resolveProtocol(cfg.Provider), - Endpoint: resolveEndpoint(cfg.Provider, cfg.Endpoint), - APIKey: cfg.APIKey, - Model: cfg.Model, - Temperature: cfg.Temperature, - MaxTokens: cfg.MaxTokens, - }) -} - // toLLMMessages model.ChatMessage[] → llm.Message[](智能助手纯对话,仅填 Role/Content) // Logan.Li func toLLMMessages(msgs []model.ChatMessage) []llm.Message { out := make([]llm.Message, len(msgs)) @@ -57,7 +45,7 @@ func (h *LLMHandler) Chat(c *gin.Context) { } messages := append([]model.ChatMessage{{Role: "system", Content: systemPrompt}}, req.Messages...) - resp, err := h.buildLLMClient(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{ + resp, err := buildLLMClientFromConfig(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{ Messages: toLLMMessages(messages), }) if err != nil { @@ -127,7 +115,7 @@ func (h *LLMHandler) ChatStream(c *gin.Context) { // 流式补全:逐 token 回调转发 SSE;ctx 取消(客户端断开)由 llm.Client 内部终止上游读取 var contentBuilder strings.Builder - _, streamErr := h.buildLLMClient(cfg).Stream(c.Request.Context(), &llm.CompletionRequest{ + _, streamErr := buildLLMClientFromConfig(cfg).Stream(c.Request.Context(), &llm.CompletionRequest{ Messages: toLLMMessages(messages), }, func(delta string) { contentBuilder.WriteString(delta) @@ -188,7 +176,7 @@ func writeSSEError(c *gin.Context, code int, message string) { c.Writer.Flush() } -// TestLLMConnection 测试 LLM 连接(发送一条 ping 消息) // Logan.Li +// TestLLMConnection 测试 LLM 连接(询问模型身份并返回回答) // Logan.Li // POST /api/v1/aiops/llm-config/test func (h *LLMHandler) TestLLMConnection(c *gin.Context) { var cfg model.LLMConfig @@ -196,12 +184,22 @@ func (h *LLMHandler) TestLLMConnection(c *gin.Context) { c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先保存配置"}) return } - _, err := h.buildLLMClient(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{ - Messages: []llm.Message{{Role: "user", Content: "ping"}}, + if !cfg.Enabled { + c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先启用 LLM 服务"}) + return + } + resp, err := buildLLMClientFromConfig(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{ + Messages: []llm.Message{{Role: "user", Content: "你是什么模型?请简短回答。"}}, }) if err != nil { c.JSON(http.StatusOK, model.Response{Code: 502, Message: "连接失败: " + err.Error()}) return } - c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功"}) + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功", Data: gin.H{ + "answer": resp.Message.Content, + "model": resp.Model, + "tokens_used": resp.Usage.TotalTokens, + "provider": cfg.Provider, + "reasoning_enabled": cfg.ReasoningEnabled, + }}) } diff --git a/backend/controller/llm_client.go b/backend/controller/llm_client.go new file mode 100644 index 0000000..17df285 --- /dev/null +++ b/backend/controller/llm_client.go @@ -0,0 +1,26 @@ +// llm_client.go - LLM 客户端构造公共函数 // Logan.Li +// Agent CLI、智能助手等模块共用,避免字段遗漏(如 ReasoningEnabled)。 +package controller + +import ( + "ezops/model" + "ezops/pkg/agentcli/llm" +) + +// buildLLMConfigFromModel 从 model.LLMConfig 构造引擎用 llm.Config // Logan.Li +func buildLLMConfigFromModel(cfg model.LLMConfig) llm.Config { + return llm.Config{ + Protocol: resolveProtocol(cfg.Provider), + Endpoint: resolveEndpoint(cfg.Provider, cfg.Endpoint), + APIKey: cfg.APIKey, + Model: cfg.Model, + Temperature: cfg.Temperature, + MaxTokens: cfg.MaxTokens, + ReasoningEnabled: cfg.ReasoningEnabled, + } +} + +// buildLLMClientFromConfig 从 model.LLMConfig 构造 LLM 客户端(OpenAI/Anthropic 自动路由) // Logan.Li +func buildLLMClientFromConfig(cfg model.LLMConfig) llm.Client { + return llm.NewClient(buildLLMConfigFromModel(cfg)) +} \ No newline at end of file diff --git a/backend/controller/log_proxy.go b/backend/controller/log_proxy.go index bd03ecc..0ccee41 100644 --- a/backend/controller/log_proxy.go +++ b/backend/controller/log_proxy.go @@ -22,7 +22,7 @@ type LogProxyHandler struct { ds *LogDSHandler } -const maxLogQueryLimit = 500 +const maxLogQueryLimit = 1000 // 单次查询返回日志行数硬上限(Loki limit / ES size 共用)// Logan.Li func NewLogProxyHandler(ds *LogDSHandler) *LogProxyHandler { return &LogProxyHandler{ds: ds} diff --git a/backend/controller/session_command.go b/backend/controller/session_command.go index 62503b9..131d339 100644 --- a/backend/controller/session_command.go +++ b/backend/controller/session_command.go @@ -2,6 +2,7 @@ package controller import ( "net/http" + "time" "ezops/model" @@ -47,6 +48,16 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) { if req.Command != "" { query = query.Where("command LIKE ?", "%"+req.Command+"%") } + if req.StartDate != "" { + if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil { + query = query.Where("timestamp >= ?", t) + } + } + if req.EndDate != "" { + if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil { + query = query.Where("timestamp < ?", t.Add(24*time.Hour)) + } + } var total int64 if err := query.Count(&total).Error; err != nil { @@ -54,17 +65,43 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) { return } - var commands []model.SessionCommand - if err := query.Order("id DESC"). + // 联表带出会话录制大小,前端可判断能否回放 // Logan.Li + var items []model.SessionCommandItem + listQuery := h.db.Table("sys_session_commands AS sc"). + Select("sc.*, COALESCE(ts.record_size, 0) AS session_record_size"). + Joins("LEFT JOIN sys_terminal_sessions AS ts ON ts.id = sc.session_id") + if req.SessionID > 0 { + listQuery = listQuery.Where("sc.session_id = ?", req.SessionID) + } + if req.Username != "" { + listQuery = listQuery.Where("sc.username LIKE ?", "%"+req.Username+"%") + } + if req.ServerID > 0 { + listQuery = listQuery.Where("sc.server_id = ?", req.ServerID) + } + if req.Command != "" { + listQuery = listQuery.Where("sc.command LIKE ?", "%"+req.Command+"%") + } + if req.StartDate != "" { + if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil { + listQuery = listQuery.Where("sc.timestamp >= ?", t) + } + } + if req.EndDate != "" { + if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil { + listQuery = listQuery.Where("sc.timestamp < ?", t.Add(24*time.Hour)) + } + } + if err := listQuery.Order("sc.id DESC"). Offset((req.Page - 1) * req.PageSize). Limit(req.PageSize). - Find(&commands).Error; err != nil { + Scan(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询失败"}) return } c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{ - List: commands, + List: items, Total: total, }}) } diff --git a/backend/controller/terminal_session.go b/backend/controller/terminal_session.go index fae2c00..af4cbb4 100644 --- a/backend/controller/terminal_session.go +++ b/backend/controller/terminal_session.go @@ -4,8 +4,10 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" + "ezops/middleware" "ezops/model" "github.com/gin-gonic/gin" @@ -48,6 +50,26 @@ func (h *TerminalSessionHandler) ListTerminalSessions(c *gin.Context) { if req.Status != "" { query = query.Where("status = ?", req.Status) } + if kw := strings.TrimSpace(req.Keyword); kw != "" { + like := "%" + kw + "%" + query = query.Where( + "username LIKE ? OR server_name LIKE ? OR server_ip LIKE ? OR client_ip LIKE ?", + like, like, like, like, + ) + } + if req.StartDate != "" { + if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil { + query = query.Where("start_time >= ?", t) + } + } + if req.EndDate != "" { + if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil { + query = query.Where("start_time < ?", t.Add(24*time.Hour)) + } + } + if req.HasRecord == "1" || strings.EqualFold(req.HasRecord, "true") { + query = query.Where("record_size > 0") + } var total int64 if err := query.Count(&total).Error; err != nil { @@ -173,11 +195,17 @@ func (h *TerminalSessionHandler) KillSession(c *gin.Context) { return } - // 强制终止 WebShell 连接 + // 强制终止 WebShell 连接;在线连接由 webshell finalize 落盘录制,勿抢先 UPDATE 覆盖 // Logan.Li + killed := false if h.killFunc != nil { - h.killFunc(session.ID) + killed = h.killFunc(session.ID) + } + if killed { + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已终止"}) + return } + // 僵尸会话(连接已断开、服务重启等):无录制可落盘,仅关闭记录 now := time.Now() duration := int(now.Sub(session.StartTime).Seconds()) if err := h.db.Model(&session).Updates(map[string]interface{}{ @@ -222,6 +250,36 @@ func (h *TerminalSessionHandler) DownloadSession(c *gin.Context) { c.String(http.StatusOK, session.RecordData) } +// LogReplayView 记录终端回放查看审计(二次审计留痕) // Logan.Li +// POST /api/v1/terminal-sessions/:id/replay-view +func (h *TerminalSessionHandler) LogReplayView(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"}) + return + } + var session model.TerminalSession + if err := h.db.Select("id", "username", "server_name", "server_ip").First(&session, id).Error; err != nil { + c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"}) + return + } + uid := middleware.GetUserID(c) + log := model.AuditLog{ + UserID: &uid, + Username: middleware.GetUsername(c), + IP: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + Module: "terminal_audit", + Action: "replay_view", + Resource: "terminal_session", + ResourceID: strconv.FormatUint(id, 10), + Request: fmt.Sprintf("回放终端会话 #%d(%s → %s/%s)", id, session.Username, session.ServerName, session.ServerIP), + Status: 200, + } + _ = h.db.Create(&log).Error // 落库失败不阻断回放 + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"}) +} + // StreamSession 终端会话实时流(WebSocket 暂未实现) // GET /api/v1/terminal-sessions/:id/stream func (h *TerminalSessionHandler) StreamSession(c *gin.Context) { diff --git a/backend/controller/webshell.go b/backend/controller/webshell.go index d823ad0..4aacdcd 100644 --- a/backend/controller/webshell.go +++ b/backend/controller/webshell.go @@ -190,23 +190,50 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { recorder := newAsciicastRecorder() const maxRecordSize = 1024 * 1024 var cmdBuf strings.Builder + sessionStatus := "closed" + var finalizeOnce sync.Once - finalizeSession := func(status string) { - endTime := time.Now() - duration := int(endTime.Sub(termSession.StartTime).Seconds()) - recData := recorder.String() - recSize := recorder.Len() - log.Printf("[WebShell] Finalize session=%d status=%s duration=%ds rec=%d bytes", termSession.ID, status, duration, recSize) - // Raw SQL with error logging - result := h.db.Exec("UPDATE sys_terminal_sessions SET status=?, duration=?, end_time=?, record_data=?, record_size=? WHERE id=?", - status, duration, endTime, recData, recSize, termSession.ID) - if result.Error != nil { - log.Printf("[WebShell] ERROR finalize session=%d: %v", termSession.ID, result.Error) - } else { - log.Printf("[WebShell] Finalize OK session=%d rows_affected=%d", termSession.ID, result.RowsAffected) + // 命令输出字节统计:每条命令对应「下一条命令前」的 stdout 累计量 // Logan.Li + var cmdTrack struct { + mu sync.Mutex + lastCmdID uint + stdoutBytes int + } + flushCmdOutput := func() { + cmdTrack.mu.Lock() + prevID := cmdTrack.lastCmdID + bytes := cmdTrack.stdoutBytes + cmdTrack.stdoutBytes = 0 + cmdTrack.mu.Unlock() + if prevID > 0 { + _ = h.db.Model(&model.SessionCommand{}).Where("id = ?", prevID). + Update("output_size", bytes).Error } } + // finalizeSession 会话结束落盘(sync.Once 防重复写入) // Logan.Li + finalizeSession := func(status string) { + sessionStatus = status + finalizeOnce.Do(func() { + endTime := time.Now() + duration := int(endTime.Sub(termSession.StartTime).Seconds()) + recData := recorder.String() + recSize := recorder.Len() + log.Printf("[WebShell] Finalize session=%d status=%s duration=%ds rec=%d bytes", termSession.ID, status, duration, recSize) + result := h.db.Exec("UPDATE sys_terminal_sessions SET status=?, duration=?, end_time=?, record_data=?, record_size=? WHERE id=?", + status, duration, endTime, recData, recSize, termSession.ID) + if result.Error != nil { + log.Printf("[WebShell] ERROR finalize session=%d: %v", termSession.ID, result.Error) + } else { + log.Printf("[WebShell] Finalize OK session=%d rows_affected=%d", termSession.ID, result.RowsAffected) + } + }) + } + defer func() { + flushCmdOutput() + finalizeSession(sessionStatus) + }() + connectingMsg := "\r\n\x1b[36mConnecting to " + server.Hostname + " (" + server.IP + ":" + strconv.Itoa(server.Port) + ")...\x1b[0m\r\n" conn.WriteJSON(WSMessage{Status: 0, Data: connectingMsg}) @@ -335,6 +362,9 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { if recorder.Len() < maxRecordSize { recorder.WriteRow(data) } + cmdTrack.mu.Lock() + cmdTrack.stdoutBytes += n + cmdTrack.mu.Unlock() if h.auditHub != nil { h.auditHub.Broadcast(termSession.ID, data) } @@ -366,13 +396,21 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { case 0: if msg.Data != "" { stdinPipe.Write([]byte(msg.Data)) + // 录入用户输入,回放可见键入内容 // Logan.Li + if recorder.Len() < maxRecordSize { + recorder.WriteRow([]byte(msg.Data)) + } // Command extraction from stdin cmdBuf.WriteString(msg.Data) if strings.Contains(msg.Data, "\r") || strings.Contains(msg.Data, "\n") { cmd := extractCommand(cmdBuf.String()) if cmd != "" { - recordCommand(h.db, termSession.ID, termSession.ServerID, usernameStr, cmd) + flushCmdOutput() + newID := recordCommand(h.db, termSession.ID, termSession.ServerID, termSession.ServerName, usernameStr, cmd) + cmdTrack.mu.Lock() + cmdTrack.lastCmdID = newID + cmdTrack.mu.Unlock() } cmdBuf.Reset() } @@ -392,10 +430,16 @@ func (h *WebShellHandler) Terminal(c *gin.Context) { sshClient.Close() <-done stdinPipe.Close() - finalizeSession("closed") + sessionStatus = "closed" log.Printf("[WebShell] Disconnected %s (%s)", server.Hostname, addr) } +// shortShellCmds 常见 2 字符 shell 命令白名单 // Logan.Li +var shortShellCmds = map[string]bool{ + "ls": true, "cd": true, "ps": true, "df": true, "id": true, "wc": true, + "mv": true, "cp": true, "vi": true, "sh": true, "ll": true, "pwd": true, +} + // extractCommand extracts a clean command from raw terminal input func extractCommand(input string) string { cleaned := strings.Map(func(r rune) rune { @@ -411,25 +455,31 @@ func extractCommand(input string) string { if len(cleaned) < 2 || len(cleaned) > 500 { return "" } + // 过滤输入中途误触回车产生的 2 字符残片(如 up/uo),保留 ls/cd 等 // Logan.Li + if len(cleaned) == 2 && !shortShellCmds[cleaned] { + return "" + } if strings.HasPrefix(cleaned, "\x1b[") { return "" } return cleaned } -// recordCommand records a terminal command to sys_session_commands -func recordCommand(db *gorm.DB, sessionID, serverID uint, username, command string) { +// recordCommand 写入命令审计并返回记录 ID // Logan.Li +func recordCommand(db *gorm.DB, sessionID, serverID uint, serverName, username, command string) uint { now := time.Now() cmd := model.SessionCommand{ - SessionID: sessionID, - ServerID: serverID, - Username: username, - Command: command, - Timestamp: now, + SessionID: sessionID, + ServerID: serverID, + ServerName: serverName, + Username: username, + Command: command, + Timestamp: now, } if err := db.Create(&cmd).Error; err != nil { log.Printf("[WebShell] Command record failed: %v", err) - } else { - log.Printf("[WebShell] Command recorded session=%d cmd=%q", sessionID, command) + return 0 } + log.Printf("[WebShell] Command recorded session=%d cmd=%q", sessionID, command) + return cmd.ID } diff --git a/backend/model/llm.go b/backend/model/llm.go index d3b4f13..95a4084 100644 --- a/backend/model/llm.go +++ b/backend/model/llm.go @@ -12,10 +12,11 @@ type LLMConfig struct { Endpoint string `gorm:"size:500" json:"endpoint"` // 基础地址,如 https://api.openai.com/v1 Temperature float64 `gorm:"default:0.7" json:"temperature"` // 采样温度 0-2 MaxTokens int `gorm:"default:2000" json:"max_tokens"` // 最大输出 token - SystemPrompt string `gorm:"type:text" json:"system_prompt"` // 系统提示词(AIOps 运维助手角色) - Enabled bool `gorm:"default:false" json:"enabled"` // 是否启用 - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + SystemPrompt string `gorm:"type:text" json:"system_prompt"` // 系统提示词(AIOps 运维助手角色) + Enabled bool `gorm:"default:false" json:"enabled"` // 是否启用 + ReasoningEnabled bool `gorm:"default:false" json:"reasoning_enabled"` // 是否启用推理/Plan 模式(OpenAI o-series / DeepSeek R1 / Claude thinking 等) // Logan.Li + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 // Logan.Li @@ -23,14 +24,15 @@ func (LLMConfig) TableName() string { return "ai_llm_configs" } // SaveLLMConfigRequest 保存 LLM 配置请求 // Logan.Li type SaveLLMConfigRequest struct { - Provider string `json:"provider"` - Model string `json:"model"` - APIKey string `json:"api_key"` // 若为脱敏值(含 ****)则后端保留原值 - Endpoint string `json:"endpoint"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens"` - SystemPrompt string `json:"system_prompt"` - Enabled bool `json:"enabled"` + Provider string `json:"provider"` + Model string `json:"model"` + APIKey string `json:"api_key"` // 若为脱敏值(含 ****)则后端保留原值 + Endpoint string `json:"endpoint"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + SystemPrompt string `json:"system_prompt"` + Enabled bool `json:"enabled"` + ReasoningEnabled bool `json:"reasoning_enabled"` // 是否启用推理/Plan 模式 } // ChatMessage 对话消息 // Logan.Li diff --git a/backend/model/server.go b/backend/model/server.go index 8e30286..b5efa07 100644 --- a/backend/model/server.go +++ b/backend/model/server.go @@ -216,4 +216,10 @@ type ServerStats struct { AvgCPU float64 `json:"avg_cpu"` AvgMemory float64 `json:"avg_memory"` AvgDisk float64 `json:"avg_disk"` + // 总资源概览(GB/核),用于仪表盘资源使用率卡片展示具体容量 // Logan.Li + TotalCPU int64 `json:"total_cpu"` // CPU 核心数合计 + TotalMemory int64 `json:"total_memory"` // 内存总量 GB + UsedMemory int64 `json:"used_memory"` // 内存已用 GB + TotalDisk int64 `json:"total_disk"` // 磁盘总量 GB + UsedDisk int64 `json:"used_disk"` // 磁盘已用 GB } diff --git a/backend/model/session_command.go b/backend/model/session_command.go index 2f3bee5..2fe36b4 100644 --- a/backend/model/session_command.go +++ b/backend/model/session_command.go @@ -11,6 +11,7 @@ type SessionCommand struct { ServerID uint `json:"server_id"` ServerName string `gorm:"size:128" json:"server_name"` Command string `gorm:"type:text" json:"command"` // 执行的原始命令 + OutputSize *int `json:"output_size"` // 下一条命令前累计的终端输出字节数,nil=历史未统计 // Logan.Li Timestamp time.Time `json:"timestamp"` // 命令执行时间 CreatedAt time.Time `json:"created_at"` } @@ -19,12 +20,14 @@ func (SessionCommand) TableName() string { return "sys_session_commands" } -// SessionCommandQuery 会话命令查询 +// SessionCommandQuery 会话命令查询 // Logan.Li type SessionCommandQuery struct { SessionID uint `form:"session_id"` Username string `form:"username"` ServerID uint `form:"server_id"` Command string `form:"command"` + StartDate string `form:"start_date"` // YYYY-MM-DD + EndDate string `form:"end_date"` Page int `form:"page,default=1"` PageSize int `form:"page_size,default=20"` } diff --git a/backend/model/session_command_item.go b/backend/model/session_command_item.go new file mode 100644 index 0000000..af84e61 --- /dev/null +++ b/backend/model/session_command_item.go @@ -0,0 +1,7 @@ +package model + +// SessionCommandItem 命令列表项(附带关联会话录制大小) // Logan.Li +type SessionCommandItem struct { + SessionCommand + SessionRecordSize int `gorm:"column:session_record_size" json:"session_record_size"` // 关联会话 record_size,0=无录制 // Logan.Li +} \ No newline at end of file diff --git a/backend/model/terminal_session.go b/backend/model/terminal_session.go index 825f302..d17fb64 100644 --- a/backend/model/terminal_session.go +++ b/backend/model/terminal_session.go @@ -26,11 +26,15 @@ func (TerminalSession) TableName() string { return "sys_terminal_sessions" } -// TerminalSessionQuery 终端会话查询 +// TerminalSessionQuery 终端会话查询 // Logan.Li type TerminalSessionQuery struct { - ServerID uint `form:"server_id"` - Username string `form:"username"` - Status string `form:"status"` - Page int `form:"page,default=1"` - PageSize int `form:"page_size,default=20"` + ServerID uint `form:"server_id"` + Username string `form:"username"` + Status string `form:"status"` + Keyword string `form:"keyword"` // 模糊匹配用户/主机名/IP/客户端IP + StartDate string `form:"start_date"` // YYYY-MM-DD + EndDate string `form:"end_date"` // YYYY-MM-DD + HasRecord string `form:"has_record"` // 1/true 仅含录制 + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=20"` } diff --git a/backend/pkg/agentcli/agentcli.go b/backend/pkg/agentcli/agentcli.go index 6460164..4195baa 100644 --- a/backend/pkg/agentcli/agentcli.go +++ b/backend/pkg/agentcli/agentcli.go @@ -11,6 +11,7 @@ package agentcli import ( "context" + "fmt" "ezops/pkg/agentcli/core" "ezops/pkg/agentcli/evolver" @@ -54,9 +55,13 @@ func NewEngine(cfg EngineConfig) (*Engine, error) { // LLM 客户端 client := llm.NewClient(cfg.LLM) - // 工具注册中心:ssh_execute(注入目标主机凭据)+ analyze_output + // 工具注册中心:ssh_execute(SSH 长连接)+ analyze_output registry := tools.NewRegistry() - registry.Register(tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)) + sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout) + if err != nil { + return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err) + } + registry.Register(sshTool) registry.Register(tools.NewAnalyzeOutputTool()) // 安全控制器 @@ -81,16 +86,9 @@ func (e *Engine) SetConfirmFn(fn safety.ConfirmFn) { e.safety.SetConfirmFn(fn) } // SetAutoApprove 测试用:自动批准(生产禁用) // Logan.Li func (e *Engine) SetAutoApprove(b bool) { e.safety.SetAutoApprove(b) } -// Run 执行任务(ReAct 循环),serverInfo 为目标主机摘要(注入 system prompt) // Logan.Li -func (e *Engine) Run(ctx context.Context, query, serverInfo string) (string, error) { - res, err := e.agent.Run(ctx, query, serverInfo) - if err != nil { - return "", err - } - if res != nil { - return res.Output, nil - } - return "", nil +// Run 执行任务(ReAct 循环);prior 为上一轮对话历史(不含 system),priorTokens 为累计 token // Logan.Li +func (e *Engine) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) { + return e.agent.Run(ctx, query, serverInfo, prior, priorTokens) } // EvolveStats 进化统计(供审计展示) // Logan.Li @@ -101,8 +99,11 @@ func (e *Engine) EvolveStats() map[string]interface{} { return e.evolver.GetStats() } -// Close 释放资源 // Logan.Li +// Close 释放资源(含 SSH 长连接) // Logan.Li func (e *Engine) Close() { + if e.tools != nil { + e.tools.Close() + } if e.agent != nil { e.agent.Close() } diff --git a/backend/pkg/agentcli/core/agent.go b/backend/pkg/agentcli/core/agent.go index f056424..35d2ea4 100644 --- a/backend/pkg/agentcli/core/agent.go +++ b/backend/pkg/agentcli/core/agent.go @@ -23,8 +23,9 @@ import ( type EventType string const ( - EventThought EventType = "thought" // AI 思考/推理文本 - EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险) + EventThought EventType = "thought" // AI 思考/推理文本(流式结束后的完整内容) + EventThoughtDelta EventType = "thought_delta" // AI 思考流式增量 token + EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险) EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时) EventProgress EventType = "progress" // 轮次进度 + token EventDone EventType = "done" // 任务完成(最终答案) @@ -50,6 +51,14 @@ type Event struct { // EventCallback 事件回调(controller 注入) // Logan.Li type EventCallback func(Event) +// RunResult ReAct 执行结果(含对话历史供多轮追问) // Logan.Li +type RunResult struct { + Success bool + Output string + History []llm.Message // system 之后的对话历史 + TotalTokens int +} + // Config Agent 运行配置 // Logan.Li type Config struct { MaxIterations int @@ -60,6 +69,7 @@ type Config struct { OutputMaxLength int MaxContextTokens int MemoryDir string // Evolver 记忆目录 + RoundOffset int // 轮次偏移(多轮追问时接续上一轮编号) // Logan.Li } // DefaultConfig 默认配置 // Logan.Li @@ -112,9 +122,9 @@ func (a *Agent) emit(e Event) { } } -// Run 执行单次任务(ReAct 循环 + Evolver 集成) // Logan.Li -func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Result, error) { - a.totalTokens = 0 +// Run 执行单次任务(ReAct 循环 + Evolver 集成);prior 为不含 system 的历史对话 // Logan.Li +func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*RunResult, error) { + a.totalTokens = priorTokens start := time.Now() // Evolver PREDICT:获取历史经验注入 system prompt @@ -127,10 +137,11 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul toolDescs := a.buildToolDescriptions() systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx) - messages := []llm.Message{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: query}, + messages := []llm.Message{{Role: "system", Content: systemPrompt}} + if len(prior) > 0 { + messages = append(messages, prior...) } + messages = append(messages, llm.Message{Role: "user", Content: query}) llmTools := a.registry.ToLLMTools() toolCallHistory := make(map[string]int) // 循环检测 @@ -144,7 +155,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul return nil, err } - a.emit(Event{Type: EventProgress, Round: iter + 1}) + a.emit(Event{Type: EventProgress, Round: a.roundNum(iter)}) // 自适应检查点 if hint := a.checkAdaptiveCheckpoint(iter, toolCallHistory, consecutiveFailures, totalToolCalls); hint != "" { @@ -154,12 +165,17 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul // 裁剪历史 trimmed := a.trimMessages(messages) - // 调用 LLM - resp, err := a.llm.Complete(ctx, &llm.CompletionRequest{ + // 调用 LLM(流式推送思考增量,降低长推理等待感) // Logan.Li + round := a.roundNum(iter) + resp, err := a.llm.Stream(ctx, &llm.CompletionRequest{ Messages: trimmed, Tools: llmTools, Temperature: a.config.Temperature, MaxTokens: a.config.MaxTokens, + }, func(delta string) { + if strings.TrimSpace(delta) != "" { + a.emit(Event{Type: EventThoughtDelta, Round: round, Thought: delta}) + } }) if err != nil { a.emit(Event{Type: EventError, Message: "LLM 调用失败: " + err.Error()}) @@ -169,9 +185,9 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul a.totalTokens += resp.Usage.TotalTokens a.emit(Event{Type: EventProgress, Tokens: a.totalTokens}) - // 推送思考文本 + // 推送完整思考文本(供持久化与回放) // Logan.Li if strings.TrimSpace(resp.Message.Content) != "" { - a.emit(Event{Type: EventThought, Round: iter + 1, Thought: resp.Message.Content}) + a.emit(Event{Type: EventThought, Round: round, Thought: resp.Message.Content}) } messages = append(messages, resp.Message) @@ -180,11 +196,14 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul answer := resp.Message.Content a.emit(Event{Type: EventDone, Answer: answer, Tokens: a.totalTokens}) a.triggerEvolve(ctx, query, serverInfo, answer, toolRecords, true, start) - return &tools.Result{Success: true, Output: answer}, nil + return &RunResult{ + Success: true, Output: answer, + History: a.extractHistory(messages), TotalTokens: a.totalTokens, + }, nil } // 处理工具调用 - observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, iter+1) + observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, round) toolRecords = append(toolRecords, records...) messages = append(messages, observations...) totalToolCalls += len(resp.Message.ToolCalls) @@ -195,7 +214,9 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul msg := fmt.Sprintf("达到最大迭代次数(%d),任务未能完成。可能任务过于复杂或需要更多步骤。", a.config.MaxIterations) a.emit(Event{Type: EventError, Message: msg, Tokens: a.totalTokens}) a.triggerEvolve(ctx, query, serverInfo, "", toolRecords, false, start) - return nil, fmt.Errorf("%s", msg) + return &RunResult{ + Success: false, History: a.extractHistory(messages), TotalTokens: a.totalTokens, + }, fmt.Errorf("%s", msg) } // processToolCalls 处理工具调用,返回 observation 消息 + 记录 // Logan.Li @@ -264,6 +285,13 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist success = false } + // 超长 ssh 输出自动分析,避免 token 爆炸 // Logan.Li + if tc.Function.Name == "ssh_execute" && success && len(output) > a.autoAnalyzeThreshold() { + if analyzed := a.autoAnalyzeOutput(ctx, output); analyzed != "" { + output = analyzed + } + } + // 输出截断 if len(output) > a.config.OutputMaxLength { output = output[:a.config.OutputMaxLength] + @@ -313,6 +341,43 @@ func (a *Agent) triggerEvolve(ctx context.Context, query, serverHost, answer str }() } +// roundNum 计算对外展示的 ReAct 轮次(含多轮追问偏移) // Logan.Li +func (a *Agent) roundNum(iter int) int { + return a.config.RoundOffset + iter + 1 +} + +// extractHistory 提取 system 之后的对话历史(供多轮追问) // Logan.Li +func (a *Agent) extractHistory(messages []llm.Message) []llm.Message { + if len(messages) <= 1 { + return nil + } + out := make([]llm.Message, len(messages)-1) + copy(out, messages[1:]) + return out +} + +// autoAnalyzeThreshold 触发自动 analyze_output 的输出长度阈值 // Logan.Li +func (a *Agent) autoAnalyzeThreshold() int { + t := a.config.OutputMaxLength / 2 + if t < 4000 { + return 4000 + } + return t +} + +// autoAnalyzeOutput 对超长命令输出自动调用 analyze_output // Logan.Li +func (a *Agent) autoAnalyzeOutput(ctx context.Context, output string) string { + tool, err := a.registry.Get("analyze_output") + if err != nil { + return "" + } + r, err := tool.Execute(ctx, map[string]interface{}{"output": output}) + if err != nil || r == nil || !r.Success || strings.TrimSpace(r.Output) == "" { + return "" + } + return r.Output + "\n[系统自动分析超长输出]" +} + // buildToolDescriptions 工具描述文本 // Logan.Li func (a *Agent) buildToolDescriptions() string { var sb strings.Builder @@ -428,9 +493,5 @@ func estimateTokens(s string) int { return len([]rune(s))/3 + 1 } -// Close 释放资源 // Logan.Li -func (a *Agent) Close() { - if a.registry != nil { - // Registry 无 Closer 实现(Web 工具无连接池),保留接口位 - } -} +// Close 释放资源(SSH 长连接由 Engine.Close 统一释放) // Logan.Li +func (a *Agent) Close() {} diff --git a/backend/pkg/agentcli/evolver/engine.go b/backend/pkg/agentcli/evolver/engine.go index 26e2528..f702c39 100644 --- a/backend/pkg/agentcli/evolver/engine.go +++ b/backend/pkg/agentcli/evolver/engine.go @@ -42,6 +42,7 @@ type EvolveResult struct { LearnedHint string `json:"learned_hint"` ToolSequence []string `json:"tool_sequence"` ExperienceAdded bool `json:"experience_added"` + SkillAdded bool `json:"skill_added"` Consolidated bool `json:"consolidated"` } @@ -107,6 +108,21 @@ func (e *EvolverEngine) Evolve(ctx context.Context, exec *TaskExecution) *Evolve go e.memorizeFact(ctx, exec) // 异步,不阻塞进化主流程 } + // Step 6: PROCEDURAL —— 成功任务沉淀程序记忆(工具命令序列) // Logan.Li + if exec.Success { + if steps := extractSSHSteps(exec.ToolCalls); len(steps) >= 2 { + skill := SkillEntry{ + Name: result.TaskType + "标准流程", + When: result.TaskType, + Steps: steps, + Timestamp: time.Now().Unix(), + } + if err := e.mem.AddSkill(skill); err == nil { + result.SkillAdded = true + } + } + } + return result } @@ -194,6 +210,12 @@ func (e *EvolverEngine) GetContextForPrompt(query string) string { if hosts := e.mem.Hosts(); len(hosts) > 0 { sb.WriteString(fmt.Sprintf(" 【已知主机】%s\n", strings.Join(hosts, ", "))) } + if skills := e.mem.FindSkills(taskType, 2); len(skills) > 0 { + sb.WriteString(" 【推荐流程】\n") + for _, sk := range skills { + sb.WriteString(fmt.Sprintf(" - %s: %s\n", sk.Name, strings.Join(sk.Steps, " → "))) + } + } return sb.String() } @@ -258,6 +280,20 @@ func resultToolNames(calls []ToolCallRecord) []string { return out } +// extractSSHSteps 从工具调用记录提取 ssh_execute 命令序列 // Logan.Li +func extractSSHSteps(calls []ToolCallRecord) []string { + out := make([]string, 0, len(calls)) + for _, c := range calls { + if c.ToolName != "ssh_execute" { + continue + } + if cmd, ok := c.Args["command"].(string); ok && strings.TrimSpace(cmd) != "" { + out = append(out, strings.TrimSpace(cmd)) + } + } + return out +} + func uniqueStrings(in []string) []string { seen := make(map[string]bool) out := make([]string, 0, len(in)) diff --git a/backend/pkg/agentcli/evolver/memory.go b/backend/pkg/agentcli/evolver/memory.go index c299c0c..21f862c 100644 --- a/backend/pkg/agentcli/evolver/memory.go +++ b/backend/pkg/agentcli/evolver/memory.go @@ -235,6 +235,21 @@ func (m *Memories) Hosts() []string { return readJSON(m.envPath, KnownHosts{}).Hosts } +// FindSkills 按任务类型查找程序记忆(运维流程,最多 n 条) // Logan.Li +func (m *Memories) FindSkills(taskType string, n int) []SkillEntry { + all := readJSONL[SkillEntry](m.sklPath) + var matched []SkillEntry + for _, s := range all { + if taskType == "" || s.When == taskType { + matched = append(matched, s) + } + } + if len(matched) > n { + matched = matched[len(matched)-n:] + } + return matched +} + // CountStats 统计各层记忆条目数 // Logan.Li func (m *Memories) CountStats() map[string]int { return map[string]int{ diff --git a/backend/pkg/agentcli/llm/anthropic.go b/backend/pkg/agentcli/llm/anthropic.go index 396578f..ba6919e 100644 --- a/backend/pkg/agentcli/llm/anthropic.go +++ b/backend/pkg/agentcli/llm/anthropic.go @@ -42,6 +42,12 @@ type anthropicMessage struct { Content json.RawMessage `json:"content"` } +// anthropicThinking 启用 Claude thinking 模式(Claude 3.7 Sonnet 支持) // Logan.Li +type anthropicThinking struct { + Type string `json:"type"` + BudgetTokens int `json:"budget_tokens"` +} + // anthropicRequest Anthropic /v1/messages 请求体 // Logan.Li type anthropicRequest struct { Model string `json:"model"` @@ -51,6 +57,7 @@ type anthropicRequest struct { Tools []anthropicTool `json:"tools,omitempty"` Temperature float64 `json:"temperature,omitempty"` Stream bool `json:"stream,omitempty"` + Thinking *anthropicThinking `json:"thinking,omitempty"` // Claude 3.7 Sonnet 推理模式 // Logan.Li } // anthropicTool Anthropic 工具定义(input_schema,对应 OpenAI 的 parameters) // Logan.Li @@ -157,6 +164,12 @@ func buildTools(tools []Tool) []anthropicTool { return out } +// isAnthropicThinkingModel 判断是否为支持 thinking 的 Claude 模型 // Logan.Li +func isAnthropicThinkingModel(model string) bool { + m := strings.ToLower(model) + return strings.Contains(m, "claude-3-7") || strings.Contains(m, "claude-3.7") +} + // buildRequest 构造 Anthropic 请求体(Complete 与 Stream 共用) // Logan.Li func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]byte, error) { system, msgs := c.buildMessages(req) @@ -168,6 +181,19 @@ func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]b if payload.MaxTokens <= 0 { payload.MaxTokens = 4096 } + // 启用 Claude thinking 模式时,temperature 必须固定为 1,max_tokens 需大于 budget_tokens // Logan.Li + enabled := req.ReasoningEnabled || c.cfg.ReasoningEnabled + if enabled && isAnthropicThinkingModel(payload.Model) { + payload.Temperature = 1 + budget := payload.MaxTokens / 2 + if budget < 1024 { + budget = 1024 + } + if payload.MaxTokens <= budget { + payload.MaxTokens = budget + 1024 + } + payload.Thinking = &anthropicThinking{Type: "enabled", BudgetTokens: budget} + } body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("序列化请求失败: %w", err) diff --git a/backend/pkg/agentcli/llm/openai.go b/backend/pkg/agentcli/llm/openai.go index ed47b14..1757a22 100644 --- a/backend/pkg/agentcli/llm/openai.go +++ b/backend/pkg/agentcli/llm/openai.go @@ -17,13 +17,14 @@ import ( // Config LLM 客户端配置(endpoint 已由上层解析为完整 base url) // Logan.Li type Config struct { - Protocol string // "openai" | "anthropic",由 client.go 工厂决定具体实现 - Endpoint string // 完整 base url,如 https://api.deepseek.com/v1 - APIKey string // Bearer token - Model string // 模型名,如 deepseek-chat / gpt-4o-mini - Temperature float64 // 采样温度 - MaxTokens int // 单次最大输出 token - Timeout time.Duration // HTTP 超时(0 表示使用默认 180s) + Protocol string // "openai" | "anthropic",由 client.go 工厂决定具体实现 + Endpoint string // 完整 base url,如 https://api.deepseek.com/v1 + APIKey string // Bearer token + Model string // 模型名,如 deepseek-chat / gpt-4o-mini + Temperature float64 // 采样温度 + MaxTokens int // 单次最大输出 token + ReasoningEnabled bool // 是否启用推理/Plan 模式(OpenAI o-series / DeepSeek R1 等) // Logan.Li + Timeout time.Duration // HTTP 超时(0 表示使用默认 180s) } // OpenAIClient OpenAI 兼容客户端 // Logan.Li @@ -48,15 +49,45 @@ func NewOpenAIClient(cfg Config) Client { // Name 返回客户端名称 // Logan.Li func (c *OpenAIClient) Name() string { return "openai-compatible(" + c.cfg.Model + ")" } +// isOpenAIReasoningModel 判断是否为需要 reasoning_effort 的 OpenAI o-series 模型 // Logan.Li +func isOpenAIReasoningModel(model string) bool { + m := strings.ToLower(model) + return strings.Contains(m, "o1") || strings.Contains(m, "o3") +} + +// isGenericReasoningModel 判断是否为通用推理开关模型(DeepSeek R1 等) // Logan.Li +func isGenericReasoningModel(model string) bool { + m := strings.ToLower(model) + return strings.Contains(m, "reasoning") || strings.Contains(m, "r1") || strings.Contains(m, "deepseek-reasoner") +} + +// applyReasoningParams 根据模型类型应用 reasoning 参数 // Logan.Li +func applyReasoningParams(payload *openaiRequest, enabled bool) { + if !enabled { + return + } + if isOpenAIReasoningModel(payload.Model) { + // OpenAI o-series 使用 reasoning_effort,temperature 必须固定为 1(不传) // Logan.Li + payload.ReasoningEffort = "medium" + payload.Temperature = 0 + return + } + if isGenericReasoningModel(payload.Model) { + payload.Reasoning = true + } +} + // openaiRequest OpenAI chat/completions 请求体 // Logan.Li type openaiRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - Tools []Tool `json:"tools,omitempty"` - ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定 - Temperature float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true) + Model string `json:"model"` + Messages []Message `json:"messages"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定 + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true) + ReasoningEffort string `json:"reasoning_effort,omitempty"` // OpenAI o-series 推理强度 // Logan.Li + Reasoning bool `json:"reasoning,omitempty"` // DeepSeek 等部分平台推理开关 // Logan.Li } // openaiResponse OpenAI chat/completions 响应体 // Logan.Li @@ -89,6 +120,7 @@ func (c *OpenAIClient) Complete(ctx context.Context, req *CompletionRequest) (*C if payload.MaxTokens <= 0 { payload.MaxTokens = 4096 } + applyReasoningParams(&payload, req.ReasoningEnabled || c.cfg.ReasoningEnabled) body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("序列化请求失败: %w", err) @@ -151,6 +183,7 @@ func (c *OpenAIClient) Stream(ctx context.Context, req *CompletionRequest, onDel if payload.MaxTokens <= 0 { payload.MaxTokens = 4096 } + applyReasoningParams(&payload, req.ReasoningEnabled || c.cfg.ReasoningEnabled) body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("序列化请求失败: %w", err) diff --git a/backend/pkg/agentcli/llm/types.go b/backend/pkg/agentcli/llm/types.go index a9f90d8..5a85334 100644 --- a/backend/pkg/agentcli/llm/types.go +++ b/backend/pkg/agentcli/llm/types.go @@ -43,10 +43,11 @@ type ToolFunction struct { // CompletionRequest 补全请求参数 // Logan.Li type CompletionRequest struct { - Messages []Message `json:"messages"` - Tools []Tool `json:"tools,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` + Messages []Message `json:"messages"` + Tools []Tool `json:"tools,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + ReasoningEnabled bool `json:"reasoning_enabled,omitempty"` // 是否启用推理/Plan 模式(协议相关) // Logan.Li } // CompletionResponse 补全响应(取首个 choice) // Logan.Li diff --git a/backend/pkg/agentcli/tools/registry.go b/backend/pkg/agentcli/tools/registry.go index 6ef27f7..4030ee0 100644 --- a/backend/pkg/agentcli/tools/registry.go +++ b/backend/pkg/agentcli/tools/registry.go @@ -153,6 +153,17 @@ func StringParam(args map[string]interface{}, key string) string { return "" } +// Close 关闭所有实现了 Close() 的工具(如 ssh_execute 长连接) // Logan.Li +func (r *Registry) Close() { + r.mu.RLock() + defer r.mu.RUnlock() + for _, t := range r.tools { + if c, ok := t.(interface{ Close() error }); ok { + _ = c.Close() + } + } +} + // IntParam 取整型参数(兼容 int/float64/int64) // Logan.Li func IntParam(args map[string]interface{}, key string) int { if v, ok := args[key]; ok { diff --git a/backend/pkg/agentcli/tools/ssh_execute.go b/backend/pkg/agentcli/tools/ssh_execute.go index 3970f13..0bef910 100644 --- a/backend/pkg/agentcli/tools/ssh_execute.go +++ b/backend/pkg/agentcli/tools/ssh_execute.go @@ -1,5 +1,6 @@ // ssh_execute.go - 远程命令执行工具 // Logan.Li // 复用 aiops 既有 pkg/remoteexec(Go 原生 SSH),凭据由 controller 从 model.Server 构建后注入。 +// 任务级复用 PersistentClient 长连接,避免每条命令重建 SSH 握手。 // 实现 DynamicRiskTool:依据具体命令动态评估风险,决定是否触发用户确认。 package tools @@ -14,16 +15,28 @@ import ( // SSHExecuteTool 远程命令执行工具 // Logan.Li type SSHExecuteTool struct { - cfg remoteexec.SSHConfig // 目标主机连接配置(含凭据) - timeout time.Duration // 默认命令超时 + client *remoteexec.PersistentClient // 任务级 SSH 长连接 + timeout time.Duration // 默认命令超时 } -// NewSSHExecuteTool 创建远程执行工具 // Logan.Li -func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) *SSHExecuteTool { +// NewSSHExecuteTool 创建远程执行工具(内部建立 SSH 长连接) // Logan.Li +func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) (*SSHExecuteTool, error) { if timeout <= 0 { timeout = 60 * time.Second } - return &SSHExecuteTool{cfg: cfg, timeout: timeout} + client, err := remoteexec.NewPersistentClient(cfg) + if err != nil { + return nil, fmt.Errorf("SSH 长连接建立失败: %w", err) + } + return &SSHExecuteTool{client: client, timeout: timeout}, nil +} + +// Close 释放 SSH 长连接 // Logan.Li +func (t *SSHExecuteTool) Close() error { + if t.client == nil { + return nil + } + return t.client.Close() } // Name 工具名 // Logan.Li @@ -31,14 +44,9 @@ func (t *SSHExecuteTool) Name() string { return "ssh_execute" } // Description 给 LLM 的工具说明 // Logan.Li func (t *SSHExecuteTool) Description() string { - host := t.cfg.Host - if host == "" { - host = "目标主机" - } - return fmt.Sprintf( - "在远程服务器 %s(用户 %s)上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。"+ - "用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。"+ - "高危命令(rm -rf /、mkfs、reboot 等)需用户确认。", host, t.cfg.User) + return "在远程目标服务器上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。" + + "用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。" + + "高危命令(rm -rf /、mkfs、reboot 等)需用户确认。" } // Parameters JSON Schema 参数定义 // Logan.Li @@ -79,13 +87,11 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{ timeout = time.Duration(sec) * time.Second } - res, err := remoteexec.Exec(t.cfg, command, timeout) + res, err := t.client.Exec(command, timeout) if err != nil { - // 连接级失败(无法 SSH) return &Result{Success: false, Error: fmt.Sprintf("SSH 连接失败: %v", err)}, nil } - // 附带退出码,便于 agent 判断命令是否成功(排障关键信号) output := res.Output if !strings.HasSuffix(output, "\n") && output != "" { output += "\n" @@ -93,8 +99,8 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{ output += fmt.Sprintf("[exit_code=%d]", res.ExitCode) return &Result{ - Success: res.Success, // exit_code==0 + Success: res.Success, Output: output, Error: res.Error, }, nil -} +} \ No newline at end of file diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 6cfa16a..1329fb3 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -120,6 +120,7 @@ func AutoMigrate(db *gorm.DB) error { &model.LogEntry{}, &model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li &model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li + &model.SessionCommand{}, // 终端会话命令审计 // Logan.Li &model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li &model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li &model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li diff --git a/backend/pkg/remoteexec/executor.go b/backend/pkg/remoteexec/executor.go index b0c9a0f..76c68dc 100644 --- a/backend/pkg/remoteexec/executor.go +++ b/backend/pkg/remoteexec/executor.go @@ -5,7 +5,6 @@ package remoteexec import ( "bytes" - "context" "fmt" "log" "net" @@ -70,10 +69,7 @@ type UploadResult struct { // 返回合并的 stdout+stderr 输出、退出码和错误。 func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) { start := time.Now() - result := &ExecResult{ - Host: cfg.Host, - Port: cfg.Port, - } + result := &ExecResult{Host: cfg.Host, Port: cfg.Port} client, err := connect(cfg) if err != nil { @@ -83,70 +79,7 @@ func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, er } defer client.Close() - // 创建 session - session, err := client.NewSession() - if err != nil { - result.Duration = time.Since(start).Milliseconds() - result.Error = fmt.Sprintf("创建SSH会话失败: %v", err) - return result, err - } - defer session.Close() - - // 设置超时 - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - // 在 goroutine 中执行,支持超时 - type execOut struct { - output []byte - err error - } - ch := make(chan execOut, 1) - - go func() { - var stdout, stderr bytes.Buffer - session.Stdout = &stdout - session.Stderr = &stderr - err := session.Run(command) - - // 合并输出 - output := stdout.Bytes() - if stderr.Len() > 0 { - if len(output) > 0 { - output = append(output, '\n') - } - output = append(output, stderr.Bytes()...) - } - ch <- execOut{output: output, err: err} - }() - - select { - case <-ctx.Done(): - result.Duration = time.Since(start).Milliseconds() - result.Error = fmt.Sprintf("执行超时(%v)", timeout) - result.ExitCode = -1 - return result, fmt.Errorf("exec timeout after %v", timeout) - case out := <-ch: - result.Duration = time.Since(start).Milliseconds() - result.Output = string(out.output) - - if out.err != nil { - if exitErr, ok := out.err.(*ssh.ExitError); ok { - result.ExitCode = exitErr.ExitStatus() - result.Success = false - } else { - result.ExitCode = -1 - result.Error = out.err.Error() - result.Success = false - return result, out.err - } - } else { - result.ExitCode = 0 - result.Success = true - } - } - - return result, nil + return execWithClient(client, cfg, command, timeout) } // BatchExec 在多台远程主机上并发执行相同命令。 diff --git a/backend/pkg/remoteexec/persistent_client.go b/backend/pkg/remoteexec/persistent_client.go new file mode 100644 index 0000000..9534b94 --- /dev/null +++ b/backend/pkg/remoteexec/persistent_client.go @@ -0,0 +1,144 @@ +// persistent_client.go - 任务级 SSH 长连接,多次 Exec 复用同一 client // Logan.Li +package remoteexec + +import ( + "bytes" + "context" + "fmt" + "sync" + "time" + + "golang.org/x/crypto/ssh" +) + +// PersistentClient 可复用的 SSH 长连接(任务生命周期内持有,Close 释放) // Logan.Li +type PersistentClient struct { + cfg SSHConfig + client *ssh.Client + mu sync.Mutex +} + +// NewPersistentClient 建立 SSH 长连接 // Logan.Li +func NewPersistentClient(cfg SSHConfig) (*PersistentClient, error) { + client, err := connect(cfg) + if err != nil { + return nil, err + } + return &PersistentClient{cfg: cfg, client: client}, nil +} + +// Exec 在长连接上执行命令;连接断开时自动重连一次 // Logan.Li +func (p *PersistentClient) Exec(command string, timeout time.Duration) (*ExecResult, error) { + p.mu.Lock() + defer p.mu.Unlock() + + result, err := p.execLocked(command, timeout) + if err == nil { + return result, nil + } + // 连接级失败:重连后重试一次 // Logan.Li + if p.client != nil { + _ = p.client.Close() + p.client = nil + } + client, connErr := connect(p.cfg) + if connErr != nil { + return result, err + } + p.client = client + return p.execLocked(command, timeout) +} + +// execLocked 假定已持锁,在当前 client 上执行 // Logan.Li +func (p *PersistentClient) execLocked(command string, timeout time.Duration) (*ExecResult, error) { + if p.client == nil { + client, err := connect(p.cfg) + if err != nil { + return nil, err + } + p.client = client + } + return execWithClient(p.client, p.cfg, command, timeout) +} + +// Close 关闭长连接 // Logan.Li +func (p *PersistentClient) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.client == nil { + return nil + } + err := p.client.Close() + p.client = nil + return err +} + +// execWithClient 在已有 SSH client 上执行命令(供 Exec / PersistentClient 共用) // Logan.Li +func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) { + start := time.Now() + result := &ExecResult{Host: cfg.Host, Port: cfg.Port} + + session, err := client.NewSession() + if err != nil { + result.Duration = time.Since(start).Milliseconds() + result.Error = fmt.Sprintf("创建SSH会话失败: %v", err) + return result, err + } + defer session.Close() + + if timeout <= 0 { + timeout = 60 * time.Second + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + type execOut struct { + output []byte + err error + } + ch := make(chan execOut, 1) + + go func() { + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + runErr := session.Run(command) + + output := stdout.Bytes() + if stderr.Len() > 0 { + if len(output) > 0 { + output = append(output, '\n') + } + output = append(output, stderr.Bytes()...) + } + ch <- execOut{output: output, err: runErr} + }() + + select { + case <-ctx.Done(): + result.Duration = time.Since(start).Milliseconds() + result.Error = fmt.Sprintf("执行超时(%v)", timeout) + result.ExitCode = -1 + return result, fmt.Errorf("exec timeout after %v", timeout) + case out := <-ch: + result.Duration = time.Since(start).Milliseconds() + result.Output = string(out.output) + + if out.err != nil { + if exitErr, ok := out.err.(*ssh.ExitError); ok { + result.ExitCode = exitErr.ExitStatus() + result.Success = false + } else { + result.ExitCode = -1 + result.Error = out.err.Error() + result.Success = false + return result, out.err + } + } else { + result.ExitCode = 0 + result.Success = true + } + } + return result, nil +} \ No newline at end of file diff --git a/backend/router/routes.go b/backend/router/routes.go index 2b987da..1a28173 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -803,6 +803,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c ts.DELETE("/:id", terminalSessionHandler.DeleteTerminalSession) ts.POST("/:id/kill", terminalSessionHandler.KillSession) ts.GET("/:id/download", terminalSessionHandler.DownloadSession) + ts.POST("/:id/replay-view", terminalSessionHandler.LogReplayView) ts.GET("/:id/stream", terminalSessionHandler.StreamSession) } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6292be7..68a898f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -135,7 +135,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1957,7 +1956,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz", "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", @@ -4798,7 +4796,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.95.2" }, @@ -4988,7 +4985,6 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4998,7 +4994,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5009,7 +5004,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5080,7 +5074,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -5385,7 +5378,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5649,7 +5641,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6132,8 +6123,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -6228,7 +6218,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6717,7 +6706,6 @@ } ], "license": "MIT", - "peer": true, "peerDependencies": { "typescript": "^5 || ^6" }, @@ -6858,7 +6846,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -7313,7 +7300,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7361,7 +7347,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7568,7 +7553,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7599,7 +7583,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7612,7 +7595,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz", "integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -8201,7 +8183,6 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8358,7 +8339,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8583,7 +8563,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8699,8 +8678,7 @@ "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/xterm-addon-fit": { "version": "0.8.0", @@ -8747,7 +8725,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1550b6a..efb52c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -205,6 +205,8 @@ function App() { } /> {/* Audit — 各 Tab 独立路由,支持 URL 直链 // Logan.Li */} + {/* /audit 父路径兜底:访问或点击父级时进入终端审计,避免命中 catch-all 跳首页 // Logan.Li */} + } /> } /> } /> } /> diff --git a/frontend/src/api/aiops/assistant.ts b/frontend/src/api/aiops/assistant.ts index b1ace5b..3e21e8d 100644 --- a/frontend/src/api/aiops/assistant.ts +++ b/frontend/src/api/aiops/assistant.ts @@ -15,6 +15,7 @@ export interface LLMConfigData { max_tokens: number; system_prompt: string; enabled: boolean; + reasoning_enabled?: boolean; // 是否启用推理/Plan 模式 // Logan.Li } export interface ChatResult { @@ -23,6 +24,14 @@ export interface ChatResult { tokens_used: number; } +export interface LLMTestResult { + answer: string; + model: string; + tokens_used: number; + provider: string; + reasoning_enabled: boolean; +} + export interface ChatHistoryItem { id: number; user_id: number; @@ -38,8 +47,8 @@ export const aiApi = { getConfig: () => http.get('/aiops/llm-config'), // 保存 LLM 配置(api_key 若为脱敏值则后端保留原值) saveConfig: (data: LLMConfigData) => http.put('/aiops/llm-config', data), - // 测试 LLM 连接(发送 ping) - testConnection: () => http.post('/aiops/llm-config/test'), + // 测试 LLM 连接(询问模型身份,返回模型回答) // Logan.Li + testConnection: () => http.post('/aiops/llm-config/test'), // AI 对话(messages 含历史 + 当前用户消息,session_id 区分多轮对话) chat: (messages: { role: 'user' | 'assistant'; content: string }[], sessionId?: string) => http.post('/aiops/chat', { messages, session_id: sessionId || '' }), diff --git a/frontend/src/api/audit/terminal.ts b/frontend/src/api/audit/terminal.ts index 3cda2be..4960b11 100644 --- a/frontend/src/api/audit/terminal.ts +++ b/frontend/src/api/audit/terminal.ts @@ -9,6 +9,10 @@ export const terminalSessionApi = { server_id?: number; username?: string; status?: string; + keyword?: string; + start_date?: string; + end_date?: string; + has_record?: string | boolean; }) => http.get('/terminal-sessions', params as Record), get: async (id: number) => http.get(`/terminal-sessions/${id}`), getRecord: async (id: number) => http.get(`/terminal-sessions/${id}/record`), @@ -16,6 +20,7 @@ export const terminalSessionApi = { download: async (id: number) => http.download(`/terminal-sessions/${id}/download`, `session-${id}.cast`), delete: async (id: number) => http.delete(`/terminal-sessions/${id}`), batchDelete: async (ids: number[]) => http.post('/terminal-sessions/batch-delete', { ids }), + logReplayView: async (id: number) => http.post(`/terminal-sessions/${id}/replay-view`), }; export const sessionCommandApi = { @@ -24,6 +29,9 @@ export const sessionCommandApi = { page_size?: number; session_id?: number; username?: string; + command?: string; + start_date?: string; + end_date?: string; }) => http.get('/session-commands', params as Record), }; diff --git a/frontend/src/api/server/server.ts b/frontend/src/api/server/server.ts index e51e1dc..41a3bde 100644 --- a/frontend/src/api/server/server.ts +++ b/frontend/src/api/server/server.ts @@ -35,8 +35,10 @@ export const serverApi = { if (creds) Object.assign(body, creds); return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent`, body); }, - batchInstallAgent: async (ids: number[], token: string) => { - return http.post<{ total: number; success: number; results: Record[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, { server_ids: ids }); + batchInstallAgent: async (ids: number[], token: string, credentials?: Record) => { + const body: Record = { server_ids: ids }; + if (credentials) body.credentials = credentials; + return http.post<{ total: number; success: number; results: Record[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, body); }, listFiles: async (serverId: number, path: string) => { return http.get(`/servers/${serverId}/files?path=${encodeURIComponent(path)}`); diff --git a/frontend/src/components/agent/AgentWizard.tsx b/frontend/src/components/agent/AgentWizard.tsx index 855f9b1..d6652ad 100644 --- a/frontend/src/components/agent/AgentWizard.tsx +++ b/frontend/src/components/agent/AgentWizard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useUserStore } from '@/store'; import { Dialog, @@ -20,30 +20,38 @@ interface AgentWizardProps { onOpenChange: (open: boolean) => void; /** 可选:从服务器列表传入的目标服务器信息 */ server?: { id: number; hostname: string; ip: string; has_auth?: boolean; username?: string; os?: string } | null; + /** SSH一键安装开始回调 */ + onStart?: (serverId: number) => void; /** SSH一键安装成功回调 */ onInstalled?: (serverId: number) => void; /** SSH一键安装失败回调 */ onFailed?: (serverId: number, message: string) => void; } -/** 自动检测地址:origin(通过 Nginx 80 代理)用于安装脚本下载,backend 8080 用于 agent 直连 */ +/** 自动检测地址:统一使用页面 origin。 + * 生产环境 Nginx 会把 /agent 和 /api 代理到后端;本地开发通过 Vite proxy 同样走 origin。 + * 不再 hardcode :8080,避免生产环境生成无法访问的地址。// Logan.Li + */ function detectServerAddress() { if (typeof window !== 'undefined' && window.location) { - return { - origin: window.location.origin, // http://10.10.10.75 → Nginx - backend: `${window.location.protocol}//${window.location.hostname}:8080`, // http://10.10.10.75:8080 → 后端直达 - }; + return window.location.origin; } - return { origin: 'http://127.0.0.1', backend: 'http://127.0.0.1:8080' }; + return 'http://127.0.0.1:8080'; } -export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed }: AgentWizardProps) { +export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled, onFailed }: AgentWizardProps) { const token = useUserStore((s) => s.token) || ''; - const { origin, backend } = detectServerAddress(); + const origin = detectServerAddress(); const [copied, setCopied] = useState(false); const [showManualInput, setShowManualInput] = useState(false); const [sshInstalling, setSshInstalling] = useState(false); const [activeTab, setActiveTab] = useState('auto'); + // 有目标主机时默认展示 SSH 一键安装页,无需用户再点一次 tab // Logan.Li + useEffect(() => { + if (open) { + setActiveTab(server ? 'ssh' : 'auto'); + } + }, [open, server]); // 凭据输入(当服务器无认证凭据时显示) const [sshUsername, setSshUsername] = useState('root'); const [sshPassword, setSshPassword] = useState(''); @@ -52,8 +60,8 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed const needsCreds = server && !server.has_auth; - // install.sh 通过 Nginx 代理下载(origin:80 → /agent/ → backend:8080) - const installCommand = `curl -fsSL ${origin}/agent/install.sh | bash -s -- --server ${backend} --token ${token}`; + // install.sh 与 Agent 上报均走页面 origin(Nginx/Vite proxy 统一转发到后端)// Logan.Li + const installCommand = `curl -fsSL ${origin}/agent/install.sh | bash -s -- --server ${origin} --token ${token}`; const handleCopy = () => { const textArea = document.createElement('textarea'); @@ -130,7 +138,7 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed ``, `[Service]`, `Type=simple`, - `ExecStart=/usr/local/bin/aiops-agent --server ${backend} --token ${token}`, + `ExecStart=/usr/local/bin/aiops-agent --server ${origin} --token ${token}`, `Restart=always`, `RestartSec=5`, ``, @@ -151,7 +159,7 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed ]; // SSH 一键安装(当从服务器列表传入 server 上下文时可用) - // 如果服务器未配置凭据,将输入框中的凭据传给后端,首次连接成功后自动保存 + // 改为调用批量异步接口,单台也走后台 goroutine 执行,避免前端长时间等待 // Logan.Li const handleSshInstall = async () => { if (!server || !token) return; if (needsCreds && !sshPassword && !sshKey) { @@ -159,24 +167,24 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed return; } setSshInstalling(true); + onStart?.(server.id); try { - const creds = needsCreds ? { - username: sshUsername, - password: sshPassword, - ssh_key: sshKey, - auth_type: authType, - } : undefined; - const res: Record = await serverApi.installAgent(server.id, token, creds); - const installed = res?.installed || false; - if (installed) { - toast.success(`${server.hostname} Agent 安装成功`); - onInstalled?.(server.id); - onOpenChange(false); - } else { - toast.warning(`${server.hostname}: Agent 安装命令已执行,但服务未检测到运行`); - onInstalled?.(server.id); - onOpenChange(false); - } + // 组装临时凭据(如有),key 为服务器 ID,供后端批量接口覆盖使用 // Logan.Li + const credentials = needsCreds + ? { + [server.id]: { + username: sshUsername, + password: sshPassword, + ssh_key: sshKey, + auth_type: authType, + }, + } + : undefined; + // 优先尝试异步批量接口,即使单台也后台执行 + await serverApi.batchInstallAgent([server.id], token, credentials); + toast.success(`${server.hostname}: 已提交 Agent 安装任务,后台执行中…`); + onInstalled?.(server.id); + onOpenChange(false); } catch (e) { const msg = e?.message || '安装失败'; toast.error(`${server.hostname} ${msg}`); @@ -261,113 +269,111 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
- 服务器地址已自动检测 — 安装脚本通过 Nginx 代理 ({origin}),Agent 直连后端 ({backend})。安装成功后将自动连接并开始上报指标。 + 服务器地址已自动检测 — 安装脚本与 Agent 上报均使用 {origin}(通过 Nginx/Vite proxy 转发到后端)。安装成功后将自动连接并开始上报指标。
- {/* SSH 一键安装 — 无服务器时提示先选择 */} - {!server && ( - + {/* SSH 一键安装 */} + + {!server ? (

未选择目标主机

请先在主机列表中勾选一台主机,再点击「安装 Agent」打开此页面

-
- )} - {/* SSH 一键安装 — 有服务器时显示 */} - {server && ( - -
-
- -
-

{server.hostname}

- {server.ip} -
-
- - {/* 未配置凭据时显示输入框 */} - {needsCreds && ( -
-
- - 该服务器未配置 SSH 凭据,请填写以下信息(首次连接成功后自动保存) + ) : ( + <> +
+
+ +
+

{server.hostname}

+ {server.ip}
-
-
- - setSshUsername(e.target.value)} - placeholder="root" - className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1" - /> +
+ + {/* 未配置凭据时显示输入框 */} + {needsCreds && ( +
+
+ + 该服务器未配置 SSH 凭据,请填写以下信息(首次连接成功后自动保存)
-
-
- +
+
+ { setSshPassword(e.target.value); setAuthType('password'); }} - placeholder="******" + value={sshUsername} + onChange={e => setSshUsername(e.target.value)} + placeholder="root" className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1" />
- 或 - +
+
+ + { setSshPassword(e.target.value); setAuthType('password'); }} + placeholder="******" + className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1" + /> +
+ 或 + +
+ {authType === 'key' && ( +
+ +