diff --git a/AGENTS.md b/AGENTS.md index 65c681f..042d872 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -499,8 +499,8 @@ WebSocket 无法设置自定义 header → 后端 `JWTAuth` 支持 `?token=` que | `docs/development-guide.md` | 开发规范、Git 工作流 | | `docs/deployment.md` | 部署方式、版本发版 | | `docs/project-structure.md` | 目录结构说明 | -| `docs/api-reference.md` | API 接口文档 | -| `docs/database-schema.md` | 数据模型和表结构 | +| `docs/reference/api-reference.md` | API 接口文档 | +| `docs/reference/database-schema.md` | 数据模型和表结构 | | `PROJECT_EVALUATION.md` | 全量项目评估(规模、技术债、评分) | | `ROADMAP.md` | K8s 管理功能演进路线 | diff --git a/CLAUDE.md b/CLAUDE.md index 2e5a832..8bf4d78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -345,4 +345,4 @@ Git 用户: attacker / attacker@gitee.com - **K8s 客户端** — 轻量自研(net/http + crypto/tls),不引入 50MB+ 的 client-go - **BuildKit cache mount** — Docker 构建从 13min 降至 3sec(二次构建) - **健康检查用 `/version`** — K8s 中 `/health` 可能依赖 DB,`/version` 始终 200 -- **Agent CLI 引擎** — 移植自 opsxcli 的 AI Agent 能力到 `pkg/agentcli/`(纯引擎库,零业务依赖,剥离 CLI/TUI 外壳):ReAct 16 轮循环 + 自适应检查点 + 循环检测;工具精简为 `ssh_execute`(复用 `pkg/remoteexec`)+ `analyze_output`;Evolver 四层记忆异步学习并注入下次 Prompt;会话用 GORM 表(`agent_cli_sessions/messages`)多用户隔离;高危命令经 WebSocket `confirm` 事件 + 前端 `useConfirm()` 确认;设计文档见 `docs/agent/` +- **Agent CLI 引擎** — AIOps 原生 Web 智能运维 Agent,核心在 `pkg/agentcli/`(纯引擎库,零业务依赖):ReAct 16 轮循环 + 自适应检查点 + 循环检测;主机工具 `ssh_execute` / `analyze_output`,Kubernetes 工具 `kubernetes_get` / `pod_logs` / `pod_execute` / `kubernetes_action`;Evolver 记忆异步学习并注入下次 Prompt;会话用 GORM 表(`agent_cli_sessions/messages`)多用户隔离;高危命令经 WebSocket `confirm` 事件确认;设计文档见 `docs/ai-agent/` diff --git a/Dockerfile b/Dockerfile index 47f1d19..5b89fa1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -88,10 +88,13 @@ RUN go mod download COPY agent/ ./ -# 仅编译目标架构,arm64/darwin 通过下载 API 提供,降低构建 CPU 占用 // Logan.Li +# 编译容器目标架构 Linux Agent,同时固定产出 Windows AMD64 便于 Windows 主机下载安装。 // Logan.Li RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ -ldflags="-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" \ -o bin/aiops-agent-linux-${TARGETARCH} . +RUN CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build \ + -ldflags="-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" \ + -o bin/aiops-agent-windows-amd64.exe . # ==================== Stage 4: Runtime ==================== FROM alpine:3.20 @@ -126,11 +129,13 @@ COPY --from=backend-builder /app/aiops . # 创建运行时目录 RUN mkdir -p /app/agent/bin /app/agent/deploy -# 复制 agent 二进制(仅目标架构, 其他架构通过下载 API 提供)// Logan.Li +# 复制 agent 二进制。 // Logan.Li COPY --from=agent-builder /agent/bin/aiops-agent-linux-${TARGETARCH} /app/agent/bin/aiops-agent-linux-${TARGETARCH} +COPY --from=agent-builder /agent/bin/aiops-agent-windows-amd64.exe /app/agent/bin/aiops-agent-windows-amd64.exe # 复制 agent 安装脚本 COPY agent/deploy/install.sh /app/agent/deploy/ +COPY agent/deploy/install.ps1 /app/agent/deploy/ COPY agent/deploy/aiops-agent.service /app/agent/deploy/ EXPOSE 8080 diff --git a/README.md b/README.md index 246e829..1c7f02c 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ aiops/ | [部署指南](./docs/deployment.md) | 二进制 / Docker / K8s / 版本发版 | | [开发规范](./docs/development-guide.md) | 代码规范、Git 工作流、PageShell / i18n | | [项目结构](./docs/project-structure.md) | 目录结构说明 | -| [Agent CLI 设计](./docs/agent/00-README.md) | ReAct 引擎、Evolver、安全系统 | +| [AI Agent / Agent CLI 设计](./docs/ai-agent/00-README.md) | ReAct 引擎、Evolver、安全系统 | | [CLAUDE.md](./CLAUDE.md) | AI 助手指引(完整命令参考) | | [AGENTS.md](./AGENTS.md) | AI Agent 工作指南 | diff --git a/agent/Makefile b/agent/Makefile index 7e0071e..8333d0a 100644 --- a/agent/Makefile +++ b/agent/Makefile @@ -10,14 +10,14 @@ LDFLAGS := -s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) # 静态编译:关闭 CGO 并使用静态链接 LINUX_LDFLAGS := -extldflags "-static" -.PHONY: build cross build-linux-amd64 build-linux-arm64 build-darwin-arm64 tidy run fmt vet clean help +.PHONY: build cross build-linux-amd64 build-linux-arm64 build-darwin-arm64 build-windows-amd64 tidy run fmt vet clean help ## build: 本地构建当前平台二进制 build: go build -ldflags "$(LDFLAGS)" -o $(APP) . -## cross: 交叉编译 Linux amd64/arm64 + macOS arm64 -cross: build-linux-amd64 build-linux-arm64 build-darwin-arm64 +## cross: 交叉编译 Linux amd64/arm64 + macOS arm64 + Windows amd64 // Logan.Li +cross: build-linux-amd64 build-linux-arm64 build-darwin-arm64 build-windows-amd64 ## build-linux-amd64: 交叉编译 linux/amd64 build-linux-amd64: @@ -34,6 +34,11 @@ build-darwin-arm64: CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \ go build -ldflags "$(LDFLAGS)" -o bin/$(APP)-darwin-arm64 . +## build-windows-amd64: 编译 Windows/amd64 Agent // Logan.Li +build-windows-amd64: + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \ + go build -ldflags "$(LDFLAGS)" -o bin/$(APP)-windows-amd64.exe . + ## tidy: 整理依赖 tidy: go mod tidy diff --git a/agent/README.md b/agent/README.md index 8893e21..796e3d6 100644 --- a/agent/README.md +++ b/agent/README.md @@ -141,6 +141,22 @@ curl -o /usr/local/bin/aiops-agent http://10.10.10.75:8080/agent/aiops-agent-lin curl -fsSL http://10.10.10.75:8080/agent/install.sh | sudo bash -s -- --server http://10.10.10.75:8080 --token ``` +### Windows + +Windows Agent 使用同一套 `/api/v1/agent/report` 上报协议,主机监控图表不需要单独分叉。第一版已支持 CPU、内存、逻辑磁盘、监听端口、TCP 连接数、进程 TOP、系统信息、健康探针和 PowerShell 任务执行;Linux 专属的 Load、IOWait、inode、FD、磁盘 IO 等字段在 Windows 上会保持 0 或空值。// Logan.Li + +在管理员 PowerShell 中执行: + +```powershell +powershell -ExecutionPolicy Bypass -Command "iwr http://10.10.10.75:8080/agent/install.ps1 -UseBasicParsing -OutFile $env:TEMP\install-aiops-agent.ps1; & $env:TEMP\install-aiops-agent.ps1 -Server 'http://10.10.10.75:8080' -Token ''" +``` + +手动下载: + +```powershell +Invoke-WebRequest -Uri http://10.10.10.75:8080/agent/aiops-agent-windows-amd64.exe -OutFile C:\ProgramData\AIOps\aiops-agent.exe -UseBasicParsing +``` + ## 本地构建 ```bash @@ -148,4 +164,4 @@ cd agent make # CGO_ENABLED=0 静态编译当前平台 ``` -Docker 多架构构建由顶层 `backend/Dockerfile` 的 `agent-builder` stage 完成(linux-amd64 / linux-arm64 / darwin-arm64)。 +顶层 `./run.sh build agent` 会交叉编译 `linux-amd64`、`linux-arm64`、`darwin-arm64`、`windows-amd64.exe`。Docker 镜像会携带当前目标架构 Linux Agent 和 Windows AMD64 Agent。// Logan.Li diff --git a/agent/collector/context.go b/agent/collector/context.go index 0fca552..fa03652 100644 --- a/agent/collector/context.go +++ b/agent/collector/context.go @@ -1,3 +1,5 @@ +//go:build !windows + // Package collector 的上下文切换采集器。 // 读取 /proc/stat 中的 ctxt(上下文切换)和 intr(中断)累计值。 // 注意:此处返回累计值而非速率,后端可用两次上报差值计算。 diff --git a/agent/collector/context_windows.go b/agent/collector/context_windows.go new file mode 100644 index 0000000..9286e8d --- /dev/null +++ b/agent/collector/context_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package collector + +import "ezops/agent/model" + +type ContextSwitchCollector struct{} + +func NewContextSwitchCollector() *ContextSwitchCollector { + return &ContextSwitchCollector{} +} + +func (c *ContextSwitchCollector) Collect() (*model.ContextSwitchStat, error) { + return nil, nil +} diff --git a/agent/collector/cpu.go b/agent/collector/cpu.go index 656f3d6..53d9b9b 100644 --- a/agent/collector/cpu.go +++ b/agent/collector/cpu.go @@ -1,3 +1,5 @@ +//go:build !windows + // Package collector 提供服务器各项系统指标的采集能力。 // 本文件实现 CPU 使用率与负载采集,主要通过解析 Linux /proc/stat 与 /proc/loadavg 实现。 // diff --git a/agent/collector/cpu_windows.go b/agent/collector/cpu_windows.go new file mode 100644 index 0000000..b088dde --- /dev/null +++ b/agent/collector/cpu_windows.go @@ -0,0 +1,77 @@ +//go:build windows + +// Package collector 提供 Windows CPU 指标采集能力。 // Logan.Li +package collector + +import ( + "fmt" + "runtime" + "sync" + "syscall" + "unsafe" + + "ezops/agent/model" +) + +type filetime struct { + LowDateTime uint32 + HighDateTime uint32 +} + +type cpuSample struct { + total uint64 + idle uint64 + iowait uint64 +} + +type CPUCollector struct { + mu sync.Mutex + prev *cpuSample +} + +func NewCPUCollector() *CPUCollector { + return &CPUCollector{} +} + +func (c *CPUCollector) Collect() (model.CPUStat, error) { + stat := model.CPUStat{CoreCount: runtime.NumCPU()} + cur, err := readCPUSample() + if err != nil { + return stat, fmt.Errorf("读取 Windows CPU 采样失败: %w", err) + } + c.mu.Lock() + defer c.mu.Unlock() + if c.prev != nil { + totalDelta := cur.total - c.prev.total + idleDelta := cur.idle - c.prev.idle + if totalDelta > 0 { + stat.UsagePercent = float64(totalDelta-idleDelta) / float64(totalDelta) * 100.0 + if stat.UsagePercent < 0 { + stat.UsagePercent = 0 + } + } + } + c.prev = &cur + return stat, nil +} + +func readCPUSample() (cpuSample, error) { + var idle, kernel, user filetime + proc := syscall.NewLazyDLL("kernel32.dll").NewProc("GetSystemTimes") + ret, _, err := proc.Call( + uintptr(unsafe.Pointer(&idle)), + uintptr(unsafe.Pointer(&kernel)), + uintptr(unsafe.Pointer(&user)), + ) + if ret == 0 { + return cpuSample{}, err + } + idleTicks := filetimeToUint64(idle) + kernelTicks := filetimeToUint64(kernel) + userTicks := filetimeToUint64(user) + return cpuSample{total: kernelTicks + userTicks, idle: idleTicks}, nil +} + +func filetimeToUint64(ft filetime) uint64 { + return uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime) +} diff --git a/agent/collector/disk.go b/agent/collector/disk.go index 187cbcb..91fafde 100644 --- a/agent/collector/disk.go +++ b/agent/collector/disk.go @@ -1,3 +1,5 @@ +//go:build !windows + // Package collector 提供服务器各项系统指标的采集能力。 // 本文件实现对所有已挂载的真实文件系统执行 statfs,采集磁盘使用情况。 // 使用 golang.org/x/sys/unix 的 Statfs,兼容 Linux 与 macOS。 diff --git a/agent/collector/disk_windows.go b/agent/collector/disk_windows.go new file mode 100644 index 0000000..d4eb9cd --- /dev/null +++ b/agent/collector/disk_windows.go @@ -0,0 +1,52 @@ +//go:build windows + +// Package collector 提供 Windows 逻辑磁盘使用率采集能力。 // Logan.Li +package collector + +import ( + "fmt" + "syscall" + "unsafe" + + "ezops/agent/model" +) + +type DiskCollector struct{} + +func NewDiskCollector() *DiskCollector { + return &DiskCollector{} +} + +func (d *DiskCollector) Collect() ([]model.DiskStat, error) { + mask, _, err := syscall.NewLazyDLL("kernel32.dll").NewProc("GetLogicalDrives").Call() + if mask == 0 { + return nil, fmt.Errorf("GetLogicalDrives 失败: %w", err) + } + var disks []model.DiskStat + for i := 0; i < 26; i++ { + if mask&(1< swapAvail { + swapUsed = swapTotal - swapAvail + } + stat := model.MemoryStat{ + TotalGB: bytesToGB(total), + UsedGB: bytesToGB(used), + AvailableGB: bytesToGB(avail), + SwapUsedGB: bytesToGB(swapUsed), + UsagePercent: float64(ms.MemoryLoad), + } + return stat, nil +} diff --git a/agent/collector/network.go b/agent/collector/network.go index 01f1b6b..6311e9c 100644 --- a/agent/collector/network.go +++ b/agent/collector/network.go @@ -1,3 +1,5 @@ +//go:build !windows + // Package collector 提供服务器各项系统指标的采集能力。 // 本文件实现网络连接数、网卡流量等网络指标采集。 // @@ -18,11 +20,11 @@ import ( // netSample 保存上一次的网络采样数据,用于计算速率 type netSample struct { - time time.Time - rxBytes uint64 - txBytes uint64 - rxErrors uint64 - txErrors uint64 + time time.Time + rxBytes uint64 + txBytes uint64 + rxErrors uint64 + txErrors uint64 } // NetworkCollector 网络指标采集器 diff --git a/agent/collector/network_windows.go b/agent/collector/network_windows.go new file mode 100644 index 0000000..837ea06 --- /dev/null +++ b/agent/collector/network_windows.go @@ -0,0 +1,53 @@ +//go:build windows + +// Package collector 提供 Windows 网络连接基础采集能力。 // Logan.Li +package collector + +import ( + "bytes" + "os/exec" + "strings" + + "ezops/agent/model" +) + +type NetworkCollector struct{} + +func NewNetworkCollector() *NetworkCollector { + return &NetworkCollector{} +} + +func (c *NetworkCollector) Collect() (model.NetworkStat, error) { + stat := model.NetworkStat{NicName: firstWindowsNIC()} + out, err := exec.Command("netstat", "-ano", "-p", "tcp").Output() + if err != nil { + return stat, nil + } + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 4 || strings.ToUpper(fields[0]) != "TCP" { + continue + } + state := strings.ToUpper(fields[3]) + switch state { + case "ESTABLISHED": + stat.TCPEstablished++ + case "TIME_WAIT": + stat.TCPTimeWait++ + case "CLOSE_WAIT": + stat.TCPCloseWait++ + } + stat.TCPTotal++ + } + return stat, nil +} + +func firstWindowsNIC() string { + out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", "Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | Select-Object -First 1 -ExpandProperty Name").Output() + if err == nil { + if name := strings.TrimSpace(string(bytes.TrimSpace(out))); name != "" { + return name + } + } + return "Ethernet" +} diff --git a/agent/collector/port.go b/agent/collector/port.go index 90f29eb..cbad506 100644 --- a/agent/collector/port.go +++ b/agent/collector/port.go @@ -1,3 +1,5 @@ +//go:build !windows + // Package collector 提供服务器各项系统指标的采集能力。 // 本文件实现监听 TCP 端口的自动发现: // - 扫描 /proc/net/tcp 与 /proc/net/tcp6 中状态为 LISTEN(0A) 的套接字; @@ -35,7 +37,7 @@ var systemProcesses = map[string]bool{ "upowerd": true, "rtkit-daemon": true, "switcheroo-contr": true, - "power-profiles-": true, // power-profiles-daemon + "power-profiles-": true, // power-profiles-daemon "wpa_supplicant": true, "avahi-daemon": true, "cron": true, diff --git a/agent/collector/port_windows.go b/agent/collector/port_windows.go new file mode 100644 index 0000000..7ef4327 --- /dev/null +++ b/agent/collector/port_windows.go @@ -0,0 +1,94 @@ +//go:build windows + +// Package collector 提供 Windows 监听端口发现能力。 // Logan.Li +package collector + +import ( + "encoding/csv" + "os/exec" + "strconv" + "strings" + + "ezops/agent/model" +) + +type PortCollector struct { + Policy *model.PortPolicy +} + +func NewPortCollector() *PortCollector { + return &PortCollector{} +} + +func (p *PortCollector) SetPolicy(policy *model.PortPolicy) { + p.Policy = policy +} + +func (p *PortCollector) Collect() ([]model.PortStat, error) { + pidNames := windowsProcessNames() + out, err := exec.Command("netstat", "-ano", "-p", "tcp").Output() + if err != nil { + return []model.PortStat{}, nil + } + seen := map[string]bool{} + var ports []model.PortStat + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 5 || strings.ToUpper(fields[0]) != "TCP" || strings.ToUpper(fields[3]) != "LISTENING" { + continue + } + port := parseWindowsPort(fields[1]) + if port <= 0 { + continue + } + if p.Policy != nil && p.Policy.IsBlacklistMode() && !p.Policy.InWhitelist(port) { + continue + } + pid, _ := strconv.Atoi(fields[4]) + key := strconv.Itoa(port) + "|" + strconv.Itoa(pid) + if seen[key] { + continue + } + seen[key] = true + ports = append(ports, model.PortStat{ + Port: port, + Protocol: "tcp", + Process: pidNames[pid], + PID: pid, + }) + } + return ports, nil +} + +func parseWindowsPort(addr string) int { + addr = strings.Trim(addr, "[]") + idx := strings.LastIndex(addr, ":") + if idx < 0 || idx+1 >= len(addr) { + return 0 + } + port, _ := strconv.Atoi(addr[idx+1:]) + return port +} + +func windowsProcessNames() map[int]string { + out, err := exec.Command("tasklist", "/FO", "CSV", "/NH").Output() + if err != nil { + return map[int]string{} + } + reader := csv.NewReader(strings.NewReader(string(out))) + rows, err := reader.ReadAll() + if err != nil { + return map[int]string{} + } + result := map[int]string{} + for _, row := range rows { + if len(row) < 2 { + continue + } + pid, err := strconv.Atoi(strings.TrimSpace(row[1])) + if err == nil { + result[pid] = row[0] + } + } + return result +} diff --git a/agent/collector/proctop.go b/agent/collector/proctop.go index a4b063b..8f4374c 100644 --- a/agent/collector/proctop.go +++ b/agent/collector/proctop.go @@ -1,3 +1,5 @@ +//go:build !windows + // Package collector — 进程 TOP 采集器 // 扫描 /proc 获取 TOP N 进程完整信息:PID/User/CMD/CPU%/MEM%/FDs // 阿里云 ECS 监控风格 — 直观 6 列布局 diff --git a/agent/collector/proctop_windows.go b/agent/collector/proctop_windows.go new file mode 100644 index 0000000..a9cc835 --- /dev/null +++ b/agent/collector/proctop_windows.go @@ -0,0 +1,62 @@ +//go:build windows + +// Package collector 提供 Windows 进程 TOP 基础采集能力。 // Logan.Li +package collector + +import ( + "encoding/csv" + "os/exec" + "sort" + "strconv" + "strings" + + "ezops/agent/model" +) + +type ProcessTopCollector struct{} + +func NewProcessTopCollector() *ProcessTopCollector { + return &ProcessTopCollector{} +} + +func (p *ProcessTopCollector) Collect(topN int) ([]model.ProcessStat, error) { + out, err := exec.Command("tasklist", "/FO", "CSV", "/NH").Output() + if err != nil { + return []model.ProcessStat{}, nil + } + rows, err := csv.NewReader(strings.NewReader(string(out))).ReadAll() + if err != nil { + return []model.ProcessStat{}, nil + } + var procs []model.ProcessStat + for _, row := range rows { + if len(row) < 5 { + continue + } + pid, _ := strconv.Atoi(strings.TrimSpace(row[1])) + memKB := parseWindowsMemKB(row[4]) + procs = append(procs, model.ProcessStat{ + PID: pid, + Name: row[0], + Cmdline: row[0], + User: "", + MemoryMB: float64(memKB) / 1024.0, + }) + } + sort.Slice(procs, func(i, j int) bool { + return procs[i].MemoryMB > procs[j].MemoryMB + }) + if topN > len(procs) { + topN = len(procs) + } + if topN < 0 { + topN = 0 + } + return procs[:topN], nil +} + +func parseWindowsMemKB(s string) int64 { + clean := strings.NewReplacer(",", "", "K", "", "k", "", " ", "").Replace(s) + v, _ := strconv.ParseInt(clean, 10, 64) + return v +} diff --git a/agent/collector/swap.go b/agent/collector/swap.go index 681849b..d56e8a4 100644 --- a/agent/collector/swap.go +++ b/agent/collector/swap.go @@ -1,3 +1,5 @@ +//go:build !windows + // Package collector — Swap 采集器 // 从 /proc/meminfo 读取 SwapTotal / SwapFree // 仅当系统有 swap 分区时返回有效数据 diff --git a/agent/collector/swap_windows.go b/agent/collector/swap_windows.go new file mode 100644 index 0000000..6cdee50 --- /dev/null +++ b/agent/collector/swap_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package collector + +import "ezops/agent/model" + +type SwapCollector struct{} + +func NewSwapCollector() *SwapCollector { + return &SwapCollector{} +} + +func (s *SwapCollector) Collect() (*model.SwapStat, error) { + return nil, nil +} + +func parseUint64(s string) (uint64, error) { + var v uint64 + for _, c := range s { + if c < '0' || c > '9' { + break + } + v = v*10 + uint64(c-'0') + } + return v, nil +} diff --git a/agent/collector/system.go b/agent/collector/system.go index 1821c18..05920f0 100644 --- a/agent/collector/system.go +++ b/agent/collector/system.go @@ -1,3 +1,5 @@ +//go:build !windows + // Package collector 实现 AIOps Agent 系统静态信息采集。 // 仅在 Agent 启动时执行一次,采集 OS 版本、内核版本等信息。 // diff --git a/agent/collector/system_windows.go b/agent/collector/system_windows.go new file mode 100644 index 0000000..9b9280e --- /dev/null +++ b/agent/collector/system_windows.go @@ -0,0 +1,56 @@ +//go:build windows + +// Package collector 实现 Windows 系统静态信息采集。 // Logan.Li +package collector + +import ( + "os/exec" + "strings" +) + +type SystemInfo struct { + OSVersion string + Kernel string + UptimeSeconds int64 +} + +func CollectSystem() SystemInfo { + return SystemInfo{ + OSVersion: windowsOSCaption(), + Kernel: windowsVersion(), + UptimeSeconds: windowsUptime(), + } +} + +func windowsOSCaption() string { + out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", "(Get-CimInstance Win32_OperatingSystem).Caption").Output() + if err != nil { + return "Windows" + } + if s := strings.TrimSpace(string(out)); s != "" { + return s + } + return "Windows" +} + +func windowsVersion() string { + out, err := exec.Command("cmd.exe", "/C", "ver").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func windowsUptime() int64 { + out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", "[int]((Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime).TotalSeconds").Output() + if err != nil { + return 0 + } + var n int64 + for _, ch := range strings.TrimSpace(string(out)) { + if ch >= '0' && ch <= '9' { + n = n*10 + int64(ch-'0') + } + } + return n +} diff --git a/agent/collector/windows_common.go b/agent/collector/windows_common.go new file mode 100644 index 0000000..1bd0325 --- /dev/null +++ b/agent/collector/windows_common.go @@ -0,0 +1,8 @@ +//go:build windows + +package collector + +func bytesToGB(b uint64) float64 { + const gb = 1024.0 * 1024.0 * 1024.0 + return float64(b) / gb +} diff --git a/agent/deploy/install.ps1 b/agent/deploy/install.ps1 new file mode 100644 index 0000000..f71ff02 --- /dev/null +++ b/agent/deploy/install.ps1 @@ -0,0 +1,71 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Server, + [Parameter(Mandatory = $false)] + [string]$Token = "none", + [Parameter(Mandatory = $false)] + [int]$Interval = 10 +) + +$ErrorActionPreference = "Stop" + +function Assert-Admin { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "请使用管理员 PowerShell 执行安装脚本" + } +} + +function Normalize-Server([string]$Value) { + return $Value.TrimEnd("/") +} + +Assert-Admin +$Server = Normalize-Server $Server +$InstallDir = "C:\Program Files\AIOps" +$LogDir = "C:\ProgramData\AIOps\logs" +$AgentPath = Join-Path $InstallDir "aiops-agent.exe" +$ServiceName = "AIOpsAgent" +$BinaryUrl = "$Server/agent/aiops-agent-windows-amd64.exe" + +Write-Host "" +Write-Host "AIOps Agent Windows 安装" +Write-Host "Server: $Server" +Write-Host "Binary: $BinaryUrl" +Write-Host "" + +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null + +$Tmp = Join-Path $env:TEMP "aiops-agent-windows-amd64.exe" +Invoke-WebRequest -Uri $BinaryUrl -OutFile $Tmp -UseBasicParsing +Copy-Item -Force $Tmp $AgentPath + +$Args = "-server `"$Server`" -interval $Interval -token `"$Token`"" +$Existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($Existing) { + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + sc.exe delete $ServiceName | Out-Null + Start-Sleep -Seconds 1 +} + +New-Service -Name $ServiceName ` + -DisplayName "AIOps Agent" ` + -Description "AIOps Windows monitoring and task agent" ` + -BinaryPathName "`"$AgentPath`" $Args" ` + -StartupType Automatic | Out-Null + +Start-Service -Name $ServiceName +Start-Sleep -Seconds 2 + +$Status = (Get-Service -Name $ServiceName).Status +if ($Status -ne "Running") { + throw "服务启动失败,当前状态: $Status" +} + +Write-Host "" +Write-Host "安装成功,AIOps Agent 已作为 Windows 服务启动" +Write-Host "查看状态: Get-Service AIOpsAgent" +Write-Host "停止服务: Stop-Service AIOpsAgent" +Write-Host "卸载服务: Stop-Service AIOpsAgent; sc.exe delete AIOpsAgent" diff --git a/agent/task/client.go b/agent/task/client.go index d9212df..20d7753 100644 --- a/agent/task/client.go +++ b/agent/task/client.go @@ -63,8 +63,8 @@ func (c *Client) PollProbes(ctx context.Context) ([]model.ProbeConfig, error) { path := probePath + "?hostname=" + url.QueryEscape(c.hostname) var wrapper struct { - Code int `json:"code"` - Message string `json:"message"` + Code int `json:"code"` + Message string `json:"message"` Data []model.ProbeConfig `json:"data"` } if err := c.client.DoJSON(ctx, "GET", path, nil, &wrapper); err != nil { diff --git a/agent/task/executor.go b/agent/task/executor.go index 6746664..940511b 100644 --- a/agent/task/executor.go +++ b/agent/task/executor.go @@ -1,3 +1,5 @@ +//go:build !windows + // Package task 实现 AIOps Agent 的任务拉取与执行能力。 // Agent 通过轮询接口从后端获取待执行任务(命令/脚本),本地执行后回传结果。 // @@ -13,28 +15,6 @@ import ( "time" ) -// 任务类型常量 -const ( - TypeCommand = "command" // 单条 shell 命令 - TypeScript = "script" // 多行 bash 脚本 -) - -// Task 表示从后端拉取到的待执行任务。 -type Task struct { - TaskID string `json:"task_id"` // 任务唯一标识,回传结果时使用 - Type string `json:"type"` // 任务类型:command / script - Command string `json:"command"` // 命令内容(command 时为单条命令,script 时为脚本正文) - Timeout int `json:"timeout"` // 执行超时,单位:秒;<=0 表示不限制 -} - -// Result 表示任务执行结果,用于回传后端。 -type Result struct { - TaskID string `json:"task_id"` // 对应的任务 ID - ExitCode int `json:"exit_code"` // 进程退出码;超时/启动失败时为 -1 - Result string `json:"result"` // 合并后的 stdout+stderr 输出 - Status string `json:"status"` // 执行状态:success / failed / timeout -} - // Executor 负责本地执行任务并产出结果。 type Executor struct { logger *log.Logger diff --git a/agent/task/executor_windows.go b/agent/task/executor_windows.go new file mode 100644 index 0000000..f35cf3d --- /dev/null +++ b/agent/task/executor_windows.go @@ -0,0 +1,103 @@ +//go:build windows + +// Package task 实现 Windows Agent 的 PowerShell 任务执行能力。 // Logan.Li +package task + +import ( + "bytes" + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "time" +) + +type Executor struct { + logger *log.Logger +} + +func New(logger *log.Logger) *Executor { + return &Executor{logger: logger} +} + +func (e *Executor) ExecuteTask(ctx context.Context, t Task) Result { + res := Result{TaskID: t.TaskID} + execCtx, cancel := e.withTimeout(ctx, t.Timeout) + defer cancel() + + var cmd *exec.Cmd + var cleanup func() + switch t.Type { + case TypeCommand: + cmd = exec.CommandContext(execCtx, "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", t.Command) + case TypeScript: + path, err := writeTempPowerShell(t.Command) + if err != nil { + res.ExitCode = -1 + res.Status = "failed" + res.Result = "写入临时 PowerShell 脚本失败: " + err.Error() + return res + } + cleanup = func() { _ = os.Remove(path) } + cmd = exec.CommandContext(execCtx, "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path) + default: + res.ExitCode = -1 + res.Status = "failed" + res.Result = fmt.Sprintf("未知的任务类型: %q", t.Type) + return res + } + if cleanup != nil { + defer cleanup() + } + + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + start := time.Now() + err := cmd.Run() + elapsed := time.Since(start) + if execCtx.Err() == context.DeadlineExceeded { + res.ExitCode = -1 + res.Status = "timeout" + res.Result = fmt.Sprintf("任务执行超时(%ds),已输出:\n%s", t.Timeout, buf.String()) + e.logger.Printf("任务 %s 执行超时(%ds)", t.TaskID, t.Timeout) + return res + } + res.ExitCode = exitCode(err) + res.Result = buf.String() + if res.ExitCode == 0 { + res.Status = "success" + e.logger.Printf("任务 %s 执行成功 exit=0 耗时=%s 输出=%dB", t.TaskID, elapsed, buf.Len()) + } else { + res.Status = "failed" + e.logger.Printf("任务 %s 执行失败 exit=%d 耗时=%s 输出=%dB", t.TaskID, res.ExitCode, elapsed, buf.Len()) + } + return res +} + +func (e *Executor) withTimeout(parent context.Context, timeout int) (context.Context, context.CancelFunc) { + if timeout <= 0 { + return parent, func() {} + } + return context.WithTimeout(parent, time.Duration(timeout)*time.Second) +} + +func exitCode(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + return -1 +} + +func writeTempPowerShell(script string) (string, error) { + path := filepath.Join(os.TempDir(), fmt.Sprintf("aiops-agent-task-%d.ps1", time.Now().UnixNano())) + if err := os.WriteFile(path, []byte(script), 0600); err != nil { + return "", err + } + return path, nil +} diff --git a/agent/task/types.go b/agent/task/types.go new file mode 100644 index 0000000..77242d9 --- /dev/null +++ b/agent/task/types.go @@ -0,0 +1,23 @@ +// Package task 定义 Agent 远程任务类型。 // Logan.Li +package task + +const ( + TypeCommand = "command" // 单条 shell/PowerShell 命令 // Logan.Li + TypeScript = "script" // 多行脚本 // Logan.Li +) + +// Task 表示从后端拉取到的待执行任务。 // Logan.Li +type Task struct { + TaskID string `json:"task_id"` + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout"` +} + +// Result 表示任务执行结果,用于回传后端。 // Logan.Li +type Result struct { + TaskID string `json:"task_id"` + ExitCode int `json:"exit_code"` + Result string `json:"result"` + Status string `json:"status"` +} diff --git a/backend/controller/agent_cli.go b/backend/controller/agent_cli.go index 56f6e56..10b0a99 100644 --- a/backend/controller/agent_cli.go +++ b/backend/controller/agent_cli.go @@ -22,6 +22,7 @@ import ( "ezops/pkg/agentcli/llm" "ezops/pkg/agentcli/safety" "ezops/pkg/agentcli/tools" + "ezops/pkg/aiopscontext" "ezops/pkg/remoteexec" ) @@ -54,11 +55,11 @@ type wsEvent struct { // wsControl 前端→后端控制消息 // Logan.Li type wsControl struct { - Type string `json:"type"` // start/continue/resume/confirm/deny/abort - Query string `json:"query,omitempty"` // start/continue 时任务描述 - SessionID uint `json:"session_id,omitempty"` // resume 时恢复的历史会话 ID - SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive - ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时 + Type string `json:"type"` // start/continue/resume/confirm/deny/abort + Query string `json:"query,omitempty"` // start/continue 时任务描述 + SessionID uint `json:"session_id,omitempty"` // resume 时恢复的历史会话 ID + SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive + ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时 } // writeWSCloseError 在 agentConn 建立前,直接通过 conn 推送一条 error 事件再关闭连接 // Logan.Li @@ -75,24 +76,24 @@ func writeWSCloseError(conn *websocket.Conn, msg string) { // agentConn 单个 WS 连接的运行态 // Logan.Li type agentConn struct { - conn *websocket.Conn - db *gorm.DB - userID uint - mode string - server model.Server - sshCfg remoteexec.SSHConfig + conn *websocket.Conn + db *gorm.DB + userID uint + mode string + server model.Server + sshCfg remoteexec.SSHConfig kubernetesCtx *kubernetesAgentContext - 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 - running bool - runningMu sync.Mutex + 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 + running bool + runningMu sync.Mutex } // Run WebSocket 入口 // Logan.Li @@ -362,6 +363,7 @@ func (ac *agentConn) runTask(sess *model.AgentCLISession, query, safetyMode stri SafetyMode: parseSafetyMode(safetyMode), MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID), Core: coreCfg, + ExtraTools: aiopscontext.NewAgentTools(aiopscontext.NewService(ac.db)), } if ac.mode == agentcli.RunModeKubernetes { if ac.kubernetesCtx == nil { @@ -604,4 +606,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/aiops_alert_analysis.go b/backend/controller/aiops_alert_analysis.go new file mode 100644 index 0000000..c1c65fd --- /dev/null +++ b/backend/controller/aiops_alert_analysis.go @@ -0,0 +1,452 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "ezops/middleware" + "ezops/model" + "ezops/pkg/agentcli/llm" + "ezops/pkg/aiopscontext" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// AIOpsAlertAnalysisHandler 基于上下文包执行告警 RCA。 // Logan.Li +type AIOpsAlertAnalysisHandler struct { + db *gorm.DB + context *aiopscontext.Service +} + +// NewAIOpsAlertAnalysisHandler 创建告警分析处理器。 // Logan.Li +func NewAIOpsAlertAnalysisHandler(db *gorm.DB) *AIOpsAlertAnalysisHandler { + return &AIOpsAlertAnalysisHandler{db: db, context: aiopscontext.NewService(db)} +} + +// AlertAnalysisRequest 告警分析请求,前端传告警 ID 和少量兜底字段。 // Logan.Li +type AlertAnalysisRequest struct { + AlertID uint `json:"alert_id"` + Name string `json:"name"` + Severity string `json:"severity"` + Target string `json:"target"` + Metric string `json:"metric"` + CurrentValue string `json:"current_value"` + Threshold string `json:"threshold"` + Duration string `json:"duration"` + Description string `json:"description"` + TimeRange string `json:"time_range"` +} + +// AlertAnalysisResponse 告警 RCA 结构化响应。 // Logan.Li +type AlertAnalysisResponse struct { + TraceID string `json:"trace_id"` + SpanID string `json:"span_id"` + RootCause string `json:"root_cause"` + Impact string `json:"impact"` + Recommendations []string `json:"recommendations"` + AutoFixScript string `json:"auto_fix_script,omitempty"` + SimilarCases int `json:"similar_cases"` + Confidence int `json:"confidence"` + Evidence []aiopscontext.EvidenceItem `json:"evidence"` + ContextPack *aiopscontext.ContextPack `json:"context_pack,omitempty"` + Model string `json:"model"` + Source string `json:"source"` + GeneratedAt time.Time `json:"generated_at"` +} + +// Analyze POST /api/v1/aiops/alert-analysis。 // Logan.Li +func (h *AIOpsAlertAnalysisHandler) Analyze(c *gin.Context) { + start := time.Now() + traceID := uuid.NewString() + spanID := uuid.NewString() + userID := middleware.GetUserID(c) + + var req AlertAnalysisRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + timeRange := req.TimeRange + if strings.TrimSpace(timeRange) == "" { + timeRange = "24h" + } + metrics := metricsForAlert(req.Metric) + pack, err := h.context.BuildPack(c.Request.Context(), aiopscontext.ContextPackRequest{ + AlertID: req.AlertID, Target: req.Target, Metrics: metrics, TimeRange: timeRange, Limit: 12, + }) + if err != nil { + h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{ + TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis", + Target: req.Target, AlertID: req.AlertID, Status: "error", Source: "context-pack", + Duration: time.Since(start).Milliseconds(), Error: err.Error(), + Result: gin.H{"error": "构建上下文失败: " + err.Error()}, + }) + c.JSON(http.StatusOK, model.Response{Code: 500, Message: "构建上下文失败: " + err.Error()}) + return + } + + fallback := fallbackAlertAnalysis(req, pack) + attachTrace(fallback, traceID, spanID) + var cfg model.LLMConfig + if err := h.db.First(&cfg).Error; err != nil || !cfg.Enabled { + h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{ + TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis", + Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider, + Model: fallback.Model, Source: fallback.Source, Status: "fallback", Pack: pack, Result: fallback, + Duration: time.Since(start).Milliseconds(), + }) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: fallback}) + return + } + + result, usage, err := h.callAlertRCA(c.Request.Context(), cfg, req, pack, fallback) + if err != nil { + fallback.Source = "rule-fallback: " + err.Error() + attachTrace(fallback, traceID, spanID) + h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{ + TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis", + Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider, + Model: cfg.Model, Source: "rule-fallback", Status: "fallback", Pack: pack, Result: fallback, + Duration: time.Since(start).Milliseconds(), Error: err.Error(), + }) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: fallback}) + return + } + attachTrace(result, traceID, spanID) + h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{ + TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis", + Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider, + Model: result.Model, Source: result.Source, Status: "success", Pack: pack, Result: result, + PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens, + Duration: time.Since(start).Milliseconds(), + }) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: result}) +} + +func (h *AIOpsAlertAnalysisHandler) callAlertRCA(ctx context.Context, cfg model.LLMConfig, req AlertAnalysisRequest, pack *aiopscontext.ContextPack, fallback *AlertAnalysisResponse) (*AlertAnalysisResponse, llm.Usage, error) { + packJSON, _ := json.MarshalIndent(pack, "", " ") + prompt := fmt.Sprintf(`你是 AIOps 平台的企业级告警 RCA 分析器。请只基于【告警基本信息】和【AIOps Context Pack】做判断,不要编造不存在的数据。 + +【告警基本信息】 +- ID: %d +- 名称: %s +- 级别: %s +- 目标: %s +- 指标: %s +- 当前值: %s +- 阈值: %s +- 持续: %s +- 描述: %s + +【AIOps Context Pack】 +%s + +请返回严格 JSON,不要 markdown,不要代码块,字段如下: +{ + "root_cause": "根因分析,2-4句,必须引用证据", + "impact": "影响范围,1-2句", + "recommendations": ["建议1", "建议2", "建议3"], + "auto_fix_script": "仅限只读诊断脚本;没有则为空字符串", + "similar_cases": 0, + "confidence": 0-100 +}`, req.AlertID, req.Name, req.Severity, req.Target, req.Metric, req.CurrentValue, req.Threshold, req.Duration, req.Description, string(packJSON)) + + resp, err := buildLLMClientFromConfig(cfg).Complete(ctx, &llm.CompletionRequest{ + Messages: []llm.Message{ + {Role: "system", Content: "你是 AIOps 平台的告警根因分析专家,输出必须是可解析 JSON。"}, + {Role: "user", Content: prompt}, + }, + Temperature: 0.2, + MaxTokens: cfg.MaxTokens, + }) + if err != nil { + return nil, llm.Usage{}, err + } + parsed := *fallback + if err := parseAlertAnalysisJSON(resp.Message.Content, &parsed); err != nil { + parsed.RootCause = strings.TrimSpace(resp.Message.Content) + } + parsed.ContextPack = pack + parsed.Evidence = pack.Evidence + parsed.Model = cfg.Model + parsed.Source = "llm" + parsed.GeneratedAt = time.Now() + return &parsed, resp.Usage, nil +} + +// ListAnalysisTraces 查询 AI 分析审计记录,普通用户只看自己的记录。 // Logan.Li +func (h *AIOpsAlertAnalysisHandler) ListAnalysisTraces(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + q := h.db.Model(&model.AIAnalysisTrace{}) + if !middleware.GetIsAdmin(c) { + q = q.Where("user_id = ?", middleware.GetUserID(c)) + } + if feature := strings.TrimSpace(c.Query("feature")); feature != "" { + q = q.Where("feature = ?", feature) + } + if target := strings.TrimSpace(c.Query("target")); target != "" { + q = q.Where("target LIKE ?", "%"+target+"%") + } + if status := strings.TrimSpace(c.Query("status")); status != "" { + q = q.Where("status = ?", status) + } + + var total int64 + q.Count(&total) + var rows []model.AIAnalysisTrace + if err := q.Select("id, trace_id, span_id, parent_span_id, user_id, feature, target, alert_id, provider, model, source, status, evidence_count, prompt_tokens, completion_tokens, total_tokens, duration, error, created_at, updated_at"). + Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil { + c.JSON(http.StatusOK, model.Response{Code: 500, Message: "查询失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": rows, "total": total}}) +} + +// GetAnalysisTrace 按 trace_id 回放某次 AI 分析看到的上下文包和输出结论。 // Logan.Li +func (h *AIOpsAlertAnalysisHandler) GetAnalysisTrace(c *gin.Context) { + traceID := strings.TrimSpace(c.Param("trace_id")) + q := h.db.Where("trace_id = ?", traceID) + if !middleware.GetIsAdmin(c) { + q = q.Where("user_id = ?", middleware.GetUserID(c)) + } + var row model.AIAnalysisTrace + if err := q.Order("id ASC").First(&row).Error; err != nil { + c.JSON(http.StatusOK, model.Response{Code: 404, Message: "分析记录不存在"}) + return + } + + c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ + "trace": row, + "context_pack": decodeJSONField(row.ContextPack), + "result": decodeJSONField(row.Result), + }}) +} + +func parseAlertAnalysisJSON(raw string, out *AlertAnalysisResponse) error { + clean := strings.TrimSpace(raw) + clean = strings.TrimPrefix(clean, "```json") + clean = strings.TrimPrefix(clean, "```") + clean = strings.TrimSuffix(clean, "```") + clean = strings.TrimSpace(clean) + var tmp struct { + RootCause string `json:"root_cause"` + Impact string `json:"impact"` + Recommendations []string `json:"recommendations"` + AutoFixScript string `json:"auto_fix_script"` + SimilarCases int `json:"similar_cases"` + Confidence int `json:"confidence"` + } + if err := json.Unmarshal([]byte(clean), &tmp); err != nil { + return err + } + if strings.TrimSpace(tmp.RootCause) != "" { + out.RootCause = tmp.RootCause + } + if strings.TrimSpace(tmp.Impact) != "" { + out.Impact = tmp.Impact + } + if len(tmp.Recommendations) > 0 { + out.Recommendations = tmp.Recommendations + } + out.AutoFixScript = tmp.AutoFixScript + out.SimilarCases = tmp.SimilarCases + if tmp.Confidence > 0 { + if tmp.Confidence > 100 { + tmp.Confidence = 100 + } + out.Confidence = tmp.Confidence + } + return nil +} + +func fallbackAlertAnalysis(req AlertAnalysisRequest, pack *aiopscontext.ContextPack) *AlertAnalysisResponse { + evidenceText := "暂无足够平台证据" + if len(pack.Evidence) > 0 { + evidenceText = pack.Evidence[0].Summary + } + target := firstText(req.Target, pack.Scope, "未知目标") + metric := firstText(req.Metric, "unknown") + recs := []string{ + fmt.Sprintf("先查看 %s 的近期指标趋势和告警时间线,确认是否持续异常", target), + "结合变更、发布、定时任务和日志错误进一步缩小根因范围", + "修复动作前先执行只读诊断,涉及重启、清理、扩容等动作需走确认或审批", + } + return &AlertAnalysisResponse{ + RootCause: fmt.Sprintf("告警 %s 在目标 %s 上触发,指标 %s 当前值 %s,阈值 %s。基于当前上下文,最直接证据是:%s。", + firstText(req.Name, "未命名告警"), target, metric, req.CurrentValue, req.Threshold, evidenceText), + Impact: fmt.Sprintf("该告警级别为 %s,可能影响目标 %s 的稳定性,需要结合业务等级判断优先级。", firstText(req.Severity, "unknown"), target), + Recommendations: recs, + SimilarCases: 0, + Confidence: fallbackConfidence(pack), + Evidence: pack.Evidence, + ContextPack: pack, + Model: "rule-fallback", + Source: "rule-fallback", + GeneratedAt: time.Now(), + } +} + +func firstText(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func metricsForAlert(metric string) []string { + m := strings.ToLower(metric) + switch { + case strings.Contains(m, "mem"): + return []string{"mem_usage", "load1"} + case strings.Contains(m, "load"): + return []string{"load1", "load5", "cpu_usage"} + case strings.Contains(m, "io"): + return []string{"cpu_iowait", "load1"} + default: + return []string{"cpu_usage", "mem_usage", "load1"} + } +} + +func fallbackConfidence(pack *aiopscontext.ContextPack) int { + score := 45 + if len(pack.Alerts) > 0 { + score += 10 + } + if len(pack.Metrics) > 0 { + score += 15 + } + if len(pack.Assets) > 0 { + score += 5 + } + if score > 80 { + score = 80 + } + return score +} + +type analysisTraceInput struct { + TraceID string + SpanID string + ParentSpanID string + UserID uint + Feature string + Target string + AlertID uint + Provider string + Model string + Source string + Status string + Pack *aiopscontext.ContextPack + Result interface{} + PromptTokens int + CompletionTokens int + TotalTokens int + Duration int64 + Error string +} + +func attachTrace(resp *AlertAnalysisResponse, traceID, spanID string) { + if resp == nil { + return + } + resp.TraceID = traceID + resp.SpanID = spanID +} + +func (h *AIOpsAlertAnalysisHandler) saveAnalysisTrace(ctx context.Context, in analysisTraceInput) { + packJSON := marshalTraceJSON(in.Pack) + resultJSON := marshalTraceJSON(traceResultForStorage(in.Result)) + evidenceCount := 0 + if in.Pack != nil { + evidenceCount = len(in.Pack.Evidence) + } + row := model.AIAnalysisTrace{ + TraceID: in.TraceID, + SpanID: in.SpanID, + ParentSpanID: in.ParentSpanID, + UserID: in.UserID, + Feature: firstText(in.Feature, "unknown"), + Target: in.Target, + AlertID: in.AlertID, + Provider: in.Provider, + Model: in.Model, + Source: trimTraceText(in.Source, 64), + Status: firstText(in.Status, "unknown"), + ContextPack: packJSON, + Result: resultJSON, + EvidenceCount: evidenceCount, + PromptTokens: in.PromptTokens, + CompletionTokens: in.CompletionTokens, + TotalTokens: in.TotalTokens, + Duration: in.Duration, + Error: in.Error, + } + _ = h.db.WithContext(ctx).Create(&row).Error +} + +func marshalTraceJSON(v interface{}) string { + if v == nil { + return "" + } + raw, err := json.Marshal(v) + if err != nil { + return "" + } + return string(raw) +} + +func traceResultForStorage(v interface{}) interface{} { + switch r := v.(type) { + case *AlertAnalysisResponse: + if r == nil { + return nil + } + cp := *r + cp.ContextPack = nil + return cp + case AlertAnalysisResponse: + r.ContextPack = nil + return r + default: + return v + } +} + +func decodeJSONField(raw string) interface{} { + if strings.TrimSpace(raw) == "" { + return nil + } + var out interface{} + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return raw + } + return out +} + +func trimTraceText(s string, max int) string { + if max <= 0 { + return s + } + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max]) +} diff --git a/backend/controller/aiops_context.go b/backend/controller/aiops_context.go new file mode 100644 index 0000000..ce2d9e9 --- /dev/null +++ b/backend/controller/aiops_context.go @@ -0,0 +1,46 @@ +package controller + +import ( + "net/http" + + "ezops/model" + "ezops/pkg/aiopscontext" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// AIOpsContextHandler 提供 AI 上下文包接口,供智能助手、告警分析、预测分析和后续 MCP 复用。 // Logan.Li +type AIOpsContextHandler struct { + service *aiopscontext.Service +} + +// NewAIOpsContextHandler 创建 AI 上下文处理器。 // Logan.Li +func NewAIOpsContextHandler(db *gorm.DB) *AIOpsContextHandler { + return &AIOpsContextHandler{service: aiopscontext.NewService(db)} +} + +// Summary GET /api/v1/aiops/context/summary // Logan.Li +func (h *AIOpsContextHandler) Summary(c *gin.Context) { + data, err := h.service.Summary(c.Request.Context()) + if err != nil { + c.JSON(http.StatusOK, model.Response{Code: 500, Message: err.Error()}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Data: data}) +} + +// BuildPack POST /api/v1/aiops/context/pack // Logan.Li +func (h *AIOpsContextHandler) BuildPack(c *gin.Context) { + var req aiopscontext.ContextPackRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + data, err := h.service.BuildPack(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusOK, model.Response{Code: 500, Message: err.Error()}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Data: data}) +} diff --git a/backend/controller/audit_setting.go b/backend/controller/audit_setting.go index 7c6e3bc..8d35c78 100644 --- a/backend/controller/audit_setting.go +++ b/backend/controller/audit_setting.go @@ -30,10 +30,35 @@ func (h *AuditSettingsHandler) getOrCreate() (model.AuditSettings, error) { if err != gorm.ErrRecordNotFound { return s, err } - s = model.AuditSettings{RetentionDays: 180, RecordAll: true} + s = model.AuditSettings{ + RetentionDays: 180, + RecordAll: true, + SessionWatermarkEnabled: true, + SessionRecordEnabled: true, + ClipboardEnabled: true, + FileUploadEnabled: true, + FileDownloadEnabled: true, + } return s, h.db.Create(&s).Error } +func auditSettingsPayload(s model.AuditSettings) gin.H { + return gin.H{ + "retention_days": s.RetentionDays, + "record_all": s.RecordAll, + "session_watermark_enabled": s.SessionWatermarkEnabled, + "session_record_enabled": s.SessionRecordEnabled, + "clipboard_enabled": s.ClipboardEnabled, + "file_upload_enabled": s.FileUploadEnabled, + "file_download_enabled": s.FileDownloadEnabled, + "command_policy_enabled": s.CommandPolicyEnabled, + "approval_required": s.ApprovalRequired, + "last_cleanup_at": s.LastCleanupAt, + "last_cleanup_summary": s.LastCleanupSummary, + "allowed_retention_days": model.AllowedAuditRetentionDays, + } +} + // GetAuditSettings 获取审计策略 // Logan.Li // GET /api/v1/admin/audit-settings func (h *AuditSettingsHandler) GetAuditSettings(c *gin.Context) { @@ -45,13 +70,7 @@ func (h *AuditSettingsHandler) GetAuditSettings(c *gin.Context) { c.JSON(http.StatusOK, model.Response{ Code: 200, Message: "success", - Data: gin.H{ - "retention_days": s.RetentionDays, - "record_all": s.RecordAll, - "last_cleanup_at": s.LastCleanupAt, - "last_cleanup_summary": s.LastCleanupSummary, - "allowed_retention_days": model.AllowedAuditRetentionDays, - }, + Data: auditSettingsPayload(s), }) } @@ -59,8 +78,15 @@ func (h *AuditSettingsHandler) GetAuditSettings(c *gin.Context) { // PUT /api/v1/admin/audit-settings func (h *AuditSettingsHandler) ApplyAuditSettings(c *gin.Context) { var req struct { - RetentionDays int `json:"retention_days" binding:"required"` - RecordAll bool `json:"record_all"` + RetentionDays int `json:"retention_days" binding:"required"` + RecordAll bool `json:"record_all"` + SessionWatermarkEnabled bool `json:"session_watermark_enabled"` + SessionRecordEnabled bool `json:"session_record_enabled"` + ClipboardEnabled bool `json:"clipboard_enabled"` + FileUploadEnabled bool `json:"file_upload_enabled"` + FileDownloadEnabled bool `json:"file_download_enabled"` + CommandPolicyEnabled bool `json:"command_policy_enabled"` + ApprovalRequired bool `json:"approval_required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"}) @@ -82,6 +108,13 @@ func (h *AuditSettingsHandler) ApplyAuditSettings(c *gin.Context) { s.RetentionDays = req.RetentionDays s.RecordAll = req.RecordAll + s.SessionWatermarkEnabled = req.SessionWatermarkEnabled + s.SessionRecordEnabled = req.SessionRecordEnabled + s.ClipboardEnabled = req.ClipboardEnabled + s.FileUploadEnabled = req.FileUploadEnabled + s.FileDownloadEnabled = req.FileDownloadEnabled + s.CommandPolicyEnabled = req.CommandPolicyEnabled + s.ApprovalRequired = req.ApprovalRequired cleanResult, cleanErr := auditcleaner.Run(h.db, req.RetentionDays) now := time.Now() @@ -111,11 +144,18 @@ func (h *AuditSettingsHandler) ApplyAuditSettings(c *gin.Context) { Code: 200, Message: msg, Data: gin.H{ - "retention_days": s.RetentionDays, - "record_all": s.RecordAll, - "last_cleanup_at": s.LastCleanupAt, - "last_cleanup_summary": s.LastCleanupSummary, - "cleanup": cleanResult, + "retention_days": s.RetentionDays, + "record_all": s.RecordAll, + "session_watermark_enabled": s.SessionWatermarkEnabled, + "session_record_enabled": s.SessionRecordEnabled, + "clipboard_enabled": s.ClipboardEnabled, + "file_upload_enabled": s.FileUploadEnabled, + "file_download_enabled": s.FileDownloadEnabled, + "command_policy_enabled": s.CommandPolicyEnabled, + "approval_required": s.ApprovalRequired, + "last_cleanup_at": s.LastCleanupAt, + "last_cleanup_summary": s.LastCleanupSummary, + "cleanup": cleanResult, }, }) -} \ No newline at end of file +} diff --git a/backend/controller/automation_cron.go b/backend/controller/automation_cron.go index 1b5ad9e..3692050 100644 --- a/backend/controller/automation_cron.go +++ b/backend/controller/automation_cron.go @@ -291,7 +291,7 @@ func (h *AutomationHandler) ToggleCronJob(c *gin.Context) { // @Router /cron-jobs/{id}/execute [post] func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) - execution, err := RunCronJobByID(h.db, uint(id)) + execution, err := QueueCronJobByID(h.db, uint(id)) if err != nil { c.JSON(http.StatusNotFound, model.Response{ Code: 404, @@ -299,13 +299,9 @@ func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) { }) return } - msg := "执行成功" - if execution.Status == "failed" { - msg = "执行失败" - } c.JSON(http.StatusOK, model.Response{ Code: 200, - Message: msg, + Message: "已提交后台执行", Data: execution, }) } diff --git a/backend/controller/automation_script.go b/backend/controller/automation_script.go index ff3d63a..a8927df 100644 --- a/backend/controller/automation_script.go +++ b/backend/controller/automation_script.go @@ -299,45 +299,41 @@ func (h *AutomationHandler) ExecuteScript(c *gin.Context) { return } + go h.finishScriptExecution(script, execution.ID) + + c.JSON(http.StatusOK, model.Response{ + Code: 200, + Message: "已提交后台执行", + Data: execution, + }) +} + +// finishScriptExecution 在后台完成脚本执行,HTTP 请求只负责提交任务 // Logan.Li +func (h *AutomationHandler) finishScriptExecution(script model.Script, executionID uint) { rt := scriptrunner.DetectRuntime() - if ok, reason := scriptrunner.CanRun(rt, script.Type); !ok { - c.JSON(http.StatusBadRequest, model.Response{ - Code: 400, - Message: reason, - }) - return - } - - // 在 AIOps 本机执行(embed 单二进制 / Docker 容器内) - output, exitCode, duration := runLocal(script.Type, script.Content, script.Timeout) - finishedAt := time.Now() status := "success" - if exitCode != 0 { + output := "" + exitCode := 0 + duration := 0 + if ok, reason := scriptrunner.CanRun(rt, script.Type); !ok { status = "failed" + exitCode = -1 + output = reason + "\n" + } else { + output, exitCode, duration = runLocal(script.Type, script.Content, script.Timeout) + if exitCode != 0 { + status = "failed" + } } - - h.db.Model(&execution).Updates(map[string]interface{}{ + finishedAt := time.Now() + h.db.Model(&model.TaskExecution{}).Where("id = ?", executionID).Updates(map[string]interface{}{ "status": status, "output": output, "duration": duration, "finished_at": finishedAt, }) - h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1) - - execution.Output = output - execution.Status = status - execution.Duration = duration - execution.FinishedAt = &finishedAt - - msg := "执行成功" - if status == "failed" { - msg = "执行失败" - } - c.JSON(http.StatusOK, model.Response{ - Code: 200, - Message: msg, - Data: execution, - }) + h.db.Model(&model.Script{}).Where("id = ?", script.ID).UpdateColumn("run_count", script.RunCount+1) + _ = exitCode } // ListExecutions 获取任务执行记录列表 diff --git a/backend/controller/cron_job_run.go b/backend/controller/cron_job_run.go index 13b5ab9..8f80c8b 100644 --- a/backend/controller/cron_job_run.go +++ b/backend/controller/cron_job_run.go @@ -21,7 +21,49 @@ func RunCronJobByID(db *gorm.DB, id uint) (*model.TaskExecution, error) { return runCronJob(db, job) } +// QueueCronJobByID 提交一次手动执行,立即返回执行记录,后台继续执行 // Logan.Li +func QueueCronJobByID(db *gorm.DB, id uint) (*model.TaskExecution, error) { + var job model.CronJob + if err := db.First(&job, id).Error; err != nil { + return nil, fmt.Errorf("定时任务不存在") + } + execution, startedAt, err := createCronExecution(db, job) + if err != nil { + return nil, err + } + go finishCronExecution(db, job, execution.ID, startedAt) + return execution, nil +} + func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) { + execution, startedAt, err := createCronExecution(db, job) + if err != nil { + return nil, err + } + finishCronExecution(db, job, execution.ID, startedAt) + _ = db.First(execution, execution.ID).Error + return execution, nil +} + +func createCronExecution(db *gorm.DB, job model.CronJob) (*model.TaskExecution, time.Time, error) { + startedAt := time.Now() + execution := &model.TaskExecution{ + TaskType: "cron", + TaskID: job.ID, + TaskName: job.Name, + Status: "running", + TargetType: job.TargetType, + Targets: job.Targets, + StartedAt: &startedAt, + } + if err := db.Create(execution).Error; err != nil { + return nil, startedAt, fmt.Errorf("创建执行记录失败") + } + return execution, startedAt, nil +} + +// finishCronExecution 执行定时任务主体并写回执行结果 // Logan.Li +func finishCronExecution(db *gorm.DB, job model.CronJob, executionID uint, startedAt time.Time) { content := job.Content scriptType := job.Type if scriptType == "command" { @@ -36,20 +78,6 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) { } } - now := time.Now() - execution := model.TaskExecution{ - TaskType: "cron", - TaskID: job.ID, - TaskName: job.Name, - Status: "running", - TargetType: job.TargetType, - Targets: job.Targets, - StartedAt: &now, - } - if err := db.Create(&execution).Error; err != nil { - return nil, fmt.Errorf("创建执行记录失败") - } - var output string var exitCode int var duration int @@ -57,7 +85,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) { if job.TargetType != "" && job.TargetType != "local" && job.Targets != "" { remoteResults, remoteErr := ExecuteCronOnTargets(db, job, scriptType, content, job.Timeout) - duration = int(time.Since(now).Milliseconds()) + duration = int(time.Since(startedAt).Milliseconds()) if remoteErr != nil { output = fmt.Sprintf("远程执行调度失败: %v", remoteErr) exitCode = -1 @@ -77,7 +105,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) { if ok, reason := scriptrunner.CanRun(rt, scriptType); !ok { output = reason + "\n" exitCode = -1 - duration = int(time.Since(now).Milliseconds()) + duration = int(time.Since(startedAt).Milliseconds()) status = "failed" } else { output, exitCode, duration = runLocal(scriptType, content, job.Timeout) @@ -88,7 +116,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) { } finishedAt := time.Now() - db.Model(&execution).Updates(map[string]interface{}{ + db.Model(&model.TaskExecution{}).Where("id = ?", executionID).Updates(map[string]interface{}{ "status": status, "output": output, "duration": duration, @@ -96,7 +124,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) { }) statsUpdates := map[string]interface{}{ - "last_run_at": now, + "last_run_at": startedAt, "last_status": status, "run_count": job.RunCount + 1, } @@ -106,11 +134,5 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) { statsUpdates["fail_count"] = job.FailCount + 1 } db.Model(&job).Updates(statsUpdates) - - execution.Output = output - execution.Status = status - execution.Duration = duration - execution.FinishedAt = &finishedAt _ = exitCode - return &execution, nil -} \ No newline at end of file +} diff --git a/backend/controller/k8s_cluster_stats.go b/backend/controller/k8s_cluster_stats.go index 568850f..e7cc2de 100644 --- a/backend/controller/k8s_cluster_stats.go +++ b/backend/controller/k8s_cluster_stats.go @@ -1,4 +1,4 @@ -// K8s 集群列表统计增强 — 实时拉取资源数量,修复 Ingress 等与详情页不一致 // Logan.Li +// K8s 集群列表统计增强 — 列表页只读本地快照,避免不可达集群阻塞全站 // Logan.Li package controller import ( @@ -7,28 +7,12 @@ import ( "ezops/model" ) -// enrichClustersForList 列表返回前刷新资源计数与节点均值使用率 // Logan.Li +// enrichClustersForList 列表返回前补齐本地资源计数与节点均值使用率 // Logan.Li func (h *KubernetesHandler) enrichClustersForList(clusters []model.KubernetesCluster) { for i := range clusters { c := &clusters[i] h.fillClusterCountsFromDB(c) - h.refreshClusterCountsLive(c) h.fillClusterUsageFromNodes(c) - // 异步落库,避免列表阻塞过久 - snap := *c - go func(cluster model.KubernetesCluster) { - if err := h.db.Model(&model.KubernetesCluster{}).Where("id = ?", cluster.ID).Updates(map[string]interface{}{ - "node_count": cluster.NodeCount, - "pod_count": cluster.PodCount, - "service_count": cluster.ServiceCount, - "deployment_count": cluster.DeploymentCount, - "ingress_count": cluster.IngressCount, - "cpu_usage": cluster.CPUUsage, - "memory_usage": cluster.MemoryUsage, - }).Error; err != nil { - log.Printf("[K8s] enrich cluster %d stats persist error: %v", cluster.ID, err) - } - }(snap) } } @@ -106,4 +90,4 @@ func (h *KubernetesHandler) updateClusterUsageAfterSync(clusterID uint) { "memory_usage": cluster.MemoryUsage, }) } -} \ No newline at end of file +} diff --git a/backend/controller/rdp.go b/backend/controller/rdp.go index 51f7f7b..b493793 100644 --- a/backend/controller/rdp.go +++ b/backend/controller/rdp.go @@ -7,6 +7,7 @@ import ( "encoding/binary" "errors" "image" + "image/draw" "image/png" "log" "net/http" @@ -37,8 +38,52 @@ type RDPHandler struct { } const rdpAuditMaxText = 500 -const rdpMaxWidth = 1024 -const rdpMaxHeight = 800 + +type sessionSecurityPolicy struct { + ClipboardEnabled bool + FileUploadEnabled bool + FileDownloadEnabled bool + SessionRecordEnabled bool +} + +func loadSessionSecurityPolicy(db *gorm.DB) sessionSecurityPolicy { + policy := sessionSecurityPolicy{ + ClipboardEnabled: true, + FileUploadEnabled: true, + FileDownloadEnabled: true, + SessionRecordEnabled: true, + } + var settings model.AuditSettings + if err := db.First(&settings).Error; err != nil { + return policy + } + policy.ClipboardEnabled = settings.ClipboardEnabled + policy.FileUploadEnabled = settings.FileUploadEnabled + policy.FileDownloadEnabled = settings.FileDownloadEnabled + policy.SessionRecordEnabled = settings.SessionRecordEnabled + return policy +} + +func rdpEventBlockedByPolicy(event rdpengine.ClientEvent, policy sessionSecurityPolicy) (bool, string) { + switch event.Type { + case "clipboard", "remote_copy": + if !policy.ClipboardEnabled { + return true, "会话策略已禁用剪贴板" + } + case "file_upload", "rdp_file_upload": + if !policy.FileUploadEnabled { + return true, "会话策略已禁用文件上传" + } + case "file_download": + if !policy.FileDownloadEnabled { + return true, "会话策略已禁用文件下载" + } + } + return false, "" +} + +const rdpMaxWidth = 1920 +const rdpMaxHeight = 1200 func NewRDPHandler(db *gorm.DB, masterKey string, wsMgr *wshub.WSManager) *RDPHandler { return &RDPHandler{ @@ -138,6 +183,7 @@ func (h *RDPHandler) Serve(c *gin.Context) { userID := middleware.GetUserID(c) username := middleware.GetUsername(c) + policy := loadSessionSecurityPolicy(h.db) conn, info, err := h.wsMgr.Upgrade(c, "rdp", userID, username) if err != nil { return @@ -183,7 +229,7 @@ func (h *RDPHandler) Serve(c *gin.Context) { }() events := make(chan rdpengine.ClientEvent, 128) - sink := &wsFrameSink{conn: conn, mgr: h.wsMgr, recorder: rdpengine.NewMemoryRecorder(500), live: liveHub, sessionID: session.ID} + sink := &wsFrameSink{conn: conn, mgr: h.wsMgr, recorder: rdpengine.NewMemoryRecorder(500), live: liveHub, sessionID: session.ID, recordW: width, recordH: height} _ = sink.SendFrame(rdpengine.ServerFrame{Type: "session", Message: strconv.Itoa(int(session.ID)), Width: width, Height: height}) engineDone := make(chan error, 1) go func() { @@ -206,7 +252,10 @@ func (h *RDPHandler) Serve(c *gin.Context) { cancel() end := time.Now() duration := int(end.Sub(session.StartTime).Seconds()) - recordData := sink.recorder.JSON() + recordData := "" + if policy.SessionRecordEnabled { + recordData = sink.recorder.JSON() + } updates := map[string]interface{}{ "status": status, "duration": duration, @@ -224,6 +273,7 @@ func (h *RDPHandler) Serve(c *gin.Context) { defer audit.Flush() readDone := make(chan struct{}) + modifiers := &rdpModifierState{} go func() { defer close(readDone) for { @@ -231,6 +281,11 @@ func (h *RDPHandler) Serve(c *gin.Context) { if err := conn.ReadJSON(&event); err != nil { return } + if blocked, message := rdpEventBlockedByPolicy(event, policy); blocked { + _ = sink.SendFrame(rdpengine.ServerFrame{Type: "status", Message: message}) + continue + } + modifiers.Apply(&event) audit.Record(event) enqueueRDPEvent(events, event) } @@ -286,6 +341,38 @@ func enqueueRDPEvent(events chan rdpengine.ClientEvent, event rdpengine.ClientEv } } +type rdpModifierState struct { + ctrl bool + alt bool + shift bool + meta bool +} + +// Apply 在服务端补齐修饰键状态,兼容旧前端未传 ctrlKey/altKey 的 RDP 按键事件。 // Logan.Li +func (s *rdpModifierState) Apply(event *rdpengine.ClientEvent) { + if event.Type != "key" { + return + } + event.Ctrl = event.Ctrl || s.ctrl + event.Alt = event.Alt || s.alt + event.Shift = event.Shift || s.shift + event.Meta = event.Meta || s.meta + if event.Meta { + event.Ctrl = true + event.Meta = false + } + switch event.Code { + case "ControlLeft", "ControlRight": + s.ctrl = event.Down + case "AltLeft", "AltRight": + s.alt = event.Down + case "ShiftLeft", "ShiftRight": + s.shift = event.Down + case "MetaLeft", "MetaRight": + s.meta = event.Down + } +} + type rdpOperationRecorder struct { db *gorm.DB session model.RDPSession @@ -312,9 +399,15 @@ func (r *rdpOperationRecorder) Record(event rdpengine.ClientEvent) { case "clipboard": r.Flush() r.create("[RDP粘贴] " + truncateAuditText(event.Text, rdpAuditMaxText)) + case "remote_copy": + r.Flush() + r.create("[RDP复制] Ctrl+C") case "file_upload": r.Flush() r.create("[RDP上传] " + truncateAuditText(event.Text, rdpAuditMaxText)) + case "rdp_file_upload": + r.Flush() + r.create("[RDP上传到虚拟盘] " + truncateAuditText(event.Name, rdpAuditMaxText)) case "file_download": r.Flush() r.create("[RDP下载] " + truncateAuditText(event.Text, rdpAuditMaxText)) @@ -328,6 +421,15 @@ func (r *rdpOperationRecorder) recordKey(event rdpengine.ClientEvent, now time.T if event.Key == "Control" || event.Key == "Shift" || event.Key == "Alt" || event.Key == "Meta" { return } + if event.Ctrl || event.Alt || event.Meta { + r.Flush() + if label, ok := rdpShortcutAuditLabel(event); ok { + r.create(label) + return + } + r.create("[RDP按键] " + rdpModifierLabel(event) + strings.ToUpper(event.Key)) + return + } if event.Code == "Enter" || event.Code == "NumpadEnter" { if strings.TrimSpace(r.inputBuffer) != "" { r.Flush() @@ -370,6 +472,42 @@ func isAuditableRDPKey(key string) bool { return false } +func rdpModifierLabel(event rdpengine.ClientEvent) string { + parts := make([]string, 0, 3) + if event.Ctrl { + parts = append(parts, "Ctrl") + } + if event.Alt { + parts = append(parts, "Alt") + } + if event.Meta { + parts = append(parts, "Meta") + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, "+") + "+" +} + +func rdpShortcutAuditLabel(event rdpengine.ClientEvent) (string, bool) { + if !event.Ctrl || event.Alt || event.Meta { + return "", false + } + key := strings.ToUpper(event.Key) + switch key { + case "A": + return "[RDP全选] Ctrl+A", true + case "C": + return "[RDP复制] Ctrl+C", true + case "V": + return "[RDP粘贴快捷键] Ctrl+V", true + case "X": + return "[RDP剪切] Ctrl+X", true + default: + return "", false + } +} + func (r *rdpOperationRecorder) Flush() { if strings.TrimSpace(r.inputBuffer) == "" { r.inputBuffer = "" @@ -423,10 +561,16 @@ type wsFrameSink struct { bitmaps int lastLog time.Time lastRecord time.Time + recordFB *image.RGBA + recordW int + recordH int + seenScreen bool } func (s *wsFrameSink) SendFrame(frame rdpengine.ServerFrame) error { - s.recorder.Append(frame) + if frame.Type != "bitmap" { + s.recorder.Append(frame) + } if s.live != nil { s.live.publish(frame) } @@ -451,20 +595,64 @@ func (s *wsFrameSink) snapshotFrame(frame rdpengine.ServerFrame) (rdpengine.Serv if frame.Type != "bitmap" || len(frame.RGBA) == 0 || frame.Width <= 0 || frame.Height <= 0 { return rdpengine.ServerFrame{}, false } + s.mu.Lock() + defer s.mu.Unlock() + if s.recordW <= 0 || s.recordH <= 0 { + s.recordW = frame.Width + frame.X + s.recordH = frame.Height + frame.Y + if s.recordW < 1 || s.recordH < 1 { + return rdpengine.ServerFrame{}, false + } + } + if s.recordFB == nil { + s.recordFB = image.NewRGBA(image.Rect(0, 0, s.recordW, s.recordH)) + } + rect := image.Rect(frame.X, frame.Y, frame.X+frame.Width, frame.Y+frame.Height).Intersect(s.recordFB.Bounds()) + if rect.Empty() { + return rdpengine.ServerFrame{}, false + } + src := image.NewRGBA(image.Rect(0, 0, frame.Width, frame.Height)) + copy(src.Pix, frame.RGBA) + draw.Draw(s.recordFB, rect, src, image.Point{X: rect.Min.X - frame.X, Y: rect.Min.Y - frame.Y}, draw.Src) + if !s.seenScreen { + if isMostlyBlackRGBA(s.recordFB, 0.985) { + return rdpengine.ServerFrame{}, false + } + s.seenScreen = true + } now := time.Now() if !s.lastRecord.IsZero() && now.Sub(s.lastRecord) < 500*time.Millisecond { return rdpengine.ServerFrame{}, false } s.lastRecord = now - img := image.NewRGBA(image.Rect(0, 0, frame.Width, frame.Height)) - copy(img.Pix, frame.RGBA) var buf bytes.Buffer - if err := png.Encode(&buf, img); err != nil { + if err := png.Encode(&buf, s.recordFB); err != nil { return rdpengine.ServerFrame{}, false } - frame.RGBA = nil - frame.Image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) - return frame, true + return rdpengine.ServerFrame{ + Type: "bitmap", + X: 0, + Y: 0, + Width: s.recordW, + Height: s.recordH, + Image: "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), + }, true +} + +// isMostlyBlackRGBA 判断录屏缓存是否仍是启动期黑屏,避免历史回放堆满无效黑帧。 // Logan.Li +func isMostlyBlackRGBA(img *image.RGBA, threshold float64) bool { + if img == nil || len(img.Pix) == 0 { + return true + } + total := 0 + black := 0 + for i := 0; i+3 < len(img.Pix); i += 4 { + total++ + if img.Pix[i] <= 4 && img.Pix[i+1] <= 4 && img.Pix[i+2] <= 4 { + black++ + } + } + return total == 0 || float64(black)/float64(total) >= threshold } func writeRDPFrame(mgr *wshub.WSManager, conn *websocket.Conn, frame rdpengine.ServerFrame) error { diff --git a/backend/controller/rdp_file.go b/backend/controller/rdp_file.go deleted file mode 100644 index a6478d6..0000000 --- a/backend/controller/rdp_file.go +++ /dev/null @@ -1,242 +0,0 @@ -package controller - -import ( - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" - - "ezops/model" - "ezops/pkg/crypto" - "ezops/pkg/rdpfilestage" - "ezops/pkg/winsmb" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -// RDPFileHandler 通过 SMB 管理共享实现 RDP 虚拟 Download 盘文件传输 // Logan.Li -type RDPFileHandler struct { - db *gorm.DB - crypt *crypto.SecretBox -} - -func NewRDPFileHandler(db *gorm.DB, masterKey string) *RDPFileHandler { - return &RDPFileHandler{db: db, crypt: crypto.NewSecretBox(masterKey)} -} - -func (h *RDPFileHandler) loadAuth(serverID uint) (winsmb.Auth, error) { - var server model.Server - if err := h.db.First(&server, serverID).Error; err != nil { - return winsmb.Auth{}, err - } - normalizeServerRemoteAccess(&server) - if !isRDPServer(server) { - return winsmb.Auth{}, errors.New("该资产未配置为 RDP/Windows") - } - cred, err := loadRDPCredential(h.crypt, server) - if err != nil { - return winsmb.Auth{}, err - } - return winsmb.Auth{ - Host: server.IP, - Domain: cred.Domain, - Username: cred.Username, - Password: cred.Password, - }, nil -} - -func (h *RDPFileHandler) ListFiles(c *gin.Context) { - serverID, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"}) - return - } - auth, err := h.loadAuth(uint(serverID)) - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) - return - } - entries, err := winsmb.ListDownload(auth) - if err != nil { - c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()}) - return - } - type item struct { - Name string `json:"name"` - Size int64 `json:"size"` - IsDir bool `json:"is_dir"` - ModTime string `json:"mod_time"` - } - list := make([]item, 0, len(entries)) - for _, ent := range entries { - if ent.IsDir { - continue - } - list = append(list, item{ - Name: ent.Name, - Size: ent.Size, - IsDir: ent.IsDir, - ModTime: ent.ModTime.Format(time.RFC3339), - }) - } - c.JSON(http.StatusOK, model.Response{ - Code: 200, - Data: gin.H{ - "root": winsmb.DownloadFullPath, - "list": list, - "total": len(list), - }, - }) -} - -func (h *RDPFileHandler) UploadFile(c *gin.Context) { - serverID, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"}) - return - } - fileHeader, err := c.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择文件"}) - return - } - if fileHeader.Size > 100<<20 { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "单文件不能超过 100MB"}) - return - } - f, err := fileHeader.Open() - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "读取上传文件失败"}) - return - } - defer f.Close() - data, err := io.ReadAll(f) - if err != nil { - c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取上传文件失败"}) - return - } - auth, err := h.loadAuth(uint(serverID)) - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) - return - } - if err := winsmb.Upload(auth, fileHeader.Filename, data); err != nil { - c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()}) - return - } - c.JSON(http.StatusOK, model.Response{ - Code: 200, - Message: "上传成功", - Data: gin.H{ - "name": fileHeader.Filename, - "path": winsmb.DownloadFullPath + `\` + fileHeader.Filename, - "size": fileHeader.Size, - }, - }) -} - -func (h *RDPFileHandler) DownloadFile(c *gin.Context) { - serverID, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"}) - return - } - name := c.Query("name") - if name == "" { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少文件名"}) - return - } - auth, err := h.loadAuth(uint(serverID)) - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) - return - } - data, err := winsmb.Download(auth, name) - if err != nil { - c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()}) - return - } - c.Header("Content-Disposition", "attachment; filename=\""+name+"\"") - c.Data(http.StatusOK, "application/octet-stream", data) -} - -// StageUpload SMB 不可用时:文件暂存 AIOps,返回可由 Windows 拉取的一次性 URL // Logan.Li -func (h *RDPFileHandler) StageUpload(c *gin.Context) { - serverID, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"}) - return - } - fileHeader, err := c.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择文件"}) - return - } - if fileHeader.Size > 50<<20 { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "回退模式单文件不能超过 50MB"}) - return - } - f, err := fileHeader.Open() - if err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "读取上传文件失败"}) - return - } - defer f.Close() - data, err := io.ReadAll(f) - if err != nil { - c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取上传文件失败"}) - return - } - if _, err := h.loadAuth(uint(serverID)); err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) - return - } - token := rdpfilestage.Put(fileHeader.Filename, data, 30*time.Minute) - base := strings.TrimRight(publicHTTPBase(c), "/") - downloadURL := fmt.Sprintf("%s/api/v1/rdp-staged/%s", base, token) - remotePath := winsmb.DownloadFullPath + `\` + fileHeader.Filename - deployCmd := fmt.Sprintf( - `powershell -NoProfile -WindowStyle Hidden -Command "$d='%s';if(!(Test-Path $d)){ni -ItemType Directory -Path $d -Force|Out-Null};iwr -UseBasicParsing -Uri '%s' -OutFile (Join-Path $d '%s')"`, - strings.ReplaceAll(winsmb.DownloadFullPath, `'`, `''`), - downloadURL, - strings.ReplaceAll(fileHeader.Filename, `'`, `''`), - ) - c.JSON(http.StatusOK, model.Response{ - Code: 200, - Data: gin.H{ - "token": token, - "download_url": downloadURL, - "remote_path": remotePath, - "deploy_command": deployCmd, - "mode": "stage", - }, - }) -} - -// ServeStaged 供 Windows 主机拉取暂存文件(token 即密钥)// Logan.Li -func (h *RDPFileHandler) ServeStaged(c *gin.Context) { - token := c.Param("token") - item, ok := rdpfilestage.Get(token) - if !ok { - c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "链接已过期或不存在"}) - return - } - c.Header("Content-Disposition", "attachment; filename=\""+item.Name+"\"") - c.Data(http.StatusOK, "application/octet-stream", item.Data) -} - -func publicHTTPBase(c *gin.Context) string { - scheme := "http" - if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") { - scheme = "https" - } - host := c.Request.Host - if fwd := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); fwd != "" { - host = strings.Split(fwd, ",")[0] - } - return scheme + "://" + host -} \ No newline at end of file diff --git a/backend/controller/rdp_staged.go b/backend/controller/rdp_staged.go new file mode 100644 index 0000000..97954d0 --- /dev/null +++ b/backend/controller/rdp_staged.go @@ -0,0 +1,22 @@ +package controller + +import ( + "net/http" + + "ezops/model" + "ezops/pkg/rdp/filestage" + + "github.com/gin-gonic/gin" +) + +// ServeRDPStaged 供浏览器下载 RDPDR Download 目录捕获的暂存文件。// Logan.Li +func ServeRDPStaged(c *gin.Context) { + token := c.Param("token") + item, ok := filestage.Get(token) + if !ok { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "链接已过期或不存在"}) + return + } + c.Header("Content-Disposition", "attachment; filename=\""+item.Name+"\"") + c.Data(http.StatusOK, "application/octet-stream", item.Data) +} diff --git a/backend/embed.go b/backend/embed.go index a5edbf6..83b97d1 100644 --- a/backend/embed.go +++ b/backend/embed.go @@ -57,7 +57,13 @@ func setupFrontend(r *gin.Engine) { f, err := staticFS.Open(cleanPath) if err == nil { f.Close() - c.Header("Cache-Control", "public, max-age=31536000") + if cleanPath == "index.html" || strings.HasSuffix(cleanPath, ".html") { + // HTML 入口必须每次校验,避免浏览器长期执行旧版前端 bundle。// Logan.Li + c.Header("Cache-Control", "no-store, no-cache, must-revalidate") + } else { + // 当前内嵌构建在本地验证阶段需要强制校验,避免旧 bundle 残留。// Logan.Li + c.Header("Cache-Control", "no-cache") + } c.FileFromFS(path, httpFS) return } @@ -68,6 +74,7 @@ func setupFrontend(r *gin.Engine) { c.String(http.StatusInternalServerError, "前端文件缺失") return } + c.Header("Cache-Control", "no-store, no-cache, must-revalidate") c.Data(http.StatusOK, "text/html; charset=utf-8", indexBytes) }) } diff --git a/backend/go.mod b/backend/go.mod index de42129..d9335e0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,7 +12,6 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/gosnmp/gosnmp v1.35.0 - github.com/hirochachacha/go-smb2 v1.1.0 github.com/mattn/go-sqlite3 v1.14.47 github.com/nalgeon/redka v0.2.0 github.com/pkg/sftp v1.13.10 diff --git a/backend/go.sum b/backend/go.sum index 7f8333f..af6cb6b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -78,8 +78,6 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI= -github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= diff --git a/backend/model/ai_analysis_trace.go b/backend/model/ai_analysis_trace.go new file mode 100644 index 0000000..dd495b2 --- /dev/null +++ b/backend/model/ai_analysis_trace.go @@ -0,0 +1,32 @@ +package model + +import "time" + +// AIAnalysisTrace 记录每次 AI 分析看到的上下文、模型输出和审计追踪信息。 // Logan.Li +type AIAnalysisTrace struct { + ID uint `gorm:"primarykey" json:"id"` + TraceID string `gorm:"size:64;index;not null" json:"trace_id"` + SpanID string `gorm:"size:64;index;not null" json:"span_id"` + ParentSpanID string `gorm:"size:64;index" json:"parent_span_id"` + UserID uint `gorm:"index" json:"user_id"` + Feature string `gorm:"size:64;index;not null" json:"feature"` + Target string `gorm:"size:255;index" json:"target"` + AlertID uint `gorm:"index" json:"alert_id"` + Provider string `gorm:"size:50" json:"provider"` + Model string `gorm:"size:100" json:"model"` + Source string `gorm:"size:64" json:"source"` + Status string `gorm:"size:32;index" json:"status"` + ContextPack string `gorm:"type:text" json:"context_pack"` + Result string `gorm:"type:text" json:"result"` + EvidenceCount int `json:"evidence_count"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + Duration int64 `json:"duration"` + Error string `gorm:"type:text" json:"error,omitempty"` + CreatedAt time.Time `gorm:"index" json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName 表名 // Logan.Li +func (AIAnalysisTrace) TableName() string { return "ai_analysis_traces" } diff --git a/backend/model/audit_setting.go b/backend/model/audit_setting.go index 04f5576..7b2866c 100644 --- a/backend/model/audit_setting.go +++ b/backend/model/audit_setting.go @@ -4,13 +4,20 @@ import "time" // AuditSettings 审计策略配置(单行) // Logan.Li type AuditSettings struct { - ID uint `gorm:"primarykey" json:"id"` - RetentionDays int `gorm:"default:180;not null" json:"retention_days"` - RecordAll bool `gorm:"default:true" json:"record_all"` - LastCleanupAt *time.Time `json:"last_cleanup_at,omitempty"` - LastCleanupSummary string `gorm:"size:500" json:"last_cleanup_summary,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + RetentionDays int `gorm:"default:180;not null" json:"retention_days"` + RecordAll bool `gorm:"default:true" json:"record_all"` + SessionWatermarkEnabled bool `gorm:"default:true;not null" json:"session_watermark_enabled"` + SessionRecordEnabled bool `gorm:"default:true;not null" json:"session_record_enabled"` + ClipboardEnabled bool `gorm:"default:true;not null" json:"clipboard_enabled"` + FileUploadEnabled bool `gorm:"default:true;not null" json:"file_upload_enabled"` + FileDownloadEnabled bool `gorm:"default:true;not null" json:"file_download_enabled"` + CommandPolicyEnabled bool `gorm:"default:false;not null" json:"command_policy_enabled"` + ApprovalRequired bool `gorm:"default:false;not null" json:"approval_required"` + LastCleanupAt *time.Time `json:"last_cleanup_at,omitempty"` + LastCleanupSummary string `gorm:"size:500" json:"last_cleanup_summary,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (AuditSettings) TableName() string { return "sys_audit_settings" } @@ -26,4 +33,4 @@ func IsValidRetentionDays(days int) bool { } } return false -} \ No newline at end of file +} diff --git a/backend/pkg/agentcli/agentcli.go b/backend/pkg/agentcli/agentcli.go index c3c8511..3b60d74 100644 --- a/backend/pkg/agentcli/agentcli.go +++ b/backend/pkg/agentcli/agentcli.go @@ -57,6 +57,7 @@ type EngineConfig struct { SafetyMode safety.Mode // 安全模式(默认 balanced) Core *core.Config // ReAct 配置(nil 用默认) MemoryDir string // Evolver 记忆目录(空则 data/agentcli) + ExtraTools []tools.Tool // 平台注入工具,如 AIOps CMDB/告警/指标查询 // Logan.Li } // Engine Agent CLI 引擎实例(每个 WebSocket 连接 / 任务一个) // Logan.Li @@ -106,6 +107,11 @@ func NewEngine(cfg EngineConfig) (*Engine, error) { } registry.Register(sshTool) } + for _, tool := range cfg.ExtraTools { + if tool != nil { + registry.Register(tool) + } + } registry.Register(tools.NewAnalyzeOutputTool()) // 安全控制器 diff --git a/backend/pkg/agentcli/tools/aiops_context.go b/backend/pkg/agentcli/tools/aiops_context.go new file mode 100644 index 0000000..526c8c0 --- /dev/null +++ b/backend/pkg/agentcli/tools/aiops_context.go @@ -0,0 +1,135 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" +) + +// AIOpsContextProvider 是 Agent CLI 调用平台上下文的最小接口,具体查库实现在 pkg/aiopscontext。 // Logan.Li +type AIOpsContextProvider interface { + SearchAssets(ctx context.Context, keyword string, limit int) (interface{}, error) + ListAlerts(ctx context.Context, target, severity, status, timeRange string, limit int) (interface{}, error) + MetricSummary(ctx context.Context, target, metric, timeRange string, limit int) (interface{}, error) +} + +// NewAIOpsAssetSearchTool 创建资产搜索工具。 // Logan.Li +func NewAIOpsAssetSearchTool(provider AIOpsContextProvider) Tool { + return &aiopsAssetSearchTool{provider: provider} +} + +// NewAIOpsAlertListTool 创建告警查询工具。 // Logan.Li +func NewAIOpsAlertListTool(provider AIOpsContextProvider) Tool { + return &aiopsAlertListTool{provider: provider} +} + +// NewAIOpsMetricSummaryTool 创建指标摘要工具。 // Logan.Li +func NewAIOpsMetricSummaryTool(provider AIOpsContextProvider) Tool { + return &aiopsMetricSummaryTool{provider: provider} +} + +type aiopsAssetSearchTool struct { + provider AIOpsContextProvider +} + +func (t *aiopsAssetSearchTool) Name() string { return "aiops_asset_search" } + +func (t *aiopsAssetSearchTool) Description() string { + return "搜索 AIOps 平台 CMDB/资产库中的服务器资产,返回脱敏后的主机名、IP、协议、系统、状态、Agent 状态和资源使用率。排障前应先用它确认目标对象。" +} + +func (t *aiopsAssetSearchTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "keyword": map[string]interface{}{"type": "string", "description": "主机名、IP、标签或描述关键字,可为空"}, + "limit": map[string]interface{}{"type": "integer", "description": "返回数量,默认 10,最大 50"}, + }, + } +} + +func (t *aiopsAssetSearchTool) RiskLevel() RiskLevel { return RiskSafe } + +func (t *aiopsAssetSearchTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) { + data, err := t.provider.SearchAssets(ctx, StringParam(args, "keyword"), IntParam(args, "limit")) + return jsonResult(data, err) +} + +type aiopsAlertListTool struct { + provider AIOpsContextProvider +} + +func (t *aiopsAlertListTool) Name() string { return "aiops_alert_list" } + +func (t *aiopsAlertListTool) Description() string { + return "查询 AIOps 平台近期告警,合并平台告警和 Agent 告警。可按目标、级别、状态、时间范围过滤,用于判断故障是否与近期告警相关。" +} + +func (t *aiopsAlertListTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "target": map[string]interface{}{"type": "string", "description": "主机名、IP、目标名称,可为空"}, + "severity": map[string]interface{}{"type": "string", "description": "critical/warning/info,可为空"}, + "status": map[string]interface{}{"type": "string", "description": "firing/resolved/active,可为空"}, + "time_range": map[string]interface{}{"type": "string", "description": "15m/30m/1h/2h/6h/24h/7d,默认 24h"}, + "limit": map[string]interface{}{"type": "integer", "description": "返回数量,默认 20,最大 100"}, + }, + } +} + +func (t *aiopsAlertListTool) RiskLevel() RiskLevel { return RiskSafe } + +func (t *aiopsAlertListTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) { + data, err := t.provider.ListAlerts(ctx, + StringParam(args, "target"), StringParam(args, "severity"), + StringParam(args, "status"), StringParam(args, "time_range"), + IntParam(args, "limit")) + return jsonResult(data, err) +} + +type aiopsMetricSummaryTool struct { + provider AIOpsContextProvider +} + +func (t *aiopsMetricSummaryTool) Name() string { return "aiops_metric_summary" } + +func (t *aiopsMetricSummaryTool) Description() string { + return "查询主机 Agent 指标摘要,返回 latest/min/max/avg/p95/trend 和少量采样点。适合验证 CPU、内存、load 等告警是否真实持续。" +} + +func (t *aiopsMetricSummaryTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "target": map[string]interface{}{"type": "string", "description": "主机名或 IP,必填"}, + "metric": map[string]interface{}{"type": "string", "description": "cpu_usage/mem_usage/load1/load5/load15/cpu_iowait,默认 cpu_usage"}, + "time_range": map[string]interface{}{"type": "string", "description": "15m/30m/1h/2h/6h/24h/7d,默认 1h"}, + "limit": map[string]interface{}{"type": "integer", "description": "最多读取多少个样本,默认 240"}, + }, + "required": []string{"target"}, + } +} + +func (t *aiopsMetricSummaryTool) RiskLevel() RiskLevel { return RiskSafe } + +func (t *aiopsMetricSummaryTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) { + if StringParam(args, "target") == "" { + return &Result{Success: false, Error: "target 不能为空", Output: ""}, nil + } + data, err := t.provider.MetricSummary(ctx, + StringParam(args, "target"), StringParam(args, "metric"), + StringParam(args, "time_range"), IntParam(args, "limit")) + return jsonResult(data, err) +} + +func jsonResult(data interface{}, err error) (*Result, error) { + if err != nil { + return &Result{Success: false, Error: err.Error(), Output: err.Error()}, nil + } + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + return nil, fmt.Errorf("JSON 序列化失败: %w", err) + } + return &Result{Success: true, Output: string(b)}, nil +} diff --git a/backend/pkg/aiopscontext/agent_tools.go b/backend/pkg/aiopscontext/agent_tools.go new file mode 100644 index 0000000..0bfdf82 --- /dev/null +++ b/backend/pkg/aiopscontext/agent_tools.go @@ -0,0 +1,39 @@ +package aiopscontext + +import ( + "context" + + "ezops/pkg/agentcli/tools" +) + +// AgentProvider 适配 Agent CLI tools.AIOpsContextProvider。 // Logan.Li +type AgentProvider struct { + service *Service +} + +// NewAgentTools 返回可注册到 Agent CLI 的 AIOps 平台只读工具。 // Logan.Li +func NewAgentTools(service *Service) []tools.Tool { + provider := &AgentProvider{service: service} + return []tools.Tool{ + tools.NewAIOpsAssetSearchTool(provider), + tools.NewAIOpsAlertListTool(provider), + tools.NewAIOpsMetricSummaryTool(provider), + } +} + +// SearchAssets 查询资产。 // Logan.Li +func (p *AgentProvider) SearchAssets(ctx context.Context, keyword string, limit int) (interface{}, error) { + return p.service.SearchAssets(ctx, keyword, limit) +} + +// ListAlerts 查询告警。 // Logan.Li +func (p *AgentProvider) ListAlerts(ctx context.Context, target, severity, status, timeRange string, limit int) (interface{}, error) { + return p.service.ListAlerts(ctx, AlertQuery{ + Target: target, Severity: severity, Status: status, TimeRange: timeRange, Limit: limit, + }) +} + +// MetricSummary 查询指标摘要。 // Logan.Li +func (p *AgentProvider) MetricSummary(ctx context.Context, target, metric, timeRange string, limit int) (interface{}, error) { + return p.service.MetricSummary(ctx, target, metric, timeRange, limit) +} diff --git a/backend/pkg/aiopscontext/service.go b/backend/pkg/aiopscontext/service.go new file mode 100644 index 0000000..3343a8c --- /dev/null +++ b/backend/pkg/aiopscontext/service.go @@ -0,0 +1,433 @@ +package aiopscontext + +import ( + "context" + "fmt" + "math" + "sort" + "strings" + "time" + + "ezops/model" + + "gorm.io/gorm" +) + +// Service 汇聚 AIOps 平台上下文,后续可复用为 MCP Server 的数据源。 // Logan.Li +type Service struct { + db *gorm.DB +} + +// NewService 创建上下文服务。 // Logan.Li +func NewService(db *gorm.DB) *Service { + return &Service{db: db} +} + +// Summary 返回平台总览上下文。 // Logan.Li +func (s *Service) Summary(ctx context.Context) (*Summary, error) { + out := Summary{ + HighLoadHosts: []AssetSummary{}, + RecentAlerts: []AlertSummary{}, + } + db := s.db.WithContext(ctx) + db.Model(&model.Server{}).Count(&out.ServerTotal) + db.Model(&model.Server{}).Where("agent_status = ? OR status = ?", "online", "running").Count(&out.ServerOnline) + db.Model(&model.KubernetesCluster{}).Count(&out.ClusterTotal) + db.Model(&model.AlertEvent{}).Where("status = ?", "firing").Count(&out.FiringAlerts) + db.Model(&model.AgentAlert{}).Where("resolved = ?", false).Count(&out.UnresolvedAlerts) + + assets, err := s.SearchAssets(ctx, "", 8) + if err == nil { + for _, a := range assets { + if a.CPUUsage >= 80 || a.MemoryUsage >= 80 || a.DiskUsage >= 85 { + out.HighLoadHosts = append(out.HighLoadHosts, a) + } + } + } + out.RecentAlerts, _ = s.ListAlerts(ctx, AlertQuery{Limit: 10}) + out.GeneratedAt = time.Now() + return &out, nil +} + +// SearchAssets 查询资产摘要,默认按更新时间倒序返回。 // Logan.Li +func (s *Service) SearchAssets(ctx context.Context, keyword string, limit int) ([]AssetSummary, error) { + if limit <= 0 || limit > 50 { + limit = 10 + } + var servers []model.Server + q := s.db.WithContext(ctx).Model(&model.Server{}) + kw := strings.TrimSpace(keyword) + if kw != "" { + like := "%" + kw + "%" + q = q.Where("hostname LIKE ? OR ip LIKE ? OR public_ip LIKE ? OR tags LIKE ? OR description LIKE ?", + like, like, like, like, like) + } + if err := q.Order("updated_at DESC").Limit(limit).Find(&servers).Error; err != nil { + return nil, err + } + out := make([]AssetSummary, 0, len(servers)) + for _, srv := range servers { + out = append(out, assetFromServer(srv)) + } + return out, nil +} + +// ListAlerts 查询统一告警摘要。 // Logan.Li +func (s *Service) ListAlerts(ctx context.Context, query AlertQuery) ([]AlertSummary, error) { + limit := query.Limit + if limit <= 0 || limit > 100 { + limit = 20 + } + since := sinceByRange(query.TimeRange, 24*time.Hour) + targetLike := "%" + strings.TrimSpace(query.Target) + "%" + + var events []model.AlertEvent + q := s.db.WithContext(ctx).Model(&model.AlertEvent{}).Where("created_at >= ?", since) + if query.Target != "" { + q = q.Where("target LIKE ? OR target_name LIKE ? OR target_ip LIKE ? OR rule_name LIKE ?", + targetLike, targetLike, targetLike, targetLike) + } + if query.Severity != "" { + q = q.Where("severity = ?", query.Severity) + } + if query.Status != "" { + q = q.Where("status = ?", query.Status) + } + if err := q.Order("created_at DESC").Limit(limit).Find(&events).Error; err != nil { + return nil, err + } + + out := make([]AlertSummary, 0, len(events)+limit) + for _, e := range events { + out = append(out, alertFromEvent(e)) + } + + var agentAlerts []model.AgentAlert + aq := s.db.WithContext(ctx).Model(&model.AgentAlert{}).Where("created_at >= ?", since) + if query.Target != "" { + aq = aq.Where("hostname LIKE ? OR rule_name LIKE ?", targetLike, targetLike) + } + if query.Severity != "" { + aq = aq.Where("severity = ?", query.Severity) + } + if query.Status == "firing" || query.Status == "active" { + aq = aq.Where("resolved = ?", false) + } else if query.Status == "resolved" { + aq = aq.Where("resolved = ?", true) + } + if err := aq.Order("created_at DESC").Limit(limit).Find(&agentAlerts).Error; err == nil { + for _, a := range agentAlerts { + out = append(out, alertFromAgent(a)) + } + } + + sort.SliceStable(out, func(i, j int) bool { + return out[i].StartedAt.After(out[j].StartedAt) + }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +// MetricSummary 查询主机指标摘要。 // Logan.Li +func (s *Service) MetricSummary(ctx context.Context, target, metric, timeRange string, limit int) (*MetricSummary, error) { + hostname, err := s.resolveHostname(ctx, target) + if err != nil { + return nil, err + } + if metric == "" { + metric = "cpu_usage" + } + if limit <= 0 || limit > 500 { + limit = 240 + } + since := sinceByRange(timeRange, time.Hour) + var reports []model.AgentReport + if err := s.db.WithContext(ctx).Where("hostname = ? AND report_time >= ?", hostname, since). + Order("report_time ASC").Limit(limit).Find(&reports).Error; err != nil { + return nil, err + } + out := &MetricSummary{Target: target, Hostname: hostname, Metric: metric, TimeRange: normalizeRangeLabel(timeRange), Points: []MetricPoint{}} + values := make([]float64, 0, len(reports)) + for _, r := range reports { + v, ok := metricValue(r, metric) + if !ok { + continue + } + values = append(values, v) + if len(out.Points) < 60 || len(reports) <= 60 { + out.Points = append(out.Points, MetricPoint{Time: r.ReportTime, Value: round2(v)}) + } + } + fillMetricStats(out, values) + return out, nil +} + +// BuildPack 构建面向 LLM 的上下文包。 // Logan.Li +func (s *Service) BuildPack(ctx context.Context, req ContextPackRequest) (*ContextPack, error) { + if req.Limit <= 0 { + req.Limit = 10 + } + scope := strings.TrimSpace(req.Target) + var primaryAlert *AlertSummary + if req.AlertID > 0 { + alert, err := s.GetAlert(ctx, req.AlertID) + if err == nil { + primaryAlert = alert + if scope == "" { + scope = alert.Target + } + } + } + pack := &ContextPack{ + Scope: scope, TimeRange: normalizeRangeLabel(req.TimeRange), + Assets: []AssetSummary{}, Alerts: []AlertSummary{}, Metrics: []MetricSummary{}, + GeneratedAt: time.Now(), Evidence: []EvidenceItem{}, + } + if scope != "" { + pack.Assets, _ = s.SearchAssets(ctx, scope, req.Limit) + for _, asset := range pack.Assets { + pack.Evidence = append(pack.Evidence, EvidenceItem{ + Type: "asset", Source: "asset_servers", + Summary: fmt.Sprintf("资产 %s ip=%s protocol=%s os=%s status=%s agent=%s", + asset.Hostname, asset.IP, asset.Protocol, asset.OSType, asset.Status, asset.AgentStatus), + Confidence: 0.9, + }) + } + } + pack.Alerts, _ = s.ListAlerts(ctx, AlertQuery{Target: scope, TimeRange: req.TimeRange, Limit: req.Limit}) + if primaryAlert != nil { + pack.Alerts = prependAlertIfMissing(pack.Alerts, *primaryAlert) + } + metrics := req.Metrics + if len(metrics) == 0 { + metrics = []string{"cpu_usage", "mem_usage", "load1"} + } + for _, metric := range metrics { + if scope == "" { + break + } + ms, err := s.MetricSummary(ctx, scope, metric, req.TimeRange, 240) + if err == nil && ms.SampleSize > 0 { + pack.Metrics = append(pack.Metrics, *ms) + pack.Evidence = append(pack.Evidence, EvidenceItem{ + Type: "metric", Source: "monitor_agent_reports", + Summary: fmt.Sprintf("%s 最近 %s: latest=%.2f avg=%.2f p95=%.2f max=%.2f trend=%s", + ms.Metric, ms.TimeRange, ms.Latest, ms.Avg, ms.P95, ms.Max, ms.Trend), + Confidence: 0.82, + }) + } + } + for _, a := range pack.Alerts { + pack.Evidence = append(pack.Evidence, EvidenceItem{ + Type: "alert", Source: a.Source, + Summary: fmt.Sprintf("[%s|%s] %s target=%s value=%.2f threshold=%.2f", + a.Severity, a.Status, a.RuleName, a.Target, a.Value, a.Threshold), + Confidence: 0.78, + }) + } + return pack, nil +} + +// GetAlert 获取单条平台告警摘要。 // Logan.Li +func (s *Service) GetAlert(ctx context.Context, id uint) (*AlertSummary, error) { + var event model.AlertEvent + if err := s.db.WithContext(ctx).Take(&event, id).Error; err == nil { + alert := alertFromEvent(event) + return &alert, nil + } + var agentAlert model.AgentAlert + if err := s.db.WithContext(ctx).Take(&agentAlert, id).Error; err != nil { + return nil, err + } + alert := alertFromAgent(agentAlert) + return &alert, nil +} + +func (s *Service) resolveHostname(ctx context.Context, target string) (string, error) { + target = strings.TrimSpace(target) + if target == "" { + return "", fmt.Errorf("target 不能为空") + } + var srv model.Server + err := s.db.WithContext(ctx).Where("hostname = ? OR ip = ? OR public_ip = ?", target, target, target).Take(&srv).Error + if err == nil { + return srv.Hostname, nil + } + if err == gorm.ErrRecordNotFound { + return target, nil + } + return "", err +} + +func assetFromServer(srv model.Server) AssetSummary { + return AssetSummary{ + ID: srv.ID, Hostname: srv.Hostname, IP: srv.IP, PublicIP: srv.PublicIP, + Protocol: srv.Protocol, OSType: srv.OSType, OS: srv.OS, Status: srv.Status, + AgentStatus: srv.AgentStatus, CPUUsage: srv.CPUUsage, MemoryUsage: srv.MemoryUsage, + DiskUsage: srv.DiskUsage, Tags: srv.Tags, Description: srv.Description, + LastCheckAt: srv.LastCheckAt, UpdatedAt: srv.UpdatedAt, + } +} + +func alertFromEvent(e model.AlertEvent) AlertSummary { + target := e.Target + if target == "" { + target = e.TargetName + } + if target == "" { + target = e.TargetIP + } + started := e.StartedAt + if started.IsZero() { + started = e.CreatedAt + } + return AlertSummary{ + ID: e.ID, Source: "monitor_alert_events", RuleName: firstNonEmpty(e.RuleName, e.Title), + Severity: e.Severity, Status: e.Status, Target: target, Metric: e.MetricName, + Value: e.Value, Threshold: e.Threshold, Message: e.Message, StartedAt: started, + ResolvedAt: e.ResolvedAt, + } +} + +func alertFromAgent(a model.AgentAlert) AlertSummary { + status := "firing" + if a.Resolved { + status = "resolved" + } + return AlertSummary{ + ID: a.ID, Source: "monitor_agent_alerts", RuleName: a.RuleName, + Severity: a.Severity, Status: status, Target: a.Hostname, Metric: a.Metric, + Value: a.Value, Threshold: a.Threshold, Message: a.Message, + StartedAt: a.CreatedAt, ResolvedAt: a.ResolvedAt, + } +} + +func prependAlertIfMissing(alerts []AlertSummary, alert AlertSummary) []AlertSummary { + for _, item := range alerts { + if item.Source == alert.Source && item.ID == alert.ID { + return alerts + } + } + return append([]AlertSummary{alert}, alerts...) +} + +func metricValue(r model.AgentReport, metric string) (float64, bool) { + switch metric { + case "cpu", "cpu_usage": + return r.CPUUsage, true + case "cpu_iowait": + return r.CPUIOwait, true + case "mem", "memory", "mem_usage", "memory_usage": + return r.MemUsage, true + case "mem_available": + return r.MemAvailable, true + case "swap", "mem_swap_used": + return r.MemSwapUsed, true + case "load1": + return r.Load1, true + case "load5": + return r.Load5, true + case "load15": + return r.Load15, true + default: + return 0, false + } +} + +func fillMetricStats(out *MetricSummary, values []float64) { + out.SampleSize = len(values) + if len(values) == 0 { + out.Trend = "unknown" + return + } + minV, maxV, sum := values[0], values[0], 0.0 + for _, v := range values { + if v < minV { + minV = v + } + if v > maxV { + maxV = v + } + sum += v + } + sorted := append([]float64(nil), values...) + sort.Float64s(sorted) + p95Idx := int(math.Ceil(float64(len(sorted))*0.95)) - 1 + if p95Idx < 0 { + p95Idx = 0 + } + out.Latest = round2(values[len(values)-1]) + out.Min = round2(minV) + out.Max = round2(maxV) + out.Avg = round2(sum / float64(len(values))) + out.P95 = round2(sorted[p95Idx]) + out.Trend = trendOf(values) +} + +func trendOf(values []float64) string { + if len(values) < 4 { + return "stable" + } + n := len(values) + head := avg(values[:n/3]) + tail := avg(values[n-n/3:]) + if tail-head > 5 { + return "up" + } + if head-tail > 5 { + return "down" + } + return "stable" +} + +func avg(values []float64) float64 { + if len(values) == 0 { + return 0 + } + sum := 0.0 + for _, v := range values { + sum += v + } + return sum / float64(len(values)) +} + +func sinceByRange(label string, fallback time.Duration) time.Time { + switch strings.ToLower(strings.TrimSpace(label)) { + case "15m": + return time.Now().Add(-15 * time.Minute) + case "30m": + return time.Now().Add(-30 * time.Minute) + case "2h": + return time.Now().Add(-2 * time.Hour) + case "6h": + return time.Now().Add(-6 * time.Hour) + case "24h", "1d": + return time.Now().Add(-24 * time.Hour) + case "7d": + return time.Now().Add(-7 * 24 * time.Hour) + default: + return time.Now().Add(-fallback) + } +} + +func normalizeRangeLabel(label string) string { + if strings.TrimSpace(label) == "" { + return "1h" + } + return label +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func round2(v float64) float64 { + return math.Round(v*100) / 100 +} diff --git a/backend/pkg/aiopscontext/types.go b/backend/pkg/aiopscontext/types.go new file mode 100644 index 0000000..9e8fbc6 --- /dev/null +++ b/backend/pkg/aiopscontext/types.go @@ -0,0 +1,110 @@ +package aiopscontext + +import "time" + +// Summary 平台智能上下文总览,供智能助手和 Agent 工具复用。 // Logan.Li +type Summary struct { + ServerTotal int64 `json:"server_total"` + ServerOnline int64 `json:"server_online"` + ClusterTotal int64 `json:"cluster_total"` + FiringAlerts int64 `json:"firing_alerts"` + UnresolvedAlerts int64 `json:"unresolved_alerts"` + HighLoadHosts []AssetSummary `json:"high_load_hosts"` + RecentAlerts []AlertSummary `json:"recent_alerts"` + GeneratedAt time.Time `json:"generated_at"` +} + +// AssetSummary 脱敏后的资产摘要,不包含凭证字段。 // Logan.Li +type AssetSummary struct { + ID uint `json:"id"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + PublicIP string `json:"public_ip,omitempty"` + Protocol string `json:"protocol"` + OSType string `json:"os_type"` + OS string `json:"os,omitempty"` + Status string `json:"status"` + AgentStatus string `json:"agent_status"` + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + DiskUsage float64 `json:"disk_usage"` + Tags string `json:"tags,omitempty"` + Description string `json:"description,omitempty"` + LastCheckAt *time.Time `json:"last_check_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AlertQuery 告警查询条件。 // Logan.Li +type AlertQuery struct { + Target string `json:"target,omitempty"` + Severity string `json:"severity,omitempty"` + Status string `json:"status,omitempty"` + TimeRange string `json:"time_range,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// AlertSummary 统一告警摘要,合并平台告警和 Agent 告警。 // Logan.Li +type AlertSummary struct { + ID uint `json:"id"` + Source string `json:"source"` + RuleName string `json:"rule_name"` + Severity string `json:"severity"` + Status string `json:"status"` + Target string `json:"target"` + Metric string `json:"metric"` + Value float64 `json:"value"` + Threshold float64 `json:"threshold"` + Message string `json:"message"` + StartedAt time.Time `json:"started_at"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` +} + +// MetricSummary 指标摘要,强调证据链需要的统计值和样本窗口。 // Logan.Li +type MetricSummary struct { + Target string `json:"target"` + Hostname string `json:"hostname"` + Metric string `json:"metric"` + TimeRange string `json:"time_range"` + SampleSize int `json:"sample_size"` + Latest float64 `json:"latest"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Avg float64 `json:"avg"` + P95 float64 `json:"p95"` + Trend string `json:"trend"` + Points []MetricPoint `json:"points"` +} + +// MetricPoint 指标采样点,默认只返回少量抽样,避免 token 膨胀。 // Logan.Li +type MetricPoint struct { + Time time.Time `json:"time"` + Value float64 `json:"value"` +} + +// ContextPackRequest 上下文包请求。 // Logan.Li +type ContextPackRequest struct { + Target string `json:"target"` + AlertID uint `json:"alert_id"` + Metrics []string `json:"metrics"` + TimeRange string `json:"time_range"` + Limit int `json:"limit"` +} + +// ContextPack 面向 LLM 的平台上下文包。 // Logan.Li +type ContextPack struct { + Scope string `json:"scope"` + TimeRange string `json:"time_range"` + Assets []AssetSummary `json:"assets"` + Alerts []AlertSummary `json:"alerts"` + Metrics []MetricSummary `json:"metrics"` + Evidence []EvidenceItem `json:"evidence"` + GeneratedAt time.Time `json:"generated_at"` +} + +// EvidenceItem 供模型引用的证据摘要。 // Logan.Li +type EvidenceItem struct { + Type string `json:"type"` + Source string `json:"source"` + Summary string `json:"summary"` + Confidence float64 `json:"confidence"` +} diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 8aed328..5550580 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -132,6 +132,7 @@ func AutoMigrate(db *gorm.DB) error { &model.SessionCommand{}, // 终端会话命令审计 // Logan.Li &model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li &model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li + &model.AIAnalysisTrace{}, // AI 分析上下文与结果审计回放 // Logan.Li &model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li &model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li &model.MonitorMetric{}, // 监控指标定义 // Logan.Li diff --git a/backend/pkg/rdp/channels/cliprdr/cliprdr.go b/backend/pkg/rdp/channels/cliprdr/cliprdr.go new file mode 100644 index 0000000..8494bdd --- /dev/null +++ b/backend/pkg/rdp/channels/cliprdr/cliprdr.go @@ -0,0 +1,422 @@ +package cliprdr + +import ( + "bytes" + "encoding/binary" + "fmt" + "log" + "strings" + "sync" + "unicode/utf16" +) + +// Sender 负责把 CLIPRDR PDU 通过 MCS SendDataRequest 发往指定 channel ID。 +// 由协议栈注入(通常封装 buildMCSSendDataRequest + transport.WriteX224Data + writeMu)。// Logan.Li +type Sender interface { + SendChannelPDU(channelID uint16, data []byte) error +} + +// Handler 实现 RDP 剪贴板虚拟通道(CF_UNICODETEXT / CF_TEXT 文本格式)。 +// +// - onRemote: 远端剪贴板文本到达时回调(协议栈应转成 WS type=clipboard 帧推给浏览器) +// - getLocal: 远端请求本地剪贴板时回调取值(协议栈从 inputLoop 维护的本地剪贴板缓存读取) +// +// 两个回调都是线程安全的调用点:Handler 内部用 mu 保护自身状态, +// 调用方负责 Sender 的并发写(writeMu)。 +type Handler struct { + sender Sender + channelID uint16 + + mu sync.Mutex + useLongFormatNames bool + serverCapsReceived bool + monitorReady bool + localText string // 本地剪贴板文本(由 SetLocalText 更新,供远端请求时回填) + suppressNextChange bool // 抑制 echo 回环:远端→本地 触发的本地变更不再回推远端 + + onRemote func(text string) // 远端文本到达回调(可能为 nil) + initialFormatListSent bool // 初始化阶段只允许发送一次空 FormatList,匹配 FreeRDP/IronRDP 时序。// Logan.Li +} + +// NewHandler 创建剪贴板处理器。 +// - onRemote: 远端剪贴板内容到达时调用(转成 WS clipboard 帧推送浏览器) +// - getLocal: 已废弃,本地文本请用 SetLocalText 主动设置(浏览器同步剪贴板时写入) +func NewHandler(onRemote func(text string)) *Handler { + return &Handler{onRemote: onRemote} +} + +// Attach 绑定发送通道与会话分配的 channel ID(在 MCS ChannelJoin 成功后调用)。 +// 附加后主动发 CB_CLIP_CAPS 声明客户端能力(必须用 Long Format Names), +// 否则 Windows 不知道格式协商方式,会忽略后续 FormatList。 // Logan.Li +func (h *Handler) Attach(sender Sender, channelID uint16) { + h.mu.Lock() + h.sender = sender + h.channelID = channelID + h.useLongFormatNames = true // 客户端强制 Long Format Names,与发送的 caps 一致 + h.mu.Unlock() + log.Printf("[cliprdr] attached channelID=%d", channelID) +} + +// Init 在会话激活后主动发起剪贴板握手。 +// MS-RDPECLIP 规定服务端先发 CB_CLIP_CAPS + CB_MONITOR_READY,但部分 Windows 版本 +// (尤其开启了剪贴板重定向策略的)会等客户端先声明能力。主动发 caps + formatList +// 可兼容两种情况,不会产生冲突(服务端收到多余的 caps 会忽略)。// Logan.Li +func (h *Handler) Init() { + h.mu.Lock() + if h.sender == nil { + h.mu.Unlock() + return + } + monitorReady := h.monitorReady + needInitial := !h.initialFormatListSent + if needInitial { + h.initialFormatListSent = true + } + h.mu.Unlock() + if monitorReady { + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr init: server MonitorReady already handled")) + return + } + // 部分 Windows 会先等客户端声明能力;延迟主动初始化兼顾标准时序和真实服务器兼容性。// Logan.Li + h.sendClipCaps() + if needInitial { + h.sendEmptyFormatList() + } + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr init: proactive caps and initial FormatList sent")) +} + +// SetLocalText 更新本地剪贴板文本(浏览器把本地剪贴板同步过来时调用)。 +// 设置后会向远端发送 FormatList 通知远端"本地剪贴板有新内容", +// 除非 suppress 为 true(用于抑制远端→本地 echo 回环)。 +func (h *Handler) SetLocalText(text string) { + h.mu.Lock() + h.localText = text + suppress := h.suppressNextChange + h.suppressNextChange = false + sender := h.sender + h.mu.Unlock() + + if suppress { + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: local text set with suppress (echo from remote), skip FormatList")) + return + } + if sender != nil { + h.sendFormatList() + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: local text changed, sent FormatList", "len", len(text))) + } +} + +// ChannelID 返回绑定的通道 ID(未 Attach 返回 0)。 +func (h *Handler) ChannelID() uint16 { + h.mu.Lock() + defer h.mu.Unlock() + return h.channelID +} + +// RequestRemoteText 在用户触发远端 Ctrl+C/Ctrl+X 后主动请求文本内容, +// 兼容部分 Windows 不主动发送 CB_FORMAT_LIST 的场景。// Logan.Li +func (h *Handler) RequestRemoteText() { + h.mu.Lock() + ready := h.sender != nil + h.mu.Unlock() + if !ready { + return + } + h.sendFormatDataRequest(cfUnicodeText) +} + +// Process 处理从 MCS SendDataIndication 收到的 cliprdr PDU(已去掉 MCS/SEC 头的原始通道数据)。 +// 由协议栈 readLoop 在识别到 cliprdr channel ID 后调用。 +func (h *Handler) Process(data []byte) { + if len(data) < 8 { + return + } + msgType := binary.LittleEndian.Uint16(data[0:2]) + msgFlags := binary.LittleEndian.Uint16(data[2:4]) + dataLen := binary.LittleEndian.Uint32(data[4:8]) + if int(dataLen) > len(data)-8 { + log.Printf("[cliprdr] Warn: %s", fmt.Sprintln("cliprdr: PDU dataLen exceeds payload", "dataLen", dataLen, "have", len(data)-8)) + return + } + body := data[8 : 8+dataLen] + + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr recv", "msgType", msgType, "msgFlags", msgFlags, "dataLen", dataLen)) + + switch msgType { + case msgClipCaps: + h.processClipCaps(body) + case msgMonitorReady: + h.processMonitorReady() + case msgFormatList: + h.processFormatList(body) + case msgFormatListResponse: + // 远端确认收到本地的 FormatList,无需动作 + if msgFlags&flagResponseOK == 0 { + log.Printf("[cliprdr] Warn: %s", fmt.Sprintln("cliprdr: FormatListResponse FAIL")) + } + case msgFormatDataRequest: + h.processFormatDataRequest(body) + case msgFormatDataResponse: + h.processFormatDataResponse(body, msgFlags) + case msgLockClipdata, msgUnlockClipdata: + // 文本模式不处理锁 + default: + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: unhandled msgType", "msgType", msgType)) + } +} + +// --- CB_CLIP_CAPS(2.2.2.1)------------------------------------------------- + +func (h *Handler) processClipCaps(body []byte) { + if len(body) < 4 { + return + } + cCapSets := binary.LittleEndian.Uint16(body[0:2]) + offset := 4 // cCapabilitySets(2) + pad1(2) + for i := 0; i < int(cCapSets); i++ { + if offset+4 > len(body) { + break + } + capType := binary.LittleEndian.Uint16(body[offset:]) + capLen := binary.LittleEndian.Uint16(body[offset+2:]) + if capType == capsTypeGeneral && capLen >= 12 && offset+12 <= len(body) { + generalFlags := binary.LittleEndian.Uint32(body[offset+8:]) + h.mu.Lock() + h.useLongFormatNames = generalFlags&flagUseLongFormatNames != 0 + useLong := h.useLongFormatNames + h.mu.Unlock() + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: server caps", "generalFlags", generalFlags, "longNames", useLong)) + } + offset += int(capLen) + } + h.mu.Lock() + h.serverCapsReceived = true + localReady := h.localText != "" + ready := h.monitorReady + h.mu.Unlock() + // 非标准顺序:MonitorReady 先到时,在拿到 caps 后补发 FormatList + if ready && localReady { + h.sendFormatList() + } +} + +// --- CB_MONITOR_READY(2.2.2.2)-------------------------------------------- + +func (h *Handler) processMonitorReady() { + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: server Monitor Ready")) + h.mu.Lock() + h.monitorReady = true + capsReady := h.serverCapsReceived + localReady := h.localText != "" + needInitial := !h.initialFormatListSent + if needInitial { + h.initialFormatListSent = true + } + h.mu.Unlock() + // MS-RDPECLIP §1.3.2.1: server 先发 CB_CLIP_CAPS 再发 CB_MONITOR_READY。 + // 拿到 caps 才知道是否用 Long Format Names,故 FormatList 等 caps 就绪后再发。 + h.sendClipCaps() + if needInitial && !localReady { + h.sendEmptyFormatList() + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: sent initial empty FormatList")) + } + if capsReady && localReady { + h.sendFormatList() + } +} + +// --- Format List(2.2.3)--------------------------------------------------- + +func (h *Handler) sendFormatList() { + h.mu.Lock() + useLong := h.useLongFormatNames + h.initialFormatListSent = true + h.mu.Unlock() + + body := &bytes.Buffer{} + if useLong { + // Long Format Name: formatId(4) + null-terminated UTF-16LE name + binary.Write(body, binary.LittleEndian, uint32(cfUnicodeText)) + binary.Write(body, binary.LittleEndian, uint16(0)) // 空名 = 标准格式 + binary.Write(body, binary.LittleEndian, uint32(cfText)) + binary.Write(body, binary.LittleEndian, uint16(0)) + } else { + // Short Format Name: formatId(4) + name[32] + binary.Write(body, binary.LittleEndian, uint32(cfUnicodeText)) + body.Write(make([]byte, 32)) + binary.Write(body, binary.LittleEndian, uint32(cfText)) + body.Write(make([]byte, 32)) + } + h.sendPDU(msgFormatList, 0, body.Bytes()) +} + +func (h *Handler) sendEmptyFormatList() { + // 初始化空 FormatList 是 FreeRDP 允许的首包,用来完成剪贴板客户端初始化。// Logan.Li + h.sendPDU(msgFormatList, 0, nil) +} + +func (h *Handler) processFormatList(body []byte) { + formats := h.parseFormatList(body) + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: server FormatList", "formats", formats)) + + // 始终回复 OK + h.sendPDU(msgFormatListResponse, flagResponseOK, nil) + + // 优先请求 Unicode 文本,其次 ANSI + for _, f := range formats { + if f == cfUnicodeText { + h.sendFormatDataRequest(cfUnicodeText) + return + } + } + for _, f := range formats { + if f == cfText { + h.sendFormatDataRequest(cfText) + return + } + } +} + +func (h *Handler) parseFormatList(body []byte) []uint32 { + h.mu.Lock() + useLong := h.useLongFormatNames + h.mu.Unlock() + + var formats []uint32 + if useLong { + // Long: formatId(4) + null-term UTF-16LE name + offset := 0 + for offset+4 <= len(body) { + formats = append(formats, binary.LittleEndian.Uint32(body[offset:])) + offset += 4 + // 跳过 name(null-terminated UTF-16LE) + for offset+1 < len(body) { + if body[offset] == 0 && body[offset+1] == 0 { + offset += 2 + break + } + offset += 2 + } + } + } else { + // Short: formatId(4) + name[32] = 36 字节/项 + for offset := 0; offset+36 <= len(body); offset += 36 { + formats = append(formats, binary.LittleEndian.Uint32(body[offset:])) + } + } + return formats +} + +// --- Format Data Request / Response(2.2.5)------------------------------- + +func (h *Handler) sendFormatDataRequest(formatID uint32) { + b := make([]byte, 4) + binary.LittleEndian.PutUint32(b, formatID) + h.sendPDU(msgFormatDataRequest, 0, b) + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: sent FormatDataRequest", "formatID", formatID)) +} + +func (h *Handler) processFormatDataRequest(body []byte) { + if len(body) < 4 { + h.sendPDU(msgFormatDataResponse, flagResponseFail, nil) + return + } + requestedFormat := binary.LittleEndian.Uint32(body[0:4]) + h.mu.Lock() + text := h.localText + h.mu.Unlock() + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: server requests format", "formatID", requestedFormat, "localLen", len(text))) + + switch requestedFormat { + case cfUnicodeText: + encoded := encodeUTF16LE(text + "\x00") + h.sendPDU(msgFormatDataResponse, flagResponseOK, encoded) + case cfText: + h.sendPDU(msgFormatDataResponse, flagResponseOK, []byte(text+"\x00")) + default: + h.sendPDU(msgFormatDataResponse, flagResponseFail, nil) + } +} + +func (h *Handler) processFormatDataResponse(body []byte, msgFlags uint16) { + if msgFlags&flagResponseOK == 0 { + log.Printf("[cliprdr] Warn: %s", fmt.Sprintln("cliprdr: FormatDataResponse FAIL")) + return + } + // 尝试按 UTF-16LE 解码(CF_UNICODETEXT) + text := decodeUTF16LE(body) + text = strings.TrimRight(text, "\x00") + if text == "" { + return + } + log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: remote clipboard text", "len", len(text))) + // 标记抑制下一次本地变更(避免 echo 回环:远端→本地触发的本地变更不再推回远端) + h.mu.Lock() + h.suppressNextChange = true + cb := h.onRemote + h.mu.Unlock() + if cb != nil { + cb(text) + } +} + +// --- Send helpers -------------------------------------------------------- + +func (h *Handler) sendClipCaps() { + // General capability set: type(2) + length(2) + version(4) + flags(4) = 12 + cap := &bytes.Buffer{} + binary.Write(cap, binary.LittleEndian, uint16(capsTypeGeneral)) + binary.Write(cap, binary.LittleEndian, uint16(12)) + binary.Write(cap, binary.LittleEndian, uint32(capsVersion2)) + binary.Write(cap, binary.LittleEndian, uint32(flagUseLongFormatNames)) + + body := &bytes.Buffer{} + binary.Write(body, binary.LittleEndian, uint16(1)) // cCapabilitySets + binary.Write(body, binary.LittleEndian, uint16(0)) // pad1 + body.Write(cap.Bytes()) + + h.sendPDU(msgClipCaps, 0, body.Bytes()) +} + +func (h *Handler) sendPDU(msgType, msgFlags uint16, body []byte) { + h.mu.Lock() + sender := h.sender + chID := h.channelID + h.mu.Unlock() + if sender == nil { + return + } + buf := &bytes.Buffer{} + binary.Write(buf, binary.LittleEndian, msgType) + binary.Write(buf, binary.LittleEndian, msgFlags) + binary.Write(buf, binary.LittleEndian, uint32(len(body))) + buf.Write(body) + if err := sender.SendChannelPDU(chID, buf.Bytes()); err != nil { + log.Printf("[cliprdr] Warn: %s", fmt.Sprintln("cliprdr: send PDU failed", "msgType", msgType, "err", err)) + } +} + +// --- UTF-16LE helpers ---------------------------------------------------- + +func decodeUTF16LE(b []byte) string { + if len(b) < 2 { + return "" + } + if len(b)%2 != 0 { + b = b[:len(b)-1] + } + u16 := make([]uint16, len(b)/2) + for i := range u16 { + u16[i] = binary.LittleEndian.Uint16(b[i*2:]) + } + return string(utf16.Decode(u16)) +} + +func encodeUTF16LE(s string) []byte { + u16 := utf16.Encode([]rune(s)) + b := make([]byte, len(u16)*2) + for i, v := range u16 { + binary.LittleEndian.PutUint16(b[i*2:], v) + } + return b +} diff --git a/backend/pkg/rdp/channels/cliprdr/types.go b/backend/pkg/rdp/channels/cliprdr/types.go new file mode 100644 index 0000000..b90b166 --- /dev/null +++ b/backend/pkg/rdp/channels/cliprdr/types.go @@ -0,0 +1,65 @@ +// Package cliprdr 实现 RDP 剪贴板虚拟通道扩展(MS-RDPECLIP)的客户端侧, +// 支持浏览器与远端 Windows 之间的双向文本剪贴板同步。// Logan.Li +// +// 协议序列(MS-RDPECLIP §1.3.2): +// +// 初始化: +// Server ── CB_CLIP_CAPS ──► Client +// Server ── CB_MONITOR_READY ──► Client +// Client ── CB_CLIP_CAPS ──► Server +// Client ── CB_FORMAT_LIST ──► Server +// Server ── CB_FORMAT_LIST_RESPONSE ──► Client +// +// 远端→本地(远端复制,本地粘贴): +// Server ── CB_FORMAT_LIST ──► Client (远端声明可用格式) +// Client ── CB_FORMAT_LIST_RESPONSE ──► Server +// Client ── CB_FORMAT_DATA_REQUEST ──► Server (本地请求文本数据) +// Server ── CB_FORMAT_DATA_RESPONSE ──► Client (远端返回剪贴板内容) +// +// 本地→远端(本地复制,远端粘贴): +// Client ── CB_FORMAT_LIST ──► Server (本地声明可用格式) +// Server ── CB_FORMAT_DATA_REQUEST ──► Client (远端请求文本数据) +// Client ── CB_FORMAT_DATA_RESPONSE ──► Server (本地返回剪贴板内容) +package cliprdr + +// CLIPRDR PDU msgType(MS-RDPECLIP 2.2.1) +const ( + msgMonitorReady = 0x0001 + msgFormatList = 0x0002 + msgFormatListResponse = 0x0003 + msgFormatDataRequest = 0x0004 + msgFormatDataResponse = 0x0005 + msgTempDirectory = 0x0006 + msgClipCaps = 0x0007 + msgFilecontentsRequest = 0x0008 + msgFilecontentsResponse = 0x0009 + msgLockClipdata = 0x000A + msgUnlockClipdata = 0x000B +) + +// msgFlags(MS-RDPECLIP 2.2.2) +const ( + flagResponseOK = 0x0001 + flagResponseFail = 0x0002 + flagAsciiNames = 0x0004 +) + +// 通用能力集 GeneralFlags(MS-RDPECLIP 2.2.2.1.1.1.1) +const ( + capsTypeGeneral = 0x0001 + capsVersion2 = 0x00000002 + flagUseLongFormatNames = 0x00000002 +) + +// Windows 标准剪贴板格式 ID(WinUser.h) +const ( + cfText = 1 // CF_TEXT ANSI 文本 + cfUnicodeText = 13 // CF_UNICODETEXT UTF-16 文本 +) + +// 通道声明常量(供协议栈在 MCS Connect-Initial 里声明通道时复用,避免魔法值分散) +const ( + ChannelName = "cliprdr" + // ChannelOptions 与 grdp/FreeRDP 保持一致:INITIALIZED | ENCRYPT_RDP | SHOW_PROTOCOL + ChannelOptions = 0x80000000 | 0x40000000 | 0x00200000 +) diff --git a/backend/pkg/rdp/channels/rdpdr/device_io.go b/backend/pkg/rdp/channels/rdpdr/device_io.go new file mode 100644 index 0000000..d2b611b --- /dev/null +++ b/backend/pkg/rdp/channels/rdpdr/device_io.go @@ -0,0 +1,602 @@ +package rdpdr + +import ( + "encoding/binary" + "log" + "path" + "strings" + "time" + "unicode/utf16" +) + +const ( + irpCreate uint32 = 0x00000000 + irpClose uint32 = 0x00000002 + irpRead uint32 = 0x00000003 + irpWrite uint32 = 0x00000004 + irpQueryInfo uint32 = 0x00000005 + irpSetInfo uint32 = 0x00000006 + irpDeviceCtrl uint32 = 0x0000000e + irpQueryVolume uint32 = 0x0000000a + irpDirControl uint32 = 0x0000000c + irpLockControl uint32 = 0x00000011 + + irpMnQueryDir uint32 = 0x00000001 + irpMnNotifyChangeDir uint32 = 0x00000002 + + statusSuccess uint32 = 0x00000000 + statusNoMoreFiles uint32 = 0x80000006 + statusAccessDenied uint32 = 0xc0000022 + statusObjectNameNotFound uint32 = 0xc0000034 + statusNoSuchFile uint32 = 0xc000000f + statusFileIsDirectory uint32 = 0xc00000ba + statusNotSupported uint32 = 0xc00000bb + statusInvalidDeviceReq uint32 = 0xc0000010 + statusInvalidParameter uint32 = 0xc000000d + statusEndOfFile uint32 = 0xc0000011 + + fileOpen uint32 = 0x00000001 + fileCreate uint32 = 0x00000002 + fileOpenIf uint32 = 0x00000003 + fileOverwrite uint32 = 0x00000004 + fileOverwriteIf uint32 = 0x00000005 + + fileDirectoryFile uint32 = 0x00000001 + fileNonDirectoryFile uint32 = 0x00000040 + + fileDirectoryInformation uint32 = 1 + fileFullDirectoryInformation uint32 = 2 + fileBothDirectoryInformation uint32 = 3 + fileBasicInformation uint32 = 4 + fileStandardInformation uint32 = 5 + fileInternalInformation uint32 = 6 + fileEaInformation uint32 = 7 + fileAccessInformation uint32 = 8 + fileNameInformation uint32 = 9 + fileNamesInformation uint32 = 12 + filePositionInformation uint32 = 14 + fileModeInformation uint32 = 16 + fileAlignmentInformation uint32 = 17 + fileEndOfFileInformation uint32 = 20 + fileNetworkOpenInformation uint32 = 34 + fileAttributeTagInformation uint32 = 35 + + fileFsVolumeInformation uint32 = 1 + fileFsSizeInformation uint32 = 3 + fileFsDeviceInformation uint32 = 4 + fileFsAttributeInformation uint32 = 5 + fileFsFullSizeInformation uint32 = 7 + + fileAttrDirectory uint32 = 0x00000010 + fileAttrNormal uint32 = 0x00000080 + fileAttrArchive uint32 = 0x00000020 + + fileOpened byte = 0x01 + fileCreated byte = 0x02 + fileOverwritten byte = 0x03 + + downloadDirName = "Download" + uploadDirName = "Upload" +) + +const windowsFiletimeOffset = 116444736000000000 + +type ioRequest struct { + deviceID uint32 + fileID uint32 + completionID uint32 + majorFn uint32 + minorFn uint32 + payload []byte +} + +type openFile struct { + path string + name string + isDir bool + data []byte + written bool + dirEntries []dirEntry + dirIndex int +} + +type dirEntry struct { + name string + isDir bool + size uint64 +} + +func (h *Handler) handleIORequest(body []byte) { + if len(body) < 20 { + return + } + req := ioRequest{ + deviceID: binary.LittleEndian.Uint32(body[0:4]), + fileID: binary.LittleEndian.Uint32(body[4:8]), + completionID: binary.LittleEndian.Uint32(body[8:12]), + majorFn: binary.LittleEndian.Uint32(body[12:16]), + minorFn: binary.LittleEndian.Uint32(body[16:20]), + payload: body[20:], + } + log.Printf("[rdpdr] io request device=%d file=%d completion=%d major=0x%x minor=0x%x payload=%d", req.deviceID, req.fileID, req.completionID, req.majorFn, req.minorFn, len(req.payload)) + switch req.majorFn { + case irpCreate: + h.handleCreate(req) + case irpClose: + h.handleClose(req) + case irpRead: + h.handleRead(req) + case irpWrite: + h.handleWrite(req) + case irpQueryInfo: + h.handleQueryInfo(req) + case irpSetInfo: + h.handleSetInfo(req) + case irpDeviceCtrl: + h.handleDeviceControl(req) + case irpQueryVolume: + h.handleQueryVolume(req) + case irpDirControl: + if req.minorFn == irpMnQueryDir { + h.handleQueryDirectory(req) + return + } + if req.minorFn == irpMnNotifyChangeDir { + h.handleNotifyChangeDirectory(req) + return + } + h.sendIOCompletion(req.deviceID, req.completionID, statusNotSupported, nil) + case irpLockControl: + h.handleLockControl(req) + default: + h.sendIOCompletion(req.deviceID, req.completionID, statusNotSupported, nil) + } +} + +func (h *Handler) handleCreate(req ioRequest) { + var out [5]byte + if len(req.payload) < 32 { + h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, out[:]) + return + } + disposition := binary.LittleEndian.Uint32(req.payload[20:24]) + options := binary.LittleEndian.Uint32(req.payload[24:28]) + pathLen := binary.LittleEndian.Uint32(req.payload[28:32]) + if uint32(len(req.payload)) < 32+pathLen { + h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, out[:]) + return + } + vpath := normalizeVirtualPath(decodeUTF16(req.payload[32 : 32+pathLen])) + isDir := options&fileDirectoryFile != 0 + kind, name := classifyVirtualPath(vpath) + if kind == "missing" { + h.sendIOCompletion(req.deviceID, req.completionID, statusObjectNameNotFound, out[:]) + return + } + switch kind { + case "root", "download", "upload": + isDir = true + case "file", "upload_file": + // Explorer 拖拽复制时可能带上目录相关 CreateOptions,虚拟盘文件必须按真实文件打开。// Logan.Li + isDir = false + } + var initialData []byte + if kind == "upload_file" { + data, ok := h.getUpload(name) + if !ok { + h.sendIOCompletion(req.deviceID, req.completionID, statusObjectNameNotFound, out[:]) + return + } + initialData = data + } + if isDir && options&fileNonDirectoryFile != 0 { + h.sendIOCompletion(req.deviceID, req.completionID, statusFileIsDirectory, out[:]) + return + } + if kind == "file" && disposition == fileOpen { + // 虚拟 Download 目录只负责接收远端写入,读取历史文件留给上传目录实现。// Logan.Li + h.sendIOCompletion(req.deviceID, req.completionID, statusObjectNameNotFound, out[:]) + return + } + info := fileOpened + if disposition == fileCreate { + info = fileCreated + } + if disposition == fileOverwrite || disposition == fileOverwriteIf { + info = fileOverwritten + } + fileID := h.storeOpenFile(&openFile{path: vpath, name: name, isDir: isDir, data: initialData}) + binary.LittleEndian.PutUint32(out[0:4], fileID) + out[4] = info + log.Printf("[rdpdr] create path=%q fileID=%d dir=%t", vpath, fileID, isDir) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:]) +} + +func (h *Handler) handleClose(req ioRequest) { + file, ok := h.removeOpenFile(req.fileID) + if ok && !file.isDir && file.written && len(file.data) > 0 && h.onDownload != nil { + data := append([]byte(nil), file.data...) + h.onDownload(Download{Name: file.name, Data: data}) + log.Printf("[rdpdr] download captured name=%s size=%d", file.name, len(data)) + } + var out [5]byte + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:]) +} + +func (h *Handler) handleRead(req ioRequest) { + if len(req.payload) < 20 { + h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, nil) + return + } + length := int(binary.LittleEndian.Uint32(req.payload[0:4])) + offset := int(binary.LittleEndian.Uint64(req.payload[4:12])) + file, ok := h.getOpenFile(req.fileID) + if !ok || file.isDir { + log.Printf("[rdpdr] read missing file=%d offset=%d length=%d", req.fileID, offset, length) + h.sendIOCompletion(req.deviceID, req.completionID, statusNoSuchFile, nil) + return + } + if length <= 0 { + var out [4]byte + log.Printf("[rdpdr] read zero file=%d path=%q offset=%d", req.fileID, file.path, offset) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:]) + return + } + if offset >= len(file.data) { + var out [4]byte + // Explorer 复制虚拟盘文件时更稳定地接受 0 字节成功读作为 EOF。// Logan.Li + log.Printf("[rdpdr] read eof file=%d path=%q offset=%d size=%d", req.fileID, file.path, offset, len(file.data)) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:]) + return + } + end := offset + length + if end > len(file.data) { + end = len(file.data) + } + out := make([]byte, 4+end-offset) + binary.LittleEndian.PutUint32(out[0:4], uint32(end-offset)) + copy(out[4:], file.data[offset:end]) + log.Printf("[rdpdr] read file=%d path=%q offset=%d requested=%d returned=%d size=%d", req.fileID, file.path, offset, length, end-offset, len(file.data)) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) +} + +func (h *Handler) handleWrite(req ioRequest) { + if len(req.payload) < 32 { + h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, nil) + return + } + length := int(binary.LittleEndian.Uint32(req.payload[0:4])) + offset := int(binary.LittleEndian.Uint64(req.payload[4:12])) + chunk := req.payload[32:] + if length > len(chunk) { + length = len(chunk) + } + file, ok := h.getOpenFile(req.fileID) + if !ok || file.isDir { + h.sendIOCompletion(req.deviceID, req.completionID, statusNoSuchFile, nil) + return + } + need := offset + length + if need > len(file.data) { + next := make([]byte, need) + copy(next, file.data) + file.data = next + } + copy(file.data[offset:need], chunk[:length]) + file.written = true + var out [5]byte + binary.LittleEndian.PutUint32(out[0:4], uint32(length)) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:]) +} + +func (h *Handler) handleQueryInfo(req ioRequest) { + if len(req.payload) < 4 { + h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, nil) + return + } + infoClass := binary.LittleEndian.Uint32(req.payload[0:4]) + file, ok := h.getOpenFile(req.fileID) + if !ok { + h.sendIOCompletion(req.deviceID, req.completionID, statusNoSuchFile, nil) + return + } + attrs := fileAttrArchive + if file.isDir { + attrs = fileAttrDirectory + } + size := uint64(len(file.data)) + log.Printf("[rdpdr] query info file=%d class=%d path=%q dir=%t size=%d", req.fileID, infoClass, file.path, file.isDir, size) + switch infoClass { + case fileBasicInformation: + out := make([]byte, 40) + binary.LittleEndian.PutUint32(out[0:4], 36) + writeFileTimes(out[4:36]) + binary.LittleEndian.PutUint32(out[36:40], attrs) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case fileStandardInformation: + out := make([]byte, 26) + binary.LittleEndian.PutUint32(out[0:4], 22) + binary.LittleEndian.PutUint64(out[4:12], align4096(size)) + binary.LittleEndian.PutUint64(out[12:20], size) + binary.LittleEndian.PutUint32(out[20:24], 1) + if file.isDir { + out[25] = 1 + } + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case fileAttributeTagInformation: + out := make([]byte, 12) + binary.LittleEndian.PutUint32(out[0:4], 8) + binary.LittleEndian.PutUint32(out[4:8], attrs) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case fileInternalInformation: + out := make([]byte, 12) + binary.LittleEndian.PutUint32(out[0:4], 8) + binary.LittleEndian.PutUint64(out[4:12], uint64(req.fileID)) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case fileEaInformation: + out := make([]byte, 8) + binary.LittleEndian.PutUint32(out[0:4], 4) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case fileAccessInformation, fileModeInformation, fileAlignmentInformation: + out := make([]byte, 8) + binary.LittleEndian.PutUint32(out[0:4], 4) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case fileNameInformation: + name := encodeUTF16(fileDisplayName(file)) + out := make([]byte, 8+len(name)) + binary.LittleEndian.PutUint32(out[0:4], uint32(4+len(name))) + binary.LittleEndian.PutUint32(out[4:8], uint32(len(name))) + copy(out[8:], name) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case filePositionInformation: + out := make([]byte, 12) + binary.LittleEndian.PutUint32(out[0:4], 8) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case fileNetworkOpenInformation: + out := make([]byte, 60) + binary.LittleEndian.PutUint32(out[0:4], 56) + writeFileTimes(out[4:36]) + binary.LittleEndian.PutUint64(out[36:44], align4096(size)) + binary.LittleEndian.PutUint64(out[44:52], size) + binary.LittleEndian.PutUint32(out[52:56], attrs) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + default: + log.Printf("[rdpdr] query info unsupported class=%d file=%d path=%q", infoClass, req.fileID, file.path) + h.sendIOCompletion(req.deviceID, req.completionID, statusNotSupported, nil) + } +} + +func (h *Handler) handleSetInfo(req ioRequest) { + var out [4]byte + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:]) +} + +func (h *Handler) handleQueryVolume(req ioRequest) { + if len(req.payload) < 4 { + h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, nil) + return + } + infoClass := binary.LittleEndian.Uint32(req.payload[0:4]) + switch infoClass { + case fileFsVolumeInformation: + label := encodeUTF16(h.driveName) + out := make([]byte, 21+len(label)) + binary.LittleEndian.PutUint32(out[0:4], uint32(17+len(label))) + binary.LittleEndian.PutUint64(out[4:12], filetimeNow()) + binary.LittleEndian.PutUint32(out[12:16], 0x41494f50) + binary.LittleEndian.PutUint32(out[16:20], uint32(len(label))) + copy(out[21:], label) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case fileFsSizeInformation: + out := make([]byte, 28) + binary.LittleEndian.PutUint32(out[0:4], 24) + binary.LittleEndian.PutUint64(out[4:12], 1024*1024) + binary.LittleEndian.PutUint64(out[12:20], 1024*1024) + binary.LittleEndian.PutUint32(out[20:24], 8) + binary.LittleEndian.PutUint32(out[24:28], 512) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case fileFsDeviceInformation: + out := make([]byte, 12) + binary.LittleEndian.PutUint32(out[0:4], 8) + binary.LittleEndian.PutUint32(out[4:8], 0x00000007) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case fileFsAttributeInformation: + fsName := encodeUTF16("AIOPS") + out := make([]byte, 16+len(fsName)) + binary.LittleEndian.PutUint32(out[0:4], uint32(12+len(fsName))) + binary.LittleEndian.PutUint32(out[4:8], 0x00000007) + binary.LittleEndian.PutUint32(out[8:12], 255) + binary.LittleEndian.PutUint32(out[12:16], uint32(len(fsName))) + copy(out[16:], fsName) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + case fileFsFullSizeInformation: + out := make([]byte, 36) + binary.LittleEndian.PutUint32(out[0:4], 32) + binary.LittleEndian.PutUint64(out[4:12], 1024*1024) + binary.LittleEndian.PutUint64(out[12:20], 1024*1024) + binary.LittleEndian.PutUint64(out[20:28], 1024*1024) + binary.LittleEndian.PutUint32(out[28:32], 8) + binary.LittleEndian.PutUint32(out[32:36], 512) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out) + default: + h.sendIOCompletion(req.deviceID, req.completionID, statusNotSupported, nil) + } +} + +func (h *Handler) handleDeviceControl(req ioRequest) { + var out [4]byte + ioctl := uint32(0) + if len(req.payload) >= 12 { + ioctl = binary.LittleEndian.Uint32(req.payload[8:12]) + } + // 资源管理器复制时会探测若干 FSCTL;明确返回“不支持”比 invalid parameter 更接近本地盘行为。// Logan.Li + log.Printf("[rdpdr] device control file=%d ioctl=0x%x payload=%d", req.fileID, ioctl, len(req.payload)) + h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidDeviceReq, out[:]) +} + +func (h *Handler) handleNotifyChangeDirectory(req ioRequest) { + // Windows 资源管理器会长期挂起目录变更通知;立即成功返回会触发重复刷新和“地址无效”。// Logan.Li + log.Printf("[rdpdr] notify change pending file=%d", req.fileID) +} + +func (h *Handler) handleLockControl(req ioRequest) { + var out [4]byte + // 上传暂存文件是只读内存文件,锁请求直接成功,避免 Explorer 复制流程中断。// Logan.Li + log.Printf("[rdpdr] lock control file=%d payload=%d", req.fileID, len(req.payload)) + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:]) +} + +func (h *Handler) handleQueryDirectory(req ioRequest) { + if len(req.payload) < 32 { + h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, nil) + return + } + infoClass := binary.LittleEndian.Uint32(req.payload[0:4]) + initial := req.payload[4] != 0 + file, ok := h.getOpenFile(req.fileID) + if !ok || !file.isDir { + h.sendIOCompletion(req.deviceID, req.completionID, statusNoSuchFile, nil) + return + } + if initial { + file.dirIndex = 0 + if file.path == "" { + file.dirEntries = []dirEntry{{name: "."}, {name: ".."}, {name: downloadDirName, isDir: true}, {name: uploadDirName, isDir: true}} + } else if strings.EqualFold(file.path, uploadDirName) { + file.dirEntries = append([]dirEntry{{name: "."}, {name: ".."}}, h.listUploads()...) + } else { + file.dirEntries = []dirEntry{{name: "."}, {name: ".."}} + } + } + log.Printf("[rdpdr] query dir file=%d class=%d path=%q initial=%t index=%d entries=%d", req.fileID, infoClass, file.path, initial, file.dirIndex, len(file.dirEntries)) + if file.dirIndex >= len(file.dirEntries) { + var out [5]byte + h.sendIOCompletion(req.deviceID, req.completionID, statusNoMoreFiles, out[:]) + return + } + entry := file.dirEntries[file.dirIndex] + file.dirIndex++ + h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, buildDirInfo(infoClass, entry)) +} + +func buildDirInfo(infoClass uint32, entry dirEntry) []byte { + name := encodeUTF16(entry.name) + nameWithNull := append(append([]byte(nil), name...), 0, 0) + attrs := fileAttrNormal + if entry.isDir || entry.name == "." || entry.name == ".." { + attrs = fileAttrDirectory + } + if infoClass == fileNamesInformation { + out := make([]byte, 16+len(nameWithNull)) + binary.LittleEndian.PutUint32(out[0:4], uint32(12+len(nameWithNull))) + binary.LittleEndian.PutUint32(out[12:16], uint32(len(nameWithNull))) + copy(out[16:], nameWithNull) + return out + } + fixed := 64 + if infoClass == fileFullDirectoryInformation { + fixed = 68 + } + if infoClass == fileBothDirectoryInformation { + fixed = 93 + } + out := make([]byte, 4+fixed+len(nameWithNull)) + binary.LittleEndian.PutUint32(out[0:4], uint32(fixed+len(nameWithNull))) + writeFileTimes(out[12:44]) + binary.LittleEndian.PutUint64(out[44:52], entry.size) + binary.LittleEndian.PutUint64(out[52:60], align4096(entry.size)) + binary.LittleEndian.PutUint32(out[60:64], attrs) + binary.LittleEndian.PutUint32(out[64:68], uint32(len(nameWithNull))) + copy(out[4+fixed:], nameWithNull) + return out +} + +func fileDisplayName(file *openFile) string { + if file.name != "" { + return file.name + } + if file.path != "" { + parts := strings.Split(file.path, "\\") + return parts[len(parts)-1] + } + return "\\" +} + +func classifyVirtualPath(vpath string) (string, string) { + if vpath == "" { + return "root", "" + } + parts := strings.Split(vpath, "\\") + if len(parts) == 1 && strings.EqualFold(parts[0], downloadDirName) { + return "download", downloadDirName + } + if len(parts) == 1 && strings.EqualFold(parts[0], uploadDirName) { + return "upload", uploadDirName + } + if len(parts) >= 2 && strings.EqualFold(parts[0], downloadDirName) { + name := parts[len(parts)-1] + if name == "" || name == "." || name == ".." { + return "missing", "" + } + return "file", name + } + if len(parts) >= 2 && strings.EqualFold(parts[0], uploadDirName) { + name := parts[len(parts)-1] + if name == "" || name == "." || name == ".." { + return "missing", "" + } + return "upload_file", name + } + return "missing", "" +} + +func normalizeVirtualPath(raw string) string { + clean := strings.Trim(strings.ReplaceAll(raw, "/", "\\"), "\\\x00 ") + if clean == "" || clean == "." { + return "" + } + parts := strings.FieldsFunc(clean, func(r rune) bool { return r == '\\' }) + safe := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" || part == "." || part == ".." { + continue + } + safe = append(safe, part) + } + return strings.ReplaceAll(path.Clean(strings.Join(safe, "/")), "/", "\\") +} + +func encodeUTF16(s string) []byte { + u16 := utf16.Encode([]rune(s)) + out := make([]byte, len(u16)*2) + for i, v := range u16 { + binary.LittleEndian.PutUint16(out[i*2:], v) + } + return out +} + +func decodeUTF16(data []byte) string { + u16 := make([]uint16, len(data)/2) + for i := range u16 { + u16[i] = binary.LittleEndian.Uint16(data[i*2:]) + } + for len(u16) > 0 && u16[len(u16)-1] == 0 { + u16 = u16[:len(u16)-1] + } + return string(utf16.Decode(u16)) +} + +func writeFileTimes(out []byte) { + ft := filetimeNow() + for i := 0; i+8 <= len(out); i += 8 { + binary.LittleEndian.PutUint64(out[i:i+8], ft) + } +} + +func filetimeNow() uint64 { + return uint64(time.Now().UnixNano()/100) + windowsFiletimeOffset +} + +func align4096(v uint64) uint64 { + return (v + 4095) &^ 4095 +} diff --git a/backend/pkg/rdp/channels/rdpdr/device_io_test.go b/backend/pkg/rdp/channels/rdpdr/device_io_test.go new file mode 100644 index 0000000..21c12c8 --- /dev/null +++ b/backend/pkg/rdp/channels/rdpdr/device_io_test.go @@ -0,0 +1,109 @@ +package rdpdr + +import ( + "encoding/binary" + "testing" +) + +type captureSender struct { + data [][]byte +} + +func (s *captureSender) SendChannelPDU(_ uint16, data []byte) error { + s.data = append(s.data, append([]byte(nil), data...)) + return nil +} + +func TestClassifyVirtualPathUploadFile(t *testing.T) { + kind, name := classifyVirtualPath(`Upload\php.ini`) + if kind != "upload_file" || name != "php.ini" { + t.Fatalf("Upload file should be classified as upload_file, got kind=%q name=%q", kind, name) + } +} + +func TestHandleCreateUploadFileOpensAsFile(t *testing.T) { + sender := &captureSender{} + handler := NewHandler("goclient", "AIOps") + handler.Attach(sender, 1004) + handler.AddUpload("php.ini", []byte("ok")) + + encodedPath := encodeUTF16(`Upload\php.ini`) + payload := make([]byte, 32+len(encodedPath)) + binary.LittleEndian.PutUint32(payload[20:24], fileOpen) + binary.LittleEndian.PutUint32(payload[24:28], fileDirectoryFile) + binary.LittleEndian.PutUint32(payload[28:32], uint32(len(encodedPath))) + copy(payload[32:], encodedPath) + + handler.handleCreate(ioRequest{ + deviceID: 1, + completionID: 7, + majorFn: irpCreate, + payload: payload, + }) + if len(sender.data) == 0 { + t.Fatal("expected create completion") + } + completion := sender.data[len(sender.data)-1] + if len(completion) < 21 { + t.Fatalf("completion too short: %d", len(completion)) + } + if status := binary.LittleEndian.Uint32(completion[12:16]); status != statusSuccess { + t.Fatalf("expected success, got status=0x%08x", status) + } + fileID := binary.LittleEndian.Uint32(completion[16:20]) + file, ok := handler.getOpenFile(fileID) + if !ok { + t.Fatalf("open file %d not stored", fileID) + } + if file.isDir { + t.Fatalf("upload file opened as directory: %#v", file) + } +} + +func TestHandleReadEOFReturnsZeroLengthSuccess(t *testing.T) { + sender := &captureSender{} + handler := NewHandler("goclient", "AIOps") + handler.Attach(sender, 1004) + fileID := handler.storeOpenFile(&openFile{path: `Upload\x.yaml`, name: "x.yaml", data: []byte("abc")}) + + payload := make([]byte, 20) + binary.LittleEndian.PutUint32(payload[0:4], 4096) + binary.LittleEndian.PutUint64(payload[4:12], 3) + handler.handleRead(ioRequest{ + deviceID: 1, + fileID: fileID, + completionID: 9, + payload: payload, + }) + + completion := sender.data[len(sender.data)-1] + if status := binary.LittleEndian.Uint32(completion[12:16]); status != statusSuccess { + t.Fatalf("expected EOF success, got status=0x%08x", status) + } + if got := binary.LittleEndian.Uint32(completion[16:20]); got != 0 { + t.Fatalf("expected zero read length at EOF, got %d", got) + } +} + +func TestHandleDeviceControlReturnsInvalidDeviceRequest(t *testing.T) { + sender := &captureSender{} + handler := NewHandler("goclient", "AIOps") + handler.Attach(sender, 1004) + + payload := make([]byte, 32) + binary.LittleEndian.PutUint32(payload[8:12], 0x000900a8) + handler.handleDeviceControl(ioRequest{ + deviceID: 1, + fileID: 2, + completionID: 10, + payload: payload, + }) + + completion := sender.data[len(sender.data)-1] + if status := binary.LittleEndian.Uint32(completion[12:16]); status != statusInvalidDeviceReq { + t.Fatalf("expected invalid device request, got status=0x%08x", status) + } + if got := binary.LittleEndian.Uint32(completion[16:20]); got != 0 { + t.Fatalf("expected empty device control output, got %d", got) + } +} diff --git a/backend/pkg/rdp/channels/rdpdr/rdpdr.go b/backend/pkg/rdp/channels/rdpdr/rdpdr.go new file mode 100644 index 0000000..2869da3 --- /dev/null +++ b/backend/pkg/rdp/channels/rdpdr/rdpdr.go @@ -0,0 +1,388 @@ +// Package rdpdr 实现 RDP 设备重定向静态虚拟通道的最小握手。// Logan.Li +package rdpdr + +import ( + "bytes" + "encoding/binary" + "fmt" + "log" + "sync" + "unicode/utf16" +) + +type Sender interface { + SendChannelPDU(channelID uint16, data []byte) error +} + +const ChannelName = "rdpdr" + +const ( + componentRDPDR = 0x4472 + + pakIDServerAnnounce = 0x496e + pakIDClientIDConfirm = 0x4343 + pakIDClientName = 0x434e + pakIDServerCoreCap = 0x5350 + pakIDClientCoreCap = 0x4350 + pakIDDeviceListAnnounce = 0x4441 + pakIDDeviceReply = 0x6472 + pakIDDeviceIORequest = 0x4952 + pakIDDeviceIOCompletion = 0x4943 + pakIDUserLoggedOn = 0x554c + + capGeneralType = 0x0001 + capDriveType = 0x0004 + + deviceTypeDisk = 0x00000008 +) + +type Download struct { + Name string + Data []byte +} + +type Handler struct { + sender Sender + channelID uint16 + clientID uint32 + gotServerCap bool + gotIDConfirm bool + serverIOCode1 uint32 + computerName string + driveName string + devicePreannounced bool + deviceAnnounced bool + mu sync.Mutex + nextFileID uint32 + files map[uint32]*openFile + uploads map[string][]byte + onDownload func(Download) +} + +func NewHandler(computerName string, driveName string) *Handler { + if computerName == "" { + computerName = "goclient" + } + if driveName == "" { + driveName = "AIOPS" + } + return &Handler{computerName: computerName, driveName: driveName, nextFileID: 1, files: make(map[uint32]*openFile), uploads: make(map[string][]byte)} +} + +func (h *Handler) SetDownloadHandler(fn func(Download)) { + h.onDownload = fn +} + +func (h *Handler) AddUpload(name string, data []byte) { + if name == "" || len(data) == 0 { + return + } + h.mu.Lock() + h.uploads[name] = append([]byte(nil), data...) + h.mu.Unlock() + log.Printf("[rdpdr] upload staged name=%s size=%d", name, len(data)) +} + +func (h *Handler) listUploads() []dirEntry { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]dirEntry, 0, len(h.uploads)) + for name, data := range h.uploads { + out = append(out, dirEntry{name: name, size: uint64(len(data))}) + } + return out +} + +func (h *Handler) getUpload(name string) ([]byte, bool) { + h.mu.Lock() + defer h.mu.Unlock() + data, ok := h.uploads[name] + if !ok { + return nil, false + } + return append([]byte(nil), data...), true +} + +func (h *Handler) Attach(sender Sender, channelID uint16) { + h.sender = sender + h.channelID = channelID + log.Printf("[rdpdr] attached channelID=%d", channelID) +} + +func (h *Handler) PreannounceDevices() { + h.mu.Lock() + if h.devicePreannounced || h.sender == nil || h.channelID == 0 { + h.mu.Unlock() + return + } + h.devicePreannounced = true + h.mu.Unlock() + // 部分 Windows 会在图形首帧后才启动 rdpdr;预公告客户端能力和磁盘设备,尽早完成驱动器重定向。// Logan.Li + log.Printf("[rdpdr] preannounce devices client=%s drive=%s", h.computerName, h.driveName) + h.sendClientName() + h.sendCoreCapResponse() + h.sendDeviceList(true) +} + +func (h *Handler) Process(data []byte) { + if len(data) < 4 { + return + } + component := binary.LittleEndian.Uint16(data[0:2]) + packetID := binary.LittleEndian.Uint16(data[2:4]) + body := data[4:] + if component != componentRDPDR { + log.Printf("[rdpdr] ignore component=0x%x packet=0x%x len=%d", component, packetID, len(data)) + return + } + log.Printf("[rdpdr] recv packet=0x%x len=%d", packetID, len(body)) + switch packetID { + case pakIDServerAnnounce: + h.handleServerAnnounce(body) + case pakIDServerCoreCap: + h.handleServerCoreCap(body) + case pakIDClientIDConfirm: + h.handleClientIDConfirm(body) + case pakIDDeviceReply: + h.handleDeviceReply(body) + case pakIDDeviceIORequest: + h.handleIORequest(body) + case pakIDUserLoggedOn: + h.sendDeviceListOnce() + default: + log.Printf("[rdpdr] unhandled packet=0x%x len=%d", packetID, len(body)) + } +} + +func (h *Handler) handleServerAnnounce(body []byte) { + if len(body) < 8 { + return + } + h.clientID = binary.LittleEndian.Uint32(body[4:8]) + log.Printf("[rdpdr] server announce clientID=%d version=%d.%d", h.clientID, binary.LittleEndian.Uint16(body[0:2]), binary.LittleEndian.Uint16(body[2:4])) + + buf := make([]byte, 12) + binary.LittleEndian.PutUint16(buf[0:2], componentRDPDR) + binary.LittleEndian.PutUint16(buf[2:4], pakIDClientIDConfirm) + binary.LittleEndian.PutUint16(buf[4:6], 1) + binary.LittleEndian.PutUint16(buf[6:8], 0x000c) + binary.LittleEndian.PutUint32(buf[8:12], h.clientID) + h.send(buf) + h.sendClientName() +} + +func (h *Handler) sendClientName() { + runes := utf16.Encode([]rune(h.computerName + "\x00")) + name := make([]byte, len(runes)*2) + for i, r := range runes { + binary.LittleEndian.PutUint16(name[i*2:], r) + } + buf := make([]byte, 16+len(name)) + binary.LittleEndian.PutUint16(buf[0:2], componentRDPDR) + binary.LittleEndian.PutUint16(buf[2:4], pakIDClientName) + binary.LittleEndian.PutUint32(buf[4:8], 1) + binary.LittleEndian.PutUint32(buf[12:16], uint32(len(name))) + copy(buf[16:], name) + h.send(buf) +} + +func (h *Handler) handleServerCoreCap(body []byte) { + if len(body) < 4 { + return + } + count := binary.LittleEndian.Uint16(body[0:2]) + offset := 4 + for i := 0; i < int(count) && offset+8 <= len(body); i++ { + capType := binary.LittleEndian.Uint16(body[offset:]) + capLen := int(binary.LittleEndian.Uint16(body[offset+2:])) + if capLen < 8 || offset+capLen > len(body) { + break + } + if capType == capGeneralType && capLen >= 24 { + h.serverIOCode1 = binary.LittleEndian.Uint32(body[offset+20 : offset+24]) + } + offset += capLen + } + h.gotServerCap = true + h.trySendReady() +} + +func (h *Handler) handleClientIDConfirm(body []byte) { + if len(body) >= 8 { + h.clientID = binary.LittleEndian.Uint32(body[4:8]) + } + h.gotIDConfirm = true + h.trySendReady() +} + +func (h *Handler) trySendReady() { + if !h.gotServerCap || !h.gotIDConfirm { + return + } + h.sendCoreCapResponse() + // 能力协商完成后立即公告虚拟盘,避免首次登录后资源管理器等待网络位置刷新。// Logan.Li + h.sendDeviceListOnce() +} + +func (h *Handler) sendCoreCapResponse() { + const generalCapLen = 44 + const driveCapLen = 8 + buf := bytes.Buffer{} + writeLE16(&buf, componentRDPDR) + writeLE16(&buf, pakIDClientCoreCap) + writeLE16(&buf, 2) + writeLE16(&buf, 0) + + writeLE16(&buf, capGeneralType) + writeLE16(&buf, generalCapLen) + writeLE32(&buf, 2) + writeLE32(&buf, 0) + writeLE32(&buf, 0) + writeLE16(&buf, 1) + writeLE16(&buf, 0x000c) + ioCode1 := h.serverIOCode1 & 0x0000ffff + if ioCode1 == 0 { + ioCode1 = 0x0000ffff + } + writeLE32(&buf, ioCode1) + writeLE32(&buf, 0) + writeLE32(&buf, 0x00000007) + writeLE32(&buf, 0x00000001) + writeLE32(&buf, 0) + writeLE32(&buf, 0) + + writeLE16(&buf, capDriveType) + writeLE16(&buf, driveCapLen) + writeLE32(&buf, 2) + h.send(buf.Bytes()) +} + +func (h *Handler) sendDeviceList(includeDevices bool) { + deviceData := []byte(nil) + deviceCount := uint32(0) + totalLen := 8 + if includeDevices { + deviceCount = 1 + deviceData = []byte(h.driveName) + totalLen += 20 + len(deviceData) + } + if deviceCount == 0 { + // FreeRDP 在没有预登录设备时直接跳过空公告,避免 Windows 记录一个无设备列表。// Logan.Li + log.Printf("[rdpdr] skip empty device list announce") + return + } + buf := make([]byte, totalLen) + binary.LittleEndian.PutUint16(buf[0:2], componentRDPDR) + binary.LittleEndian.PutUint16(buf[2:4], pakIDDeviceListAnnounce) + binary.LittleEndian.PutUint32(buf[4:8], deviceCount) + if includeDevices { + // 公告一个磁盘设备,Windows 里显示为 \\goclient\AIOPS。// Logan.Li + binary.LittleEndian.PutUint32(buf[8:12], deviceTypeDisk) + binary.LittleEndian.PutUint32(buf[12:16], 1) + copy(buf[16:24], []byte(dosDeviceName(h.driveName))) + binary.LittleEndian.PutUint32(buf[24:28], uint32(len(deviceData))) + copy(buf[28:], deviceData) + } + h.send(buf) + log.Printf("[rdpdr] device list announced devices=%d name=%s", deviceCount, h.driveName) +} + +func (h *Handler) sendDeviceListOnce() { + h.mu.Lock() + if h.deviceAnnounced { + h.mu.Unlock() + return + } + h.deviceAnnounced = true + h.mu.Unlock() + h.sendDeviceList(true) +} + +func dosDeviceName(name string) string { + if name == "" { + return "AIOPS" + } + if len(name) > 8 { + return name[:8] + } + return name +} + +func (h *Handler) send(data []byte) { + if h.sender == nil || h.channelID == 0 { + return + } + if err := h.sender.SendChannelPDU(h.channelID, data); err != nil { + log.Printf("[rdpdr] send failed: %v", err) + } +} + +func (h *Handler) sendIOCompletion(deviceID, completionID, status uint32, output []byte) { + buf := make([]byte, 16+len(output)) + binary.LittleEndian.PutUint16(buf[0:2], componentRDPDR) + binary.LittleEndian.PutUint16(buf[2:4], pakIDDeviceIOCompletion) + binary.LittleEndian.PutUint32(buf[4:8], deviceID) + binary.LittleEndian.PutUint32(buf[8:12], completionID) + binary.LittleEndian.PutUint32(buf[12:16], status) + copy(buf[16:], output) + h.send(buf) +} + +func (h *Handler) allocFileID() uint32 { + h.mu.Lock() + defer h.mu.Unlock() + id := h.nextFileID + h.nextFileID++ + return id +} + +func (h *Handler) storeOpenFile(file *openFile) uint32 { + id := h.allocFileID() + h.mu.Lock() + h.files[id] = file + h.mu.Unlock() + return id +} + +func (h *Handler) getOpenFile(id uint32) (*openFile, bool) { + h.mu.Lock() + defer h.mu.Unlock() + file, ok := h.files[id] + return file, ok +} + +func (h *Handler) removeOpenFile(id uint32) (*openFile, bool) { + h.mu.Lock() + defer h.mu.Unlock() + file, ok := h.files[id] + if ok { + delete(h.files, id) + } + return file, ok +} + +func (h *Handler) handleDeviceReply(body []byte) { + if len(body) < 8 { + return + } + deviceID := binary.LittleEndian.Uint32(body[0:4]) + status := binary.LittleEndian.Uint32(body[4:8]) + if status == statusSuccess { + h.mu.Lock() + h.deviceAnnounced = true + h.mu.Unlock() + } + log.Printf("[rdpdr] device reply deviceID=%d status=0x%08x", deviceID, status) +} + +func writeLE16(w *bytes.Buffer, v uint16) { + _ = binary.Write(w, binary.LittleEndian, v) +} + +func writeLE32(w *bytes.Buffer, v uint32) { + _ = binary.Write(w, binary.LittleEndian, v) +} + +func (h *Handler) String() string { + return fmt.Sprintf("rdpdr(channel=%d)", h.channelID) +} diff --git a/backend/pkg/rdp/domain/types.go b/backend/pkg/rdp/domain/types.go index df19d44..575912d 100644 --- a/backend/pkg/rdp/domain/types.go +++ b/backend/pkg/rdp/domain/types.go @@ -31,9 +31,14 @@ type ClientEvent struct { Code string `json:"code,omitempty"` Key string `json:"key,omitempty"` Down bool `json:"down,omitempty"` + Ctrl bool `json:"ctrl,omitempty"` + Alt bool `json:"alt,omitempty"` + Shift bool `json:"shift,omitempty"` + Meta bool `json:"meta,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` } // ServerFrame 是后端推给浏览器 Canvas 的统一帧协议 // Logan.Li @@ -46,6 +51,9 @@ type ServerFrame struct { Image string `json:"image,omitempty"` Message string `json:"message,omitempty"` Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + Size int64 `json:"size,omitempty"` RGBA []byte `json:"-"` } diff --git a/backend/pkg/rdpfilestage/stage.go b/backend/pkg/rdp/filestage/stage.go similarity index 97% rename from backend/pkg/rdpfilestage/stage.go rename to backend/pkg/rdp/filestage/stage.go index c8fe85d..f9eaf51 100644 --- a/backend/pkg/rdpfilestage/stage.go +++ b/backend/pkg/rdp/filestage/stage.go @@ -1,4 +1,4 @@ -package rdpfilestage +package filestage import ( "sync" @@ -38,4 +38,4 @@ func Get(token string) (Item, bool) { return Item{}, false } return item, true -} \ No newline at end of file +} diff --git a/backend/pkg/rdp/input_fastpath.go b/backend/pkg/rdp/input_fastpath.go index 195f9a2..9ea1d1e 100644 --- a/backend/pkg/rdp/input_fastpath.go +++ b/backend/pkg/rdp/input_fastpath.go @@ -136,17 +136,10 @@ func (s *InputSender) AutoExec(command string) error { if err := s.Key(0xE05B, true); err != nil { return err } - if err := s.Key(0xE05B, false); err != nil { + if err := s.Key(0x0013, true); err != nil { return err } - time.Sleep(80 * time.Millisecond) - if err := s.Key(0xE05B, true); err != nil { - return err - } - if err := s.Key(0x0015, true); err != nil { - return err - } - if err := s.Key(0x0015, false); err != nil { + if err := s.Key(0x0013, false); err != nil { return err } if err := s.Key(0xE05B, false); err != nil { @@ -157,5 +150,9 @@ func (s *InputSender) AutoExec(command string) error { return err } time.Sleep(80 * time.Millisecond) - return s.Key(0x001C, true) + if err := s.Key(0x001C, true); err != nil { + return err + } + time.Sleep(20 * time.Millisecond) + return s.Key(0x001C, false) } diff --git a/backend/pkg/rdp/mcs_probe.go b/backend/pkg/rdp/mcs_probe.go index 77690ae..82c0c83 100644 --- a/backend/pkg/rdp/mcs_probe.go +++ b/backend/pkg/rdp/mcs_probe.go @@ -37,6 +37,17 @@ const ( gccSCSecurity = 0x0C02 gccSCNet = 0x0C03 + // 静态虚拟通道声明(MS-RDPBCGR 2.2.7.1.1 Channel Pool) + // channel name 固定 8 字节 ASCII,不足补 0;options 控制通道加密/压缩行为 // Logan.Li + channelNameRdpdr = "rdpdr" + channelNameRdpsnd = "rdpsnd" + channelNameCliprdr = "cliprdr" + channelNameDrdynvc = "drdynvc" + channelOptionInitialized = 0x80000000 + channelOptionEncryptRDP = 0x40000000 + channelOptionShowProtocol = 0x00200000 + channelOptionCompressRDP = 0x00800000 + secFlagInfoPkt = 0x0040 secFlagLicensePkt = 0x0080 @@ -65,11 +76,15 @@ var errFinalizeActive = errors.New("RDP 会话已进入图形更新流") // MCSProbeInfo 记录 NLA 后 MCS/GCC 连接期响应,用于确认图形会话层入口是否跑通 // Logan.Li type MCSProbeInfo struct { HandshakeInfo - UserDataBytes int - ServerCoreVersion uint32 - ServerChannelID uint16 - ServerChannelCount uint16 - ServerChannelIDs []uint16 + UserDataBytes int + ServerCoreVersion uint32 + ServerChannelID uint16 + ServerChannelCount uint16 + ServerChannelIDs []uint16 + // ChannelNameByID 记录服务端在 Connect-Response 里分配的 channel ID 对应的名称。 + // 客户端在 Connect-Initial 里声明的 channel def 顺序与服务端返回的 ServerChannelIDs 一一对应, + // 据此建立 ID→名称映射,供 readLoop 按通道分发虚拟通道数据(cliprdr/rdpdr)。// Logan.Li + ChannelNameByID map[uint16]string ServerEncryption uint32 ServerEncryptionLvl uint32 UserChannelID uint16 @@ -179,7 +194,7 @@ func probeMCSConnectOnce(ctx context.Context, cfg Config, address string, opts c mcsInfo.DemandCapabilities = demand.capabilityCount mcsInfo.DemandSource = string(demand.sourceDescriptor) rdpDebugf(cfg, "demand active shared=0x%08x caps=%d source=%q firstSecurity=0x%04x firstPayload=%d", demand.sharedID, demand.capabilityCount, string(demand.sourceDescriptor), first.securityFlags, len(first.payload)) - postTypes, err := sendConfirmActiveAndFinalize(tlsConn, cfg, userID, demand) + postTypes, _, err := sendConfirmActiveAndFinalize(tlsConn, cfg, userID, demand) if err != nil { return MCSProbeInfo{}, fmt.Errorf("Client Finalize 失败: shared=0x%08x caps=%d source=%q: %w", demand.sharedID, demand.capabilityCount, string(demand.sourceDescriptor), err) } @@ -258,7 +273,7 @@ func sendClientInfoAndReadDemand(rw io.ReadWriter, cfg Config, userID uint16) (m return first, demand, nil } -func sendConfirmActiveAndFinalize(rw io.ReadWriter, cfg Config, userID uint16, demand demandActiveProbe) ([]uint8, error) { +func sendConfirmActiveAndFinalize(rw io.ReadWriter, cfg Config, userID uint16, demand demandActiveProbe) ([]uint8, []mcsDataIndication, error) { rdpDebugf(cfg, "finalize start userID=%d shared=0x%08x", userID, demand.sharedID) pdus := []struct { name string @@ -274,22 +289,22 @@ func sendConfirmActiveAndFinalize(rw io.ReadWriter, cfg Config, userID uint16, d for _, pdu := range pdus { rdpDebugf(cfg, "finalize send %s bytes=%d", pdu.name, len(pdu.data)) if err := transport.WriteX224Data(rw, buildMCSSendDataRequest(userID, mcsGlobalChannelID, pdu.data)); err != nil { - return nil, fmt.Errorf("发送 %s 失败: %w", pdu.name, err) + return nil, nil, fmt.Errorf("发送 %s 失败: %w", pdu.name, err) } if isRDPStepMode(cfg) { if stepTypes, err := readFinalizeStep(rw, pdu.name, 500*time.Millisecond); err != nil { - return append(types, stepTypes...), err + return append(types, stepTypes...), nil, err } else if len(stepTypes) > 0 { types = append(types, stepTypes...) } } } - readTypes, err := waitFinalizeResponses(rw, cfg, 5*time.Second) + readTypes, pending, err := waitFinalizeResponses(rw, cfg, 5*time.Second) if err != nil { - return types, err + return types, pending, err } types = append(types, readTypes...) - return types, nil + return types, pending, nil } func isRDPStepMode(cfg Config) bool { @@ -321,7 +336,7 @@ func readFinalizeStep(rw io.ReadWriter, name string, timeout time.Duration) ([]u return []uint8{got.type2}, nil } -func waitFinalizeResponses(rw io.ReadWriter, cfg Config, timeout time.Duration) ([]uint8, error) { +func waitFinalizeResponses(rw io.ReadWriter, cfg Config, timeout time.Duration) ([]uint8, []mcsDataIndication, error) { if conn, ok := rw.(interface{ SetReadDeadline(time.Time) error }); ok { _ = conn.SetReadDeadline(time.Now().Add(timeout)) defer conn.SetReadDeadline(time.Time{}) @@ -333,28 +348,36 @@ func waitFinalizeResponses(rw io.ReadWriter, cfg Config, timeout time.Duration) {type2: pduType2FontMap}, } var types []uint8 + var pending []mcsDataIndication for _, want := range expected { for { payload, err := readNextSlowPathPayload(rw, cfg, timeout) if err != nil { if errors.Is(err, errFinalizeActive) || len(types) > 0 { rdpDebugf(cfg, "finalize accept active session types=%v err=%v", types, err) - return types, nil + return types, pending, nil } - return types, fmt.Errorf("读取 Client Finalize 响应失败: %w", err) + return types, pending, fmt.Errorf("读取 Client Finalize 响应失败: %w", err) } data, err := parseMCSDataIndication(payload) if err != nil { - return types, err + return types, pending, err + } + // 虚拟通道数据(cliprdr/rdpdr 等,channel ≠ 1003 global)在 finalize 阶段 + // 先缓存,等 SessionEngine 创建对应 handler 后再分发。 // Logan.Li + if data.channelID != mcsGlobalChannelID { + rdpDebugf(cfg, "finalize skip virtual channel data channel=%d payload=%d", data.channelID, len(data.payload)) + pending = append(pending, data) + continue } got, err := parseShareDataPDUInfo(data.payload) if err != nil { - return types, err + return types, pending, err } types = append(types, got.type2) rdpDebugf(cfg, "finalize recv type2=0x%02x action=0x%04x raw=%s", got.type2, got.controlAction, got.rawPrefix) if got.type2 == pduType2SetErrorInfo { - return types, fmt.Errorf("服务端返回 ErrorInfo: code=0x%08x(%s) seen=%v raw=%s", got.errorInfo, rdpErrorInfoName(got.errorInfo), types, got.rawPrefix) + return types, pending, fmt.Errorf("服务端返回 ErrorInfo: code=0x%08x(%s) seen=%v raw=%s", got.errorInfo, rdpErrorInfoName(got.errorInfo), types, got.rawPrefix) } if got.type2 == pduType2SaveSessionInfo { continue @@ -368,7 +391,7 @@ func waitFinalizeResponses(rw io.ReadWriter, cfg Config, timeout time.Duration) break } } - return types, nil + return types, pending, nil } func readNextSlowPathPayload(r io.Reader, cfg Config, timeout time.Duration) ([]byte, error) { @@ -492,13 +515,13 @@ func buildConfirmCapabilities(cfg Config) ([]byte, uint16) { writeCapability(&out, capType, body) count++ } - if cfg.MCSMode == "grdp-comment" || cfg.MCSMode == "step-grdp-comment" { + if cfg.MCSMode == "comment" || cfg.MCSMode == "step-comment" || cfg.MCSMode == "grdp-comment" || cfg.MCSMode == "step-grdp-comment" { return buildGRDPCommentCapabilities(cfg, width, height) } addCapability(0x0001, buildGeneralCapability()) bitmap := bytes.Buffer{} preferredBPP := uint16(24) - if cfg.MCSMode == "bpp32" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" || cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" { + if cfg.MCSMode == "bpp32" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "modern" || cfg.MCSMode == "step-modern" || cfg.MCSMode == "raw" || cfg.MCSMode == "step-raw" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" || cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" { preferredBPP = 32 } writeLE16(&bitmap, preferredBPP) @@ -509,7 +532,7 @@ func buildConfirmCapabilities(cfg Config) ([]byte, uint16) { writeLE16(&bitmap, uint16(height)) writeLE16(&bitmap, 0) writeLE16(&bitmap, 1) - if cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" { + if cfg.MCSMode == "raw" || cfg.MCSMode == "step-raw" || cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" { writeLE16(&bitmap, 0) } else { writeLE16(&bitmap, 1) @@ -544,7 +567,7 @@ func buildConfirmCapabilities(cfg Config) ([]byte, uint16) { addCapability(0x000e, mustHex("01000000")) return out.Bytes(), count } - if cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" || cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" { + if cfg.MCSMode == "modern" || cfg.MCSMode == "step-modern" || cfg.MCSMode == "raw" || cfg.MCSMode == "step-raw" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" || cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" { addCapability(0x0003, buildGRDPOrderCapability()) addCapability(0x0005, mustHex("0000000002000200")) addCapability(0x0007, mustHex("0000000000000000")) @@ -575,7 +598,7 @@ func buildConfirmCapabilities(cfg Config) ([]byte, uint16) { addCapability(0x0010, make([]byte, 48)) addCapability(0x0013, buildBitmapCacheRev2Capability()) addCapability(0x0011, mustHex("0000000000000000")) - addCapability(0x0014, mustHex("0000000000000000")) + addCapability(0x0014, mustHex("0000000040060000")) addCapability(0x001a, mustHex("00000000")) return out.Bytes(), count } @@ -887,6 +910,15 @@ func parseMCSChannelJoinConfirm(data []byte, expectedUserID uint16, expectedChan return channelID, nil } +// ParseMCSChannel 解析 MCS SendDataIndication,返回 channel ID(探针诊断用)。// Logan.Li +func ParseMCSChannel(data []byte) (uint16, error) { + ind, err := parseMCSDataIndication(data) + if err != nil { + return 0, err + } + return ind.channelID, nil +} + func parseMCSDataIndication(data []byte) (mcsDataIndication, error) { r := bytes.NewReader(data) pdu, err := readMCSPDUHeader(r) @@ -1118,7 +1150,7 @@ func buildGCCClientCore(cfg Config) []byte { } highColorDepth := uint16(24) supportedColorDepths := uint16(0x000f) - if cfg.MCSMode == "bpp32" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" { + if cfg.MCSMode == "bpp32" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "modern" || cfg.MCSMode == "step-modern" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" { highColorDepth = 32 supportedColorDepths = 0x001f } @@ -1148,9 +1180,28 @@ func buildGCCClientCore(cfg Config) []byte { return withGCCBlock(gccCSCore, body.Bytes()) } +// declaredStaticChannels 是客户端在 MCS Connect-Initial 里声明的静态虚拟通道。 +// 顺序与服务端 Connect-Response 的 SCNet.ChannelIdArray 一一对应,保持接近 MSTSC/FreeRDP 的通道组合。// Logan.Li +var declaredStaticChannels = []struct { + Name string + Options uint32 +}{ + {Name: channelNameRdpdr, Options: channelOptionInitialized | channelOptionEncryptRDP}, + {Name: channelNameRdpsnd, Options: channelOptionInitialized | channelOptionEncryptRDP}, + {Name: channelNameCliprdr, Options: channelOptionInitialized | channelOptionEncryptRDP | channelOptionShowProtocol}, + {Name: channelNameDrdynvc, Options: channelOptionInitialized | channelOptionCompressRDP}, +} + func buildGCCClientNetwork() []byte { body := bytes.Buffer{} - writeLE32(&body, 0) + writeLE32(&body, uint32(len(declaredStaticChannels))) // channelCount + for _, ch := range declaredStaticChannels { + // channel name: 8 字节 ASCII,不足补 0 // Logan.Li + nameBuf := make([]byte, 8) + copy(nameBuf, ch.Name) + body.Write(nameBuf) + writeLE32(&body, ch.Options) + } return withGCCBlock(gccCSNet, body.Bytes()) } @@ -1256,6 +1307,13 @@ func parseGCCServerBlock(blockType uint16, body []byte, info *MCSProbeInfo) { for i := 0; i < int(info.ServerChannelCount) && r.Len() >= 2; i++ { channelID, _ := readLE16(r) info.ServerChannelIDs = append(info.ServerChannelIDs, channelID) + // 与 buildGCCClientNetwork 声明的 declaredStaticChannels 按顺序对应,建立 ID→名称映射。 // Logan.Li + if i < len(declaredStaticChannels) { + if info.ChannelNameByID == nil { + info.ChannelNameByID = make(map[uint16]string) + } + info.ChannelNameByID[channelID] = declaredStaticChannels[i].Name + } } case 0x0C04: info.MessageChannelID, _ = readLE16(r) diff --git a/backend/pkg/rdp/session_connect.go b/backend/pkg/rdp/session_connect.go index ff78e16..ed574c0 100644 --- a/backend/pkg/rdp/session_connect.go +++ b/backend/pkg/rdp/session_connect.go @@ -19,6 +19,11 @@ type Session struct { SharedID uint32 Width int Height int + // ChannelNameByID 记录服务端分配的静态虚拟通道 ID→名称(cliprdr 等), + // 供 readLoop 按通道分发数据给对应 Handler。空表示服务端未授权任何虚拟通道。// Logan.Li + ChannelNameByID map[uint16]string + // PendingChannelPDUs 缓存握手收尾阶段提前到达的虚拟通道包,避免 cliprdr 初始化被吞。// Logan.Li + PendingChannelPDUs []mcsDataIndication } // OpenSession 完成 CredSSP + MCS/GCC 握手并进入可收帧状态 // Logan.Li @@ -31,7 +36,7 @@ func OpenSession(ctx context.Context, cfg Config) (*Session, error) { cfg.MCSMode = strings.TrimSpace(os.Getenv("RDP_MCS_MODE")) } if cfg.MCSMode == "" { - cfg.MCSMode = "grdp-modern" + cfg.MCSMode = "modern" } address := fmt.Sprintf("%s:%d", cfg.Host, port) strategies := []credssp.Options{ @@ -80,6 +85,9 @@ func openSessionOnce(ctx context.Context, cfg Config, address string, opts creds } mcsInfo, _ := parseMCSConnectResponse(payload) joinIDs := append([]uint16{mcsGlobalChannelID, userID}, mcsInfo.ServerChannelIDs...) + if mcsInfo.MessageChannelID != 0 { + joinIDs = append(joinIDs, mcsInfo.MessageChannelID) + } for _, channelID := range joinIDs { if _, err := joinMCSChannel(tlsConn, userID, channelID); err != nil { _ = tlsConn.Close() @@ -91,14 +99,17 @@ func openSessionOnce(ctx context.Context, cfg Config, address string, opts creds _ = tlsConn.Close() return nil, err } - if _, err := sendConfirmActiveAndFinalize(tlsConn, cfg, userID, demand); err != nil { + _, pending, err := sendConfirmActiveAndFinalize(tlsConn, cfg, userID, demand) + if err != nil { _ = tlsConn.Close() return nil, err } - if err := requestDisplayUpdates(tlsConn, cfg, userID, demand.sharedID); err != nil { + morePending, err := requestDisplayUpdates(tlsConn, cfg, userID, demand.sharedID) + if err != nil { _ = tlsConn.Close() return nil, err } + pending = append(pending, morePending...) width := cfg.Width if width <= 0 { width = 1280 @@ -109,29 +120,34 @@ func openSessionOnce(ctx context.Context, cfg Config, address string, opts creds } _ = tlsConn.SetDeadline(time.Time{}) return &Session{ - Conn: tlsConn, - UserID: userID, - SharedID: demand.sharedID, - Width: width, - Height: height, + Conn: tlsConn, + UserID: userID, + SharedID: demand.sharedID, + Width: width, + Height: height, + ChannelNameByID: mcsInfo.ChannelNameByID, + PendingChannelPDUs: pending, }, nil } -func requestDisplayUpdates(rw io.ReadWriter, cfg Config, userID uint16, sharedID uint32) error { +func requestDisplayUpdates(rw io.ReadWriter, cfg Config, userID uint16, sharedID uint32) ([]mcsDataIndication, error) { + var pending []mcsDataIndication if conn, ok := rw.(interface{ SetReadDeadline(time.Time) error }); ok { _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) pkt, err := ReadWirePacket(rw) _ = conn.SetReadDeadline(time.Time{}) if err == nil && !pkt.FastPath { - _, _ = parseMCSDataIndication(pkt.Payload) + if ind, perr := parseMCSDataIndication(pkt.Payload); perr == nil && ind.channelID != mcsGlobalChannelID { + pending = append(pending, ind) + } } } body := buildSuppressOutputBody(cfg, true) pdu := buildDataPDU(userID, sharedID, pduType2SuppressOutput, body) if err := transport.WriteX224Data(rw, buildMCSSendDataRequest(userID, mcsGlobalChannelID, pdu)); err != nil { - return fmt.Errorf("发送 SuppressOutput 失败: %w", err) + return pending, fmt.Errorf("发送 SuppressOutput 失败: %w", err) } - return nil + return pending, nil } func buildSuppressOutputBody(cfg Config, allow bool) []byte { diff --git a/backend/pkg/rdp/session_engine.go b/backend/pkg/rdp/session_engine.go index caeae0f..0476095 100644 --- a/backend/pkg/rdp/session_engine.go +++ b/backend/pkg/rdp/session_engine.go @@ -2,16 +2,82 @@ package rdp import ( "context" + "encoding/base64" + "encoding/binary" "fmt" "io" + "log" + "strings" "sync" "time" + "unicode/utf16" + "ezops/pkg/rdp/channels/cliprdr" + "ezops/pkg/rdp/channels/rdpdr" "ezops/pkg/rdp/display" + "ezops/pkg/rdp/filestage" "ezops/pkg/rdp/trace" "ezops/pkg/rdp/transport" ) +// channelSender 适配 cliprdr.Sender 接口:把虚拟通道 PDU 通过 MCS SendDataRequest +// 发往指定 channelID,复用会话 writeMu 避免与 FastPath 输入并发写连接。// Logan.Li +type channelSender struct { + conn io.ReadWriter + userID uint16 + writeMu *sync.Mutex + showProtocol bool +} + +// 虚拟通道数据标志(MS-RDPBCGR 2.2.6.1 Virtual Channel PDU Header) +const ( + secFlagChannelPkt = 0x0004 + channelFlagFirst = 0x00000001 + channelFlagLast = 0x00000002 + channelFlagShow = 0x00000010 + channelChunkLength = 1600 // 单个 MCS PDU 的虚拟通道数据上限 +) + +// SendChannelPDU 按 MS-RDPBCGR 静态虚拟通道格式发送: +// 所有静态虚拟通道发送都带 Channel PDU Header,showProtocol 只控制 flags。// Logan.Li +func (s *channelSender) SendChannelPDU(channelID uint16, data []byte) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + totalLen := uint32(len(data)) + baseFlag := uint32(0) + if s.showProtocol { + baseFlag |= channelFlagShow + } + remaining := len(data) + offset := 0 + first := true + for remaining > 0 { + chunkSize := remaining + if chunkSize > channelChunkLength { + chunkSize = channelChunkLength + } + flag := baseFlag + if first { + flag |= channelFlagFirst + first = false + } + remaining -= chunkSize + if remaining == 0 { + flag |= channelFlagLast + } + // 虚拟通道数据格式: [Channel PDU Header(totalLen+flags)] + [data]。// Logan.Li + buf := make([]byte, 8+chunkSize) + binary.LittleEndian.PutUint32(buf[0:], totalLen) // Channel PDU totalLen + binary.LittleEndian.PutUint32(buf[4:], flag) // Channel PDU flags + copy(buf[8:], data[offset:offset+chunkSize]) + if err := transport.WriteX224Data(s.conn, buildMCSSendDataRequest(s.userID, channelID, buf)); err != nil { + return err + } + offset += chunkSize + } + return nil +} + // SessionEngine 是自研完整 RDP 图形会话引擎 // Logan.Li type SessionEngine struct{} @@ -43,11 +109,77 @@ func (e *SessionEngine) Run(ctx context.Context, cfg Config, sink FrameSink, eve input := NewInputSenderWithLock(sess.Conn, writeMu) receiver := NewFastPathReceiver() + // 剪贴板虚拟通道:服务端授权了 cliprdr 通道才创建 Handler。// Logan.Li + var clip *cliprdr.Handler + var clipChannelID uint16 + var drive *rdpdr.Handler + var driveChannelID uint16 + log.Printf("[RDP] mcs channels userID=%d global=%d granted=%d map=%v", sess.UserID, mcsGlobalChannelID, len(sess.ChannelNameByID), sess.ChannelNameByID) + for id, name := range sess.ChannelNameByID { + if name == cliprdr.ChannelName { + clipChannelID = id + } + if name == rdpdr.ChannelName { + driveChannelID = id + } + } + if driveChannelID != 0 { + drive = rdpdr.NewHandler("Go-client", "AIOps") + drive.SetDownloadHandler(func(file rdpdr.Download) { + if text, ok := decodeRDPClipboardFallback(file); ok { + _ = sink.SendFrame(ServerFrame{Type: "clipboard", Text: text, Message: "已读取远端剪贴板"}) + log.Printf("[RDP] rdpdr clipboard fallback ready len=%d", len(text)) + return + } + token := filestage.Put(file.Name, file.Data, 30*time.Minute) + _ = sink.SendFrame(ServerFrame{ + Type: "rdp_file_download", + Name: file.Name, + URL: "/api/v1/rdp-staged/" + token, + Size: int64(len(file.Data)), + Message: "已捕获远端文件:" + file.Name, + }) + log.Printf("[RDP] rdpdr download ready name=%s size=%d token=%s", file.Name, len(file.Data), token) + }) + drive.Attach(&channelSender{conn: sess.Conn, userID: sess.UserID, writeMu: writeMu}, driveChannelID) + log.Printf("[RDP] rdpdr channel attached id=%d", driveChannelID) + drive.PreannounceDevices() + go func() { + time.Sleep(2 * time.Second) + drive.PreannounceDevices() + }() + } + if clipChannelID != 0 { + clip = cliprdr.NewHandler(func(text string) { + // 远端→本地:把远端剪贴板文本通过 WS clipboard 帧推给浏览器 + _ = sink.SendFrame(ServerFrame{Type: "clipboard", Text: text}) + log.Printf("[RDP] clipboard remote→local len=%d", len(text)) + }) + clip.Attach(&channelSender{conn: sess.Conn, userID: sess.UserID, writeMu: writeMu, showProtocol: true}, clipChannelID) + log.Printf("[RDP] cliprdr channel attached id=%d", clipChannelID) + go func() { + time.Sleep(1500 * time.Millisecond) + clip.Init() + }() + for _, ind := range sess.PendingChannelPDUs { + if ind.channelID == clipChannelID { + e.dispatchVirtualChannelPDU(ind, clip, clipChannelID) + } + if drive != nil && ind.channelID == driveChannelID { + e.dispatchRdpdrPDU(ind, drive, driveChannelID) + } + } + } else { + log.Printf("[RDP] cliprdr channel NOT granted by server, clipboard sync disabled (channels=%v)", sess.ChannelNameByID) + } + + // 会话激活后主动发起 cliprdr 握手,兼容等待客户端先声明能力的 Windows 版本。// Logan.Li + readErr := make(chan error, 1) go func() { - readErr <- e.readLoop(ctx, sess, receiver, sink, writeMu) + readErr <- e.readLoop(ctx, sess, receiver, sink, writeMu, clip, clipChannelID, drive, driveChannelID) }() - go e.inputLoop(ctx, input, sink, events) + go e.inputLoop(ctx, input, sink, events, clip, drive) for { select { @@ -62,7 +194,7 @@ func (e *SessionEngine) Run(ctx context.Context, cfg Config, sink FrameSink, eve } } -func (e *SessionEngine) inputLoop(ctx context.Context, input *InputSender, sink FrameSink, events <-chan ClientEvent) { +func (e *SessionEngine) inputLoop(ctx context.Context, input *InputSender, sink FrameSink, events <-chan ClientEvent, clip *cliprdr.Handler, drive *rdpdr.Handler) { for { select { case <-ctx.Done(): @@ -74,18 +206,19 @@ func (e *SessionEngine) inputLoop(ctx context.Context, input *InputSender, sink if event.Type != "mousemove" && event.Type != "mouse_move" { trace.Logf("engine=rdp input type=%s x=%d y=%d button=%d code=%s down=%t", event.Type, event.X, event.Y, event.Button, event.Code, event.Down) } - if err := e.handleEvent(input, sink, event); err != nil { + if err := e.handleEvent(input, sink, event, clip, drive); err != nil { _ = sink.SendFrame(ServerFrame{Type: "status", Message: "输入事件失败:" + err.Error()}) } } } } -func (e *SessionEngine) readLoop(ctx context.Context, sess *Session, receiver *FastPathReceiver, sink FrameSink, writeMu *sync.Mutex) error { +func (e *SessionEngine) readLoop(ctx context.Context, sess *Session, receiver *FastPathReceiver, sink FrameSink, writeMu *sync.Mutex, clip *cliprdr.Handler, clipChannelID uint16, drive *rdpdr.Handler, driveChannelID uint16) error { framebuffer := display.NewFramebuffer(sess.Width, sess.Height) ticker := time.NewTicker(16 * time.Millisecond) defer ticker.Stop() refreshRequested := false + usefulFrameSeen := false flushFrame := func() { frame, ok := framebuffer.FlushRGBA() if !ok { @@ -105,15 +238,23 @@ func (e *SessionEngine) readLoop(ctx context.Context, sess *Session, receiver *F if rect.Width <= 0 || rect.Height <= 0 { continue } - if !refreshRequested && isBlackFullScreen(rect, sess.Width, sess.Height) { - refreshRequested = true - go func() { - time.Sleep(400 * time.Millisecond) - if err := e.requestRefresh(sess, writeMu); err != nil { - trace.Logf("engine=rdp refresh request failed err=%v", err) - } - }() + if isBlackFullScreen(rect, sess.Width, sess.Height) { + if !refreshRequested { + refreshRequested = true + go func() { + time.Sleep(400 * time.Millisecond) + if err := e.requestRefresh(sess, writeMu); err != nil { + trace.Logf("engine=rdp refresh request failed err=%v", err) + } + }() + } + if !usefulFrameSeen { + // 登录初期的纯黑全屏帧通常是 RDP 激活噪声,跳过可避免浏览器闪黑。 // Logan.Li + trace.Logf("engine=rdp skip startup black fullscreen") + continue + } } + usefulFrameSeen = true framebuffer.Draw(display.Bitmap{ X: rect.DestLeft, Y: rect.DestTop, @@ -161,6 +302,22 @@ func (e *SessionEngine) readLoop(ctx context.Context, sess *Session, receiver *F if got.packet.FastPath { rects, err = receiver.Handle(got.packet.SecFlag, got.packet.Payload) } else { + // SlowPath:先解析 channelID 判断是图形(global 1003)还是虚拟通道(cliprdr 等) // Logan.Li + if ind, perr := parseMCSDataIndication(got.packet.Payload); perr == nil { + if clip != nil && ind.channelID == clipChannelID { + e.dispatchVirtualChannelPDU(ind, clip, clipChannelID) + continue + } + if drive != nil && ind.channelID == driveChannelID { + e.dispatchRdpdrPDU(ind, drive, driveChannelID) + continue + } + if ind.channelID != mcsGlobalChannelID { + log.Printf("[RDP] virtual channel ignored channel=%d name=%s payload=%d", ind.channelID, sess.ChannelNameByID[ind.channelID], len(ind.payload)) + continue + } + logSlowPathSummary(ind, clipChannelID) + } rects, err = receiver.HandleSlowPath(got.packet.Payload) } if err != nil { @@ -193,6 +350,129 @@ func isBlackFullScreen(rect BitmapRect, width, height int) bool { return limit > 0 } +func (e *SessionEngine) dispatchRdpdrPDU(ind mcsDataIndication, drive *rdpdr.Handler, driveChannelID uint16) { + if drive == nil || ind.channelID != driveChannelID { + return + } + prefixLen := len(ind.payload) + if prefixLen > 12 { + prefixLen = 12 + } + log.Printf("[RDP] rdpdr payload prefix=%x len=%d", ind.payload[:prefixLen], len(ind.payload)) + if len(ind.payload) >= 4 && binary.LittleEndian.Uint16(ind.payload[0:2]) == 0x4472 { + log.Printf("[RDP] rdpdr recv channel=%d rawLen=%d raw=true", ind.channelID, len(ind.payload)) + drive.Process(ind.payload) + return + } + if len(ind.payload) >= 8 && binary.LittleEndian.Uint32(ind.payload[0:4]) <= uint32(len(ind.payload)) && binary.LittleEndian.Uint16(ind.payload[4:6]) == 0x4472 { + log.Printf("[RDP] rdpdr recv channel=%d rawLen=%d lengthOnlyHeader=true", ind.channelID, len(ind.payload)) + drive.Process(ind.payload[4:]) + return + } + if len(ind.payload) >= 8 && binary.LittleEndian.Uint16(ind.payload[4:6]) == 0x4472 { + log.Printf("[RDP] rdpdr recv channel=%d rawLen=%d securityHeader=true", ind.channelID, len(ind.payload)) + drive.Process(ind.payload[4:]) + return + } + if p, flag, ok := parseVirtualChannelPDU(ind.payload); ok { + log.Printf("[RDP] rdpdr recv channel=%d rawLen=%d flag=0x%x", ind.channelID, len(ind.payload), flag) + if flag&channelFlagFirst != 0 && flag&channelFlagLast != 0 { + drive.Process(p) + } else { + log.Printf("[RDP] rdpdr fragmented chunk flag=0x%x len=%d", flag, len(p)) + } + } +} + +func (e *SessionEngine) dispatchVirtualChannelPDU(ind mcsDataIndication, clip *cliprdr.Handler, clipChannelID uint16) { + if clip == nil || ind.channelID != clipChannelID { + return + } + prefixLen := len(ind.payload) + if prefixLen > 16 { + prefixLen = 16 + } + log.Printf("[RDP] cliprdr payload prefix=%x len=%d", ind.payload[:prefixLen], len(ind.payload)) + if p, flag, ok := parseVirtualChannelPDU(ind.payload); ok { + log.Printf("[RDP] cliprdr recv channel=%d rawLen=%d flag=0x%x", ind.channelID, len(ind.payload), flag) + if flag&channelFlagFirst != 0 && flag&channelFlagLast != 0 { + clip.Process(p) + } else { + log.Printf("[RDP] cliprdr fragmented chunk flag=0x%x len=%d", flag, len(p)) + } + } else { + log.Printf("[RDP] cliprdr parse failed channel=%d rawLen=%d", ind.channelID, len(ind.payload)) + } +} + +func parseVirtualChannelPDU(payload []byte) ([]byte, uint32, bool) { + if len(payload) < 8 { + return nil, 0, false + } + offset := 0 + if hasVirtualChannelSecurityHeader(payload) { + offset = 4 + } + totalLen := int(binary.LittleEndian.Uint32(payload[offset:])) + flag := binary.LittleEndian.Uint32(payload[offset+4:]) + dataStart := offset + 8 + if totalLen < 0 || dataStart+totalLen > len(payload) { + return nil, 0, false + } + return payload[dataStart : dataStart+totalLen], flag, true +} + +func hasVirtualChannelSecurityHeader(payload []byte) bool { + if len(payload) < 12 || binary.LittleEndian.Uint16(payload[0:2]) != secFlagChannelPkt { + return false + } + totalLen := int(binary.LittleEndian.Uint32(payload[4:8])) + flag := binary.LittleEndian.Uint32(payload[8:12]) + if totalLen < 0 || 12+totalLen > len(payload) { + return false + } + // 只有后移 4 字节后能看到合法 Channel PDU flags,才认为存在 security header。// Logan.Li + return flag&(channelFlagFirst|channelFlagLast) != 0 +} + +func logSlowPathSummary(ind mcsDataIndication, clipChannelID uint16) { + prefixLen := len(ind.payload) + if prefixLen > 24 { + prefixLen = 24 + } + prefix := fmt.Sprintf("%x", ind.payload[:prefixLen]) + type1 := uint16(0) + type2 := byte(0) + if len(ind.payload) >= 15 { + type1 = binary.LittleEndian.Uint16(ind.payload[6:8]) + type2 = ind.payload[14] + } + log.Printf("[RDP] slowpath packet channel=%d payload=%d sec=0x%04x type1=0x%04x type2=0x%02x prefix=%s (clipID=%d)", ind.channelID, len(ind.payload), ind.securityFlags, type1, type2, prefix, clipChannelID) +} + +// isChannelData 判断一个 SlowPath MCS 包是否发往指定虚拟通道 ID。// Logan.Li +func isChannelData(payload []byte, channelID uint16) bool { + ind, err := parseMCSDataIndication(payload) + if err != nil { + return false + } + return ind.channelID == channelID +} + +// extractChannelPayload 从 SlowPath MCS 包中提取虚拟通道负载(已去掉 MCS/SEC 头)。// Logan.Li +func extractChannelPayload(payload []byte, channelID uint16) []byte { + ind, err := parseMCSDataIndication(payload) + if err != nil || ind.channelID != channelID { + return nil + } + // 虚拟通道数据:payload 前 4 字节是 securityHeader(encrypt/decrypt 标志), + // 之后的 raw data 即 CLIPRDR PDU。NLA 模式下通道不额外加密,直接取 data 段。 // Logan.Li + if len(ind.payload) < 4 { + return nil + } + return ind.payload[4:] +} + func (e *SessionEngine) requestRefresh(sess *Session, writeMu *sync.Mutex) error { body := buildRefreshRectBody(Config{Width: sess.Width, Height: sess.Height}) pdu := buildDataPDU(sess.UserID, sess.SharedID, pduType2RefreshRect, body) @@ -218,7 +498,7 @@ func (e *SessionEngine) ackSurfaceFrames(sess *Session, frameIDs []uint32, write return nil } -func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event ClientEvent) error { +func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event ClientEvent, clip *cliprdr.Handler, drive *rdpdr.Handler) error { switch event.Type { case "mousemove", "mouse_move": return input.MouseMove(event.X, event.Y) @@ -229,16 +509,71 @@ func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event Cl case "wheel": return input.MouseWheel(event.X, event.Y, event.DeltaY) case "key": + if event.Meta { + event.Ctrl = true + } + if event.Down && (event.Ctrl || event.Meta) && (event.Code == "KeyC" || event.Code == "KeyX") && clip != nil { + go func() { + time.Sleep(250 * time.Millisecond) + clip.RequestRemoteText() + }() + } + if event.Code == "MetaLeft" || event.Code == "MetaRight" { + event.Code = "ControlLeft" + event.Key = "Control" + } + // 可打印字符按 Unicode 下发,避免远端键盘布局/修饰键状态导致审计有记录但远端无输入。 // Logan.Li + if event.Down && len([]rune(event.Key)) == 1 && !event.Ctrl && !event.Alt && !event.Meta { + return input.TypeText(event.Key) + } + if !event.Down && len([]rune(event.Key)) == 1 && !event.Ctrl && !event.Alt && !event.Meta { + return nil + } sc, ok := ScanCodeFromJS(event.Code) if !ok { return nil } return input.Key(sc, event.Down) - case "clipboard": - if event.Text != "" { - return input.TypeText(event.Text) + case "remote_copy": + if err := sendCopyShortcut(input); err != nil { + return err } - return nil + if clip != nil { + go func() { + time.Sleep(250 * time.Millisecond) + clip.RequestRemoteText() + }() + } + go func() { + time.Sleep(900 * time.Millisecond) + log.Printf("[RDP] clipboard explicit fallback auto_exec start") + if err := input.AutoExec(buildClipboardFallbackCommand()); err != nil { + log.Printf("[RDP] clipboard explicit fallback auto_exec failed: %v", err) + return + } + log.Printf("[RDP] clipboard explicit fallback auto_exec sent") + }() + return sink.SendFrame(ServerFrame{Type: "status", Message: "已发送 Ctrl+C 到远端,等待剪贴板回传"}) + case "clipboard": + // 本地→远端:优先走 cliprdr 协议级同步(通知远端本地剪贴板变更), + // 远端粘贴时主动拉取;cliprdr 不可用时回退到 Unicode 键盘模拟。// Logan.Li + if event.Text == "" { + return nil + } + if clip != nil { + clip.SetLocalText(event.Text) + } + return input.TypeText(event.Text) + case "rdp_file_upload": + if drive == nil || event.Name == "" || event.Text == "" { + return nil + } + data, err := base64.StdEncoding.DecodeString(event.Text) + if err != nil { + return err + } + drive.AddUpload(event.Name, data) + return sink.SendFrame(ServerFrame{Type: "status", Message: "已放入 AIOps\\Upload:" + event.Name}) case "auto_exec": if event.Text == "" { return nil @@ -253,6 +588,44 @@ func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event Cl } } +func sendCopyShortcut(input *InputSender) error { + // 工具按钮专用复制快捷键,避免复用前端 key 序列导致修饰键状态漂移。// Logan.Li + if err := input.Key(0x001D, true); err != nil { + return err + } + if err := input.Key(0x002E, true); err != nil { + return err + } + if err := input.Key(0x002E, false); err != nil { + return err + } + return input.Key(0x001D, false) +} + +func buildClipboardFallbackCommand() string { + ps := `$ErrorActionPreference='SilentlyContinue';$v='';try{$v=Get-Clipboard -Raw -ErrorAction Stop}catch{};if([string]::IsNullOrEmpty($v)){try{Add-Type -AssemblyName System.Windows.Forms;$v=[Windows.Forms.Clipboard]::GetText()}catch{}};if(-not [string]::IsNullOrEmpty($v)){$utf8=New-Object System.Text.UTF8Encoding -ArgumentList $false;$paths=@('\\tsclient\AIOps\Download\aiops-clipboard.txt','\\Go-client\AIOps\Download\aiops-clipboard.txt');foreach($p in $paths){try{[System.IO.File]::WriteAllText($p,$v,$utf8);break}catch{}}}` + encoded := encodePowerShellCommand(ps) + return "powershell -NoProfile -STA -WindowStyle Hidden -EncodedCommand " + encoded +} + +func encodePowerShellCommand(command string) string { + u16 := utf16.Encode([]rune(command)) + raw := make([]byte, len(u16)*2) + for i, v := range u16 { + binary.LittleEndian.PutUint16(raw[i*2:], v) + } + return base64.StdEncoding.EncodeToString(raw) +} + +func decodeRDPClipboardFallback(file rdpdr.Download) (string, bool) { + if file.Name != "aiops-clipboard.txt" || len(file.Data) == 0 { + return "", false + } + text := string(file.Data) + text = strings.TrimPrefix(text, "\ufeff") + return text, text != "" +} + // ProbeSessionFrames 用于诊断:建立会话后读取若干秒位图帧数量 // Logan.Li func ProbeSessionFrames(ctx context.Context, cfg Config, seconds int) (int, error) { sess, err := OpenSession(ctx, cfg) diff --git a/backend/pkg/winsmb/client.go b/backend/pkg/winsmb/client.go deleted file mode 100644 index b23ff75..0000000 --- a/backend/pkg/winsmb/client.go +++ /dev/null @@ -1,171 +0,0 @@ -package winsmb - -import ( - "context" - "fmt" - "io" - "net" - "os" - "path" - "strings" - "time" - - "github.com/hirochachacha/go-smb2" -) - -// 虚拟 Download 盘在 Windows 上的相对路径(挂载 C$ 共享)// Logan.Li -const ( - AdminShare = "C$" - DownloadRelDir = `Users\Public\AIOpsShare\Download` - DownloadFullPath = `C:\Users\Public\AIOpsShare\Download` -) - -// Auth SMB 管理员凭据 // Logan.Li -type Auth struct { - Host string - Domain string - Username string - Password string -} - -// FileEntry 目录项 // Logan.Li -type FileEntry struct { - Name string - Size int64 - IsDir bool - ModTime time.Time -} - -func sanitizeFileName(name string) string { - base := path.Base(strings.ReplaceAll(name, `\`, "/")) - base = strings.TrimSpace(base) - if base == "" || base == "." || base == ".." { - return "upload.bin" - } - return base -} - -func (a Auth) dial() (*smb2.Share, func(), error) { - if strings.TrimSpace(a.Host) == "" { - return nil, nil, fmt.Errorf("SMB 主机为空") - } - d := &smb2.Dialer{ - Initiator: &smb2.NTLMInitiator{ - User: a.Username, - Password: a.Password, - Domain: a.Domain, - }, - } - tcpConn, err := net.DialTimeout("tcp", net.JoinHostPort(a.Host, "445"), 15*time.Second) - if err != nil { - return nil, nil, fmt.Errorf("SMB 连接失败(请确认 445 端口可达且账号有管理员权限): %w", err) - } - session, err := d.DialContext(context.Background(), tcpConn) - if err != nil { - tcpConn.Close() - return nil, nil, fmt.Errorf("SMB 认证失败: %w", err) - } - share, err := session.Mount(AdminShare) - if err != nil { - _ = session.Logoff() - return nil, nil, fmt.Errorf("挂载 %s 失败: %w", AdminShare, err) - } - cleanup := func() { - _ = share.Umount() - _ = session.Logoff() - } - return share, cleanup, nil -} - -// EnsureDownloadDir 创建虚拟 Download 目录 // Logan.Li -func EnsureDownloadDir(auth Auth) error { - share, closeFn, err := auth.dial() - if err != nil { - return err - } - defer closeFn() - cur := "" - for _, part := range strings.Split(DownloadRelDir, `\`) { - if part == "" { - continue - } - if cur == "" { - cur = part - } else { - cur = cur + `\` + part - } - _ = share.Mkdir(cur, 0755) - } - return nil -} - -func remoteFilePath(fileName string) string { - return DownloadRelDir + `\` + sanitizeFileName(fileName) -} - -// Upload 上传文件到虚拟 Download 盘 // Logan.Li -func Upload(auth Auth, fileName string, data []byte) error { - if err := EnsureDownloadDir(auth); err != nil { - return err - } - share, closeFn, err := auth.dial() - if err != nil { - return err - } - defer closeFn() - target := remoteFilePath(fileName) - f, err := share.Create(target) - if err != nil { - return fmt.Errorf("创建远端文件失败: %w", err) - } - defer f.Close() - if _, err := f.Write(data); err != nil { - return fmt.Errorf("写入远端文件失败: %w", err) - } - return nil -} - -// Download 从虚拟 Download 盘读取文件 // Logan.Li -func Download(auth Auth, fileName string) ([]byte, error) { - share, closeFn, err := auth.dial() - if err != nil { - return nil, err - } - defer closeFn() - target := remoteFilePath(fileName) - f, err := share.Open(target) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("文件不存在: %s", sanitizeFileName(fileName)) - } - return nil, fmt.Errorf("打开远端文件失败: %w", err) - } - defer f.Close() - return io.ReadAll(f) -} - -// ListDownload 列出虚拟 Download 盘文件 // Logan.Li -func ListDownload(auth Auth) ([]FileEntry, error) { - if err := EnsureDownloadDir(auth); err != nil { - return nil, err - } - share, closeFn, err := auth.dial() - if err != nil { - return nil, err - } - defer closeFn() - entries, err := share.ReadDir(DownloadRelDir) - if err != nil { - return nil, fmt.Errorf("读取目录失败: %w", err) - } - out := make([]FileEntry, 0, len(entries)) - for _, ent := range entries { - out = append(out, FileEntry{ - Name: ent.Name(), - Size: ent.Size(), - IsDir: ent.IsDir(), - ModTime: ent.ModTime(), - }) - } - return out, nil -} \ No newline at end of file diff --git a/backend/router/agent_static.go b/backend/router/agent_static.go index b367a0f..c146bc9 100644 --- a/backend/router/agent_static.go +++ b/backend/router/agent_static.go @@ -17,9 +17,10 @@ type agentStaticPaths struct { } var agentBinaryFiles = map[string]string{ - "linux-amd64": "aiops-agent-linux-amd64", - "linux-arm64": "aiops-agent-linux-arm64", - "darwin-arm64": "aiops-agent-darwin-arm64", + "linux-amd64": "aiops-agent-linux-amd64", + "linux-arm64": "aiops-agent-linux-arm64", + "darwin-arm64": "aiops-agent-darwin-arm64", + "windows-amd64": "aiops-agent-windows-amd64.exe", } // registerAgentDownloads 注册 Agent 安装脚本和二进制下载路由,避免维护 backend/agent 重复目录。 // Logan.Li @@ -39,6 +40,9 @@ func registerAgentDownloads(r *gin.Engine, log *zap.Logger) { r.GET("/agent/install.sh", func(c *gin.Context) { serveAgentStaticFile(c, paths.deployDir, "install.sh") }) + r.GET("/agent/install.ps1", func(c *gin.Context) { + serveAgentStaticFile(c, paths.deployDir, "install.ps1") + }) for _, fileName := range agentBinaryFiles { name := fileName r.GET("/agent/"+name, func(c *gin.Context) { diff --git a/backend/router/routes.go b/backend/router/routes.go index 20c19f4..2e0024b 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -125,9 +125,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c api.POST("/auth/login", authHandler.Login) api.POST("/auth/register", authHandler.Register) - // RDP 暂存文件拉取(token 鉴权,供 Windows iwr 回退下载)// Logan.Li - rdpFilePublic := controller.NewRDPFileHandler(db, cfg.JWT.Secret) - api.GET("/rdp-staged/:token", rdpFilePublic.ServeStaged) + // RDPDR Download 捕获文件拉取(token 鉴权)。// Logan.Li + api.GET("/rdp-staged/:token", controller.ServeRDPStaged) // Webhook 触发路由(无需 JWT,由 Webhook Token 签名验证) pipelineHandler := controller.NewPipelineHandler(db, redisCache, ti) @@ -262,12 +261,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c servers.DELETE("/:id/files", sftpHandler.DeleteFile) servers.POST("/:id/files/mkdir", sftpHandler.Mkdir) - // RDP 虚拟 Download 盘(SMB 管理共享,一键上传下载)// Logan.Li - rdpFileHandler := controller.NewRDPFileHandler(db, cfg.JWT.Secret) - servers.GET("/:id/rdp-files", rdpFileHandler.ListFiles) - servers.POST("/:id/rdp-files/upload", rdpFileHandler.UploadFile) - servers.GET("/:id/rdp-files/download", rdpFileHandler.DownloadFile) - servers.POST("/:id/rdp-files/stage", rdpFileHandler.StageUpload) } // 云账单处理器(云账号同步账单路由共用) // Logan.Li @@ -941,6 +934,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li llmHandler := controller.NewLLMHandler(db) + aiopsContextHandler := controller.NewAIOpsContextHandler(db) + aiopsAlertAnalysisHandler := controller.NewAIOpsAlertAnalysisHandler(db) aiopsGroup := authorized.Group("/aiops") { aiopsGroup.GET("/llm-config", llmHandler.GetLLMConfig) @@ -950,6 +945,11 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c aiopsGroup.POST("/chat/stream", llmHandler.ChatStream) aiopsGroup.GET("/chat-history", llmHandler.GetChatHistory) aiopsGroup.DELETE("/chat-history", llmHandler.ClearChatHistory) + aiopsGroup.GET("/context/summary", aiopsContextHandler.Summary) + aiopsGroup.POST("/context/pack", aiopsContextHandler.BuildPack) + aiopsGroup.POST("/alert-analysis", aiopsAlertAnalysisHandler.Analyze) + aiopsGroup.GET("/analysis-traces", aiopsAlertAnalysisHandler.ListAnalysisTraces) + aiopsGroup.GET("/analysis-traces/:trace_id", aiopsAlertAnalysisHandler.GetAnalysisTrace) } // 指标浏览器 — 监控指标元数据 + 时序查询 // Logan.Li diff --git a/backend/static/.gitkeep b/backend/static/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/BACKEND_SUMMARY.md b/docs/BACKEND_SUMMARY.md deleted file mode 100644 index 8d27e40..0000000 --- a/docs/BACKEND_SUMMARY.md +++ /dev/null @@ -1,320 +0,0 @@ -# AIOps 后端项目总结 - -> **注意**:目录结构已重构,入口为 `backend/main.go`,处理器在 `controller/`。 -> 最新结构见 [project-structure.md](./project-structure.md),部署见 [deployment.md](./deployment.md)。 - -## 项目概述 - -AIOps 后端是一个基于 Go + Gin 框架开发的专业 AIOps 智能运维平台 API 服务。 -前端通过 `go:embed` 嵌入 `backend/static/`,支持单二进制与 embed 单容器部署。 - -## 项目结构(历史参考,部分路径已变更) - -``` -backend/ -├── main.go # 应用入口(当前) -├── embed.go # 前端静态资源嵌入 -├── controller/ # HTTP 处理器(原 handlers) -├── internal/ # [已迁移] 见下方历史结构 -│ ├── api/ -│ │ ├── handlers/ # HTTP 处理器 -│ │ │ ├── user.go # 用户管理 -│ │ │ ├── server.go # 服务器管理 -│ │ │ ├── dashboard.go # 仪表盘 -│ │ │ ├── k8s.go # K8s 管理 -│ │ │ ├── alert.go # 监控告警 -│ │ │ ├── pipeline.go # 流水线/发布 -│ │ │ ├── automation.go # 自动化运维 -│ │ │ ├── approval.go # 审批流程 -│ │ │ ├── navigation.go # 站内导航 -│ │ │ └── audit.go # 审计日志 -│ │ ├── middleware/ # 中间件 -│ │ │ └── auth.go # JWT 认证、管理员权限 -│ │ └── routes/ # 路由注册 -│ │ └── routes.go -│ ├── config/ # 配置管理 -│ │ └── config.go -│ └── models/ # 数据模型 -│ ├── user.go # 用户、角色、权限 -│ ├── server.go # 服务器、云账号、域名 -│ ├── k8s.go # K8s 相关模型 -│ ├── alert.go # 告警相关模型 -│ ├── cicd.go # CI/CD 相关模型 -│ ├── automation.go # 自动化运维模型 -│ ├── approval.go # 审批流程模型 -│ ├── navigation.go # 导航模型 -│ ├── audit.go # 审计日志模型 -│ └── common.go # 通用响应模型 -├── pkg/ -│ ├── database/ # 数据库连接 -│ │ └── database.go -│ └── logger/ # 日志工具 -│ └── logger.go -├── scripts/ # 脚本文件 -│ ├── init.sql # 数据库初始化脚本 -│ └── mock.go # Mock 数据生成 -├── config.yaml.example # 配置文件示例 -├── docker-compose.yml # Docker Compose 配置 -├── Dockerfile # Docker 构建文件 -├── Makefile # 构建脚本 -├── nginx.conf # Nginx 配置 -├── go.mod # Go 模块定义 -├── go.sum # Go 模块校验 -├── .gitignore # Git 忽略文件 -└── README.md # 项目说明 -``` - -## 功能模块 - -### 1. 用户管理 -- 用户注册、登录、登出 -- JWT Token 认证 -- 用户信息管理 -- 角色权限管理 - -### 2. 资产管理 -- 服务器资产 CRUD -- 批量导入(云主机、Excel) -- 云账号管理(阿里云、腾讯云、AWS、华为云) -- 域名管理(SSL 证书到期提醒) -- 区域管理 - -### 3. Kubernetes 管理 -- 集群管理 -- 节点管理 -- Pod 管理 -- Deployment 详情 -- Service 详情 -- Ingress 详情 - -### 4. 监控告警 -- 告警规则管理 -- 告警事件处理 -- 通知渠道配置 -- 仪表盘指标展示 - -### 5. 应用发布 -- CI/CD 流水线管理 -- 发布审批流程 -- 发布记录追踪 -- 回滚功能 - -### 6. 自动化运维 -- 定时任务(CronJob) -- 脚本库管理 -- 任务执行记录 -- Playbook 编排 - -### 7. 审批流程 -- 审批模板管理 -- 审批流程创建 -- 审批/驳回操作 - -### 8. 站内导航 -- 导航分组管理 -- 导航链接管理 -- 收藏功能 - -### 9. 审计日志 -- 操作日志记录 -- 日志查询、筛选 -- 日志导出 -- 日志统计 -- 日志清理 - -## API 路由列表 - -### 认证相关 -- `POST /api/v1/auth/login` - 登录 -- `POST /api/v1/auth/register` - 注册 -- `GET /api/v1/user/profile` - 获取用户信息 -- `PUT /api/v1/user/profile` - 更新用户信息 -- `PUT /api/v1/user/password` - 修改密码 - -### 用户管理(管理员) -- `GET /api/v1/admin/users` - 用户列表 -- `GET /api/v1/admin/users/:id` - 用户详情 -- `POST /api/v1/admin/users` - 创建用户 -- `PUT /api/v1/admin/users/:id` - 更新用户 -- `DELETE /api/v1/admin/users/:id` - 删除用户 - -### 仪表盘 -- `GET /api/v1/dashboard/stats` - 统计数据 -- `GET /api/v1/dashboard/recent-alerts` - 最近告警 -- `GET /api/v1/dashboard/recent-releases` - 最近发布 -- `GET /api/v1/dashboard/metrics` - 指标数据 - -### 服务器管理 -- `GET /api/v1/servers` - 服务器列表 -- `GET /api/v1/servers/stats` - 服务器统计 -- `GET /api/v1/servers/:id` - 服务器详情 -- `POST /api/v1/servers` - 创建服务器 -- `PUT /api/v1/servers/:id` - 更新服务器 -- `DELETE /api/v1/servers/:id` - 删除服务器 -- `POST /api/v1/servers/batch-delete` - 批量删除 - -### 云账号管理 -- `GET /api/v1/cloud-accounts` - 云账号列表 -- `POST /api/v1/cloud-accounts` - 创建云账号 -- `DELETE /api/v1/cloud-accounts/:id` - 删除云账号 - -### 域名管理 -- `GET /api/v1/domains` - 域名列表 -- `POST /api/v1/domains` - 创建域名 -- `DELETE /api/v1/domains/:id` - 删除域名 - -### Kubernetes 管理 -- `GET /api/v1/k8s-clusters` - 集群列表 -- `GET /api/v1/k8s-clusters/:id` - 集群详情 -- `POST /api/v1/k8s-clusters` - 创建集群 -- `PUT /api/v1/k8s-clusters/:id` - 更新集群 -- `DELETE /api/v1/k8s-clusters/:id` - 删除集群 -- `GET /api/v1/k8s-clusters/:id/nodes` - 节点列表 -- `GET /api/v1/k8s-clusters/:id/pods` - Pod 列表 -- `GET /api/v1/k8s-clusters/:id/deployments` - Deployment 列表 -- `GET /api/v1/k8s-clusters/:id/services` - Service 列表 -- `GET /api/v1/k8s-clusters/:id/ingresses` - Ingress 列表 - -### 监控告警 -- `GET /api/v1/alerts` - 告警事件列表 -- `GET /api/v1/alerts/rules` - 告警规则列表 -- `POST /api/v1/alerts/rules` - 创建告警规则 -- `PUT /api/v1/alerts/rules/:id` - 更新告警规则 -- `DELETE /api/v1/alerts/rules/:id` - 删除告警规则 -- `POST /api/v1/alerts/rules/:id/toggle` - 启用/禁用规则 -- `POST /api/v1/alerts/:id/ack` - 确认告警 - -### 应用发布 -- `GET /api/v1/pipelines` - 流水线列表 -- `POST /api/v1/pipelines` - 创建流水线 -- `GET /api/v1/pipelines/:id` - 流水线详情 -- `PUT /api/v1/pipelines/:id` - 更新流水线 -- `DELETE /api/v1/pipelines/:id` - 删除流水线 -- `POST /api/v1/pipelines/:id/trigger` - 触发流水线 -- `GET /api/v1/pipelines/:id/runs` - 运行记录 -- `GET /api/v1/releases` - 发布列表 -- `POST /api/v1/releases` - 创建发布 -- `POST /api/v1/releases/:id/approve` - 审批发布 -- `POST /api/v1/releases/:id/deploy` - 执行发布 -- `POST /api/v1/releases/:id/rollback` - 回滚发布 - -### 自动化运维 -- `GET /api/v1/cron-jobs` - 定时任务列表 -- `POST /api/v1/cron-jobs` - 创建定时任务 -- `GET /api/v1/cron-jobs/:id` - 定时任务详情 -- `PUT /api/v1/cron-jobs/:id` - 更新定时任务 -- `DELETE /api/v1/cron-jobs/:id` - 删除定时任务 -- `POST /api/v1/cron-jobs/:id/toggle` - 启用/禁用任务 -- `POST /api/v1/cron-jobs/:id/execute` - 立即执行 -- `GET /api/v1/scripts` - 脚本列表 -- `POST /api/v1/scripts` - 创建脚本 -- `GET /api/v1/scripts/:id` - 脚本详情 -- `PUT /api/v1/scripts/:id` - 更新脚本 -- `DELETE /api/v1/scripts/:id` - 删除脚本 -- `POST /api/v1/scripts/:id/execute` - 执行脚本 - -### 审批流程 -- `GET /api/v1/approvals/templates` - 审批模板列表 -- `POST /api/v1/approvals/templates` - 创建审批模板 -- `GET /api/v1/approvals/templates/:id` - 模板详情 -- `PUT /api/v1/approvals/templates/:id` - 更新模板 -- `DELETE /api/v1/approvals/templates/:id` - 删除模板 -- `GET /api/v1/approvals/processes` - 审批流程列表 -- `POST /api/v1/approvals/processes` - 创建审批流程 -- `GET /api/v1/approvals/processes/:id` - 流程详情 -- `POST /api/v1/approvals/processes/:id/approve` - 审批通过 -- `POST /api/v1/approvals/processes/:id/reject` - 审批驳回 - -### 站内导航 -- `GET /api/v1/navigations/groups` - 导航分组列表 -- `POST /api/v1/navigations/groups` - 创建分组 -- `PUT /api/v1/navigations/groups/:id` - 更新分组 -- `DELETE /api/v1/navigations/groups/:id` - 删除分组 -- `GET /api/v1/navigations/links` - 导航链接列表 -- `POST /api/v1/navigations/links` - 创建链接 -- `PUT /api/v1/navigations/links/:id` - 更新链接 -- `DELETE /api/v1/navigations/links/:id` - 删除链接 -- `POST /api/v1/navigations/links/:id/favorite` - 收藏/取消收藏 - -### 审计日志 -- `GET /api/v1/audit/logs` - 审计日志列表 -- `GET /api/v1/audit/logs/:id` - 日志详情 -- `GET /api/v1/audit/modules` - 模块列表 -- `GET /api/v1/audit/actions` - 操作类型列表 -- `GET /api/v1/audit/logs/export` - 导出日志 -- `GET /api/v1/audit/statistics` - 统计信息 -- `POST /api/v1/audit/logs/clear` - 清理日志 - -## 快速开始 - -### 1. 安装依赖 - -```bash -cd backend -go mod download -``` - -### 2. 配置 - -```bash -cp config.yaml.example config.yaml -# 编辑 config.yaml 修改数据库等配置 -``` - -### 3. 启动数据库 - -```bash -docker-compose up -d mysql redis -``` - -### 4. 运行 - -```bash -# 推荐:项目根目录 -./run.sh dev - -# 或仅后端 -cd backend && go run . -``` - -### 5. 生成 Mock 数据 - -```bash -go run scripts/mock.go -``` - -### 6. 访问 API 文档 - -打开浏览器访问 http://localhost:8080/swagger/index.html - -## Docker 部署 - -```bash -# 构建镜像 -docker build -t ezops-backend . - -# 运行容器 -docker run -p 8080:8080 -v $(pwd)/config.yaml:/app/config.yaml ezops-backend - -# 或使用 Docker Compose -docker-compose up -d -``` - -## 技术栈 - -- **框架**: Gin Web Framework -- **ORM**: GORM -- **数据库**: MySQL / PostgreSQL -- **缓存**: Redis (可选) -- **认证**: JWT -- **日志**: Zap -- **配置**: Viper -- **文档**: Swagger - -## 注意事项 - -1. 生产环境请修改默认的 JWT Secret 和数据库密码 -2. 建议启用 HTTPS -3. 配置适当的日志轮转策略 -4. 定期备份数据库 -5. 启用审计日志功能 diff --git a/docs/PDD_PLAN.md b/docs/PDD_PLAN.md deleted file mode 100644 index 20aa706..0000000 --- a/docs/PDD_PLAN.md +++ /dev/null @@ -1,642 +0,0 @@ -# AIOps 智能运维平台 - 产品设计文档 - -> 文档版本: v1.0.0 -> 编写日期: 2025-04-14 -> 文档状态: 正式发布 - ---- - -## 目录 - -1. [产品概述](#1-产品概述) -2. [目标用户](#2-目标用户) -3. [产品架构](#3-产品架构) -4. [功能设计](#4-功能设计) -5. [AI能力设计](#5-ai能力设计) -6. [界面设计](#6-界面设计) -7. [技术方案](#7-技术方案) -8. [安全设计](#8-安全设计) -9. [部署方案](#9-部署方案) -10. [运营计划](#10-运营计划) - ---- - -## 1. 产品概述 - -### 1.1 产品定位 - -AIOps 智能运维平台是一款面向企业级用户的云原生智能运维解决方案,通过深度集成AI大模型能力,实现运维工作的智能化、自动化,帮助企业降低运维成本、提升系统稳定性。 - -### 1.2 核心价值 - -- **降本增效**: AI辅助决策,减少人工干预,提升运维效率 -- **预防为主**: 智能预测和预警,防患于未然 -- **快速恢复**: 智能诊断和自动修复,缩短故障恢复时间 -- **知识沉淀**: 运维经验AI化,实现知识复用 - -### 1.3 产品Slogan - -> **智能运维,让系统更稳定,让团队更高效** - ---- - -## 2. 目标用户 - -### 2.1 用户画像 - -| 用户类型 | 角色描述 | 核心需求 | 使用场景 | -|---------|---------|---------|---------| -| 运维工程师 | 日常运维执行者 | 快速定位问题、自动化操作 | 故障处理、日常巡检 | -| 运维主管 | 团队管理者 | 全局视图、效率提升 | 团队管理、决策支持 | -| SRE工程师 | 站点可靠性工程师 | 系统稳定性、性能优化 | 容量规划、性能调优 | -| DevOps工程师 | 开发运维一体化 | CI/CD、自动化发布 | 应用发布、流水线管理 | -| 技术总监 | 技术决策者 | 成本优化、风险评估 | 技术选型、预算规划 | - -### 2.2 用户场景 - -#### 场景1: 故障快速响应 -``` -用户: 运维工程师小王 -场景: 凌晨2点收到告警,生产环境服务响应缓慢 -流程: -1. 收到AI告警分析推送,了解根因是数据库连接池耗尽 -2. 查看AI推荐的解决方案 -3. 一键执行自动修复脚本 -4. 验证修复效果,故障恢复 -时间: 从30分钟缩短到5分钟 -``` - -#### 场景2: 日常运维自动化 -``` -用户: SRE工程师小李 -场景: 需要每日巡检100+台服务器 -流程: -1. 使用AI脚本生成器创建巡检脚本 -2. 配置定时任务自动执行 -3. AI分析巡检结果,生成报告 -4. 异常情况自动创建告警 -效果: 节省2小时/天 -``` - -#### 场景3: 容量规划决策 -``` -用户: 技术总监老张 -场景: 规划下季度服务器采购预算 -流程: -1. 查看AI预测分析,了解资源使用趋势 -2. 获取AI推荐的扩容方案 -3. 对比不同方案的成本和性能 -4. 做出最优决策 -效果: 成本节省20% -``` - ---- - -## 3. 产品架构 - -### 3.1 系统架构 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 展示层 │ -│ ┌──────────────┬──────────────┬──────────────┬──────────────┐ │ -│ │ Web控制台 │ 移动App │ 可视化大屏 │ CLI工具 │ │ -│ └──────────────┴──────────────┴──────────────┴──────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ -┌─────────────────────────────────────────────────────────────────┐ -│ 接入层 │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ API网关 (认证/限流/路由/日志) │ │ -│ └──────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ -┌─────────────────────────────────────────────────────────────────┐ -│ 服务层 │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐│ -│ │ 资产管理 │ │ 应用发布 │ │ 监控告警 │ │ 自动化 │ │ AIOps ││ -│ │ 服务 │ │ 服务 │ │ 服务 │ │ 服务 │ │ 服务 ││ -│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ -┌─────────────────────────────────────────────────────────────────┐ -│ 数据层 │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐│ -│ │ MySQL │ │ Redis │ │ Prometheus│ │ ES │ │ MinIO ││ -│ │ 业务数据 │ │ 缓存 │ │ 时序数据 │ │ 日志 │ │ 对象存储││ -│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ -┌─────────────────────────────────────────────────────────────────┐ -│ AI服务层 │ -│ ┌──────────┬──────────┬──────────┬──────────┬──────────────┐ │ -│ │ OpenAI │Anthropic │ Azure │ Ollama │ 本地模型 │ │ -│ │ GPT-4 │ Claude │ OpenAI │ 本地部署 │ 微调模型 │ │ -│ └──────────┴──────────┴──────────┴──────────┴──────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 3.2 模块划分 - -| 模块名称 | 功能描述 | 优先级 | -|---------|---------|--------| -| 资产管理 | 服务器、云资源、域名等资产全生命周期管理 | P0 | -| 应用发布 | CI/CD流水线、发布管理、版本控制 | P0 | -| 监控告警 | 指标采集、告警管理、可视化大屏 | P0 | -| 自动化运维 | 定时任务、脚本管理、Ansible执行 | P1 | -| AIOps智能 | AI助手、告警分析、故障诊断、预测分析 | P0 | -| 系统管理 | 用户权限、操作审计、系统配置 | P1 | - ---- - -## 4. 功能设计 - -### 4.1 资产管理模块 - -#### 4.1.1 服务器管理 - -**功能列表:** -- 服务器CRUD操作 -- 批量导入 (Excel/云API) -- 标签管理 -- 远程WebShell -- 状态监控 - -**数据模型:** -```typescript -interface Server { - id: string; - name: string; - hostname: string; - ip: string; - os: string; - cpu: number; - memory: number; - disk: number; - status: 'running' | 'stopped' | 'error'; - tags: string[]; - cloudProvider?: string; - region?: string; - createdAt: string; - updatedAt: string; -} -``` - -#### 4.1.2 云账号管理 - -**功能列表:** -- 多云账号接入 (阿里云/腾讯云/AWS) -- AK/SK安全存储 -- 资源自动同步 -- 成本分析 - -### 4.2 应用发布模块 - -#### 4.2.1 流水线管理 - -**功能列表:** -- 可视化流水线配置 -- 多阶段构建 -- 并行/串行执行 -- 审批节点 - -**流水线阶段:** -```yaml -stages: - - name: 代码检出 - type: checkout - - name: 构建 - type: build - - name: 单元测试 - type: test - - name: 镜像构建 - type: docker_build - - name: 部署 - type: deploy - approval: true - - name: 健康检查 - type: health_check -``` - -#### 4.2.2 发布管理 - -**功能列表:** -- 发布申请 -- 发布审批 -- 发布历史 -- 一键回滚 - -### 4.3 监控告警模块 - -#### 4.3.1 指标监控 - -**支持指标:** -- CPU使用率 -- 内存使用率 -- 磁盘使用率 -- 网络流量 -- 应用性能指标 (QPS/RT/错误率) - -#### 4.3.2 告警中心 - -**功能列表:** -- 告警规则配置 -- 告警级别划分 (严重/警告/提示) -- 告警通知 (邮件/短信/钉钉/企业微信) -- 告警抑制和聚合 -- AI告警分析 - -### 4.4 自动化运维模块 - -#### 4.4.1 定时任务 - -**功能列表:** -- Cron表达式配置 -- 任务执行日志 -- 失败重试机制 -- 任务依赖管理 - -#### 4.4.2 Ansible管理 - -**功能列表:** -- Playbook管理 -- 主机清单管理 -- 任务执行 -- 执行结果展示 - -### 4.5 K8s集群管理 - -**功能列表:** -- 多集群管理 -- 节点管理 -- 工作负载管理 (Deployment/Service/Ingress) -- 架构拓扑可视化 -- 资源监控 - ---- - -## 5. AI能力设计 - -### 5.1 AI架构 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ AI能力层 │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ AI编排引擎 │ │ -│ │ (Prompt管理/上下文管理/工具调用/结果解析) │ │ -│ └───────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────┬──────────────┬──────────────┬──────────┐ │ -│ │ 告警分析 │ 脚本生成 │ 故障诊断 │ 预测分析 │ │ -│ │ Agent │ Agent │ Agent │ Agent │ │ -│ └──────────────┴──────────────┴──────────────┴──────────┘ │ -│ │ │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ 模型接入层 │ │ -│ │ OpenAI / Anthropic / Azure / Ollama / 本地模型 │ │ -│ └───────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### 5.2 AI告警分析 - -**功能描述:** -- 接收告警事件 -- 关联相关日志和指标 -- AI分析根因 -- 生成修复建议 -- 推荐自动修复脚本 - -**Prompt设计:** -``` -你是一位资深的运维专家,请分析以下告警信息: - -告警名称: {alert_name} -告警级别: {severity} -告警对象: {target} -告警时间: {time} -告警详情: {details} - -相关日志: -{logs} - -相关指标: -{metrics} - -请提供: -1. 根因分析 (置信度) -2. 影响范围评估 -3. 修复建议 (按优先级排序) -4. 自动修复脚本 (如适用) -5. 相似历史案例 -``` - -### 5.3 AI脚本生成 - -**功能描述:** -- 自然语言描述需求 -- AI生成运维脚本 -- 支持多语言 (Bash/Python/Ansible) -- 一键测试执行 - -**Prompt设计:** -``` -你是一位运维脚本专家,请根据以下需求生成{language}脚本: - -需求描述: {description} -目标系统: {os} -执行环境: {environment} - -要求: -1. 添加详细的注释 -2. 包含错误处理 -3. 添加日志输出 -4. 考虑安全性 - -请生成完整的可执行脚本。 -``` - -### 5.4 AI故障诊断 - -**功能描述:** -- 接收故障症状描述 -- AI分析可能原因 -- 提供诊断步骤 -- 推荐解决方案 - -### 5.5 AI预测分析 - -**功能描述:** -- 分析历史指标数据 -- 预测未来趋势 -- 容量规划建议 -- 异常预警 - -**预测模型:** -- 时间序列分析 (ARIMA/Prophet) -- 异常检测 (Isolation Forest) -- 趋势预测 (LSTM) - ---- - -## 6. 界面设计 - -### 6.1 设计原则 - -- **简洁高效**: 减少用户操作步骤,提升效率 -- **信息密度**: 合理的信息展示,避免信息过载 -- **一致性**: 统一的视觉风格和交互模式 -- **响应式**: 适配不同屏幕尺寸 - -### 6.2 色彩系统 - -```css -/* 主色调 */ ---primary: #3b82f6; /* 蓝色 - 主要操作 */ ---primary-hover: #2563eb; - -/* 功能色 */ ---success: #10b981; /* 绿色 - 成功 */ ---warning: #f59e0b; /* 橙色 - 警告 */ ---danger: #ef4444; /* 红色 - 危险 */ ---info: #06b6d4; /* 青色 - 信息 */ - -/* 中性色 */ ---slate-900: #0f172a; /* 深色背景 */ ---slate-800: #1e293b; /* 卡片背景 */ ---slate-700: #334155; /* 边框 */ ---slate-100: #f1f5f9; /* 浅色文字 */ - -/* AI强调色 */ ---ai-purple: #8b5cf6; /* 紫色 - AI功能 */ ---ai-pink: #ec4899; /* 粉色 - AI强调 */ -``` - -### 6.3 布局结构 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Header (Logo + 全局搜索 + 通知 + 用户头像) │ -├──────────┬──────────────────────────────────────────────────┤ -│ │ │ -│ │ │ -│ Sidebar │ Main Content │ -│ (菜单) │ (主内容区) │ -│ │ │ -│ │ │ -│ │ │ -└──────────┴──────────────────────────────────────────────────┘ -``` - -### 6.4 核心页面 - -#### 仪表盘 -- 系统健康度卡片 -- 成本费用图表 -- Top资源使用排行 -- 告警消息列表 -- 快捷操作入口 - -#### AI智能助手 -- 对话界面 -- 快捷操作面板 -- 能力展示 -- 模型配置 - ---- - -## 7. 技术方案 - -### 7.1 技术栈 - -| 层级 | 技术选型 | 版本 | -|-----|---------|------| -| 前端 | React + TypeScript + Vite | React 18 | -| UI组件 | shadcn/ui + Tailwind CSS | v2.x | -| 状态管理 | Zustand | v4.x | -| 后端 | Go + Gin | Go 1.22 | -| ORM | GORM | v2.x | -| 数据库 | MySQL + Redis | 8.0 / 7.x | -| 消息队列 | RabbitMQ | 3.x | -| 监控 | Prometheus + Grafana | - | -| AI服务 | OpenAI/Anthropic/Azure | - | - -### 7.2 API设计 - -**RESTful API规范:** -``` -GET /api/v1/servers # 列表查询 -GET /api/v1/servers/:id # 详情查询 -POST /api/v1/servers # 创建 -PUT /api/v1/servers/:id # 更新 -DELETE /api/v1/servers/:id # 删除 -``` - -**AI服务API:** -``` -POST /api/v1/ai/chat # AI对话 -POST /api/v1/ai/analyze-alert # 告警分析 -POST /api/v1/ai/generate-script # 脚本生成 -POST /api/v1/ai/diagnose # 故障诊断 -POST /api/v1/ai/predict # 预测分析 -``` - -### 7.3 数据模型 - -**核心实体关系:** -``` -User (1) ----< (N) Role -User (1) ----< (N) OperationLog - -Server (N) --> (1) CloudAccount -Server (N) --> (N) Tag - -Cluster (1) ----< (N) Node -Cluster (1) ----< (N) Workload - -Pipeline (1) ----< (N) PipelineStage -Pipeline (1) ----< (N) Release - -AlertRule (1) ----< (N) AlertEvent -``` - ---- - -## 8. 安全设计 - -### 8.1 认证授权 - -- JWT Token认证 -- RBAC权限模型 -- 操作审计日志 -- 敏感数据加密 - -### 8.2 数据安全 - -- 数据库连接加密 -- 敏感配置加密存储 -- 定期数据备份 -- 操作日志保留 - -### 8.3 网络安全 - -- HTTPS传输加密 -- API限流保护 -- SQL注入防护 -- XSS攻击防护 - ---- - -## 9. 部署方案 - -### 9.1 部署架构 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 负载均衡层 │ -│ Nginx / Traefik │ -└─────────────────────────────────────────────────────────────┘ - │ -┌─────────────────────────────────────────────────────────────┐ -│ 应用服务层 │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Frontend │ │ Backend │ │ AI │ │ -│ │ (Nginx) │ │ (Gin) │ │ Service │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - │ -┌─────────────────────────────────────────────────────────────┐ -│ 数据存储层 │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ MySQL │ │ Redis │ │Prometheus│ │ ES │ │ -│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### 9.2 部署方式 - -**Docker Compose (开发/测试):** -```yaml -version: '3.8' -services: - frontend: - image: ezops/frontend:latest - ports: - - "80:80" - - backend: - image: ezops/backend:latest - ports: - - "8080:8080" - - mysql: - image: mysql:8.0 - environment: - MYSQL_ROOT_PASSWORD: password - - redis: - image: redis:7-alpine -``` - -**Kubernetes (生产):** -- Helm Chart部署 -- ConfigMap配置管理 -- Secret密钥管理 -- HPA自动扩缩容 - ---- - -## 10. 运营计划 - -### 10.1 版本发布计划 - -| 版本 | 发布日期 | 主要功能 | 目标用户 | -|-----|---------|---------|---------| -| v0.1.0 | 2025-04 | MVP核心功能 | 早期用户 | -| v0.2.0 | 2025-06 | AI能力增强 | 技术团队 | -| v0.3.0 | 2025-09 | 企业级特性 | 企业客户 | -| v0.4.0 | 2025-12 | 全栈智能化 | 大规模用户 | -| v1.0.0 | 2026-Q1 | 正式发布 | 全量用户 | - -### 10.2 成功指标 - -| 指标 | 目标值 | 测量方式 | -|-----|-------|---------| -| 用户活跃度 | DAU > 1000 | 系统统计 | -| 故障响应时间 | < 5分钟 | 告警系统 | -| AI分析准确率 | > 85% | 用户反馈 | -| 用户满意度 | NPS > 50 | 问卷调查 | -| 系统可用性 | 99.9% | 监控数据 | - -### 10.3 迭代策略 - -- **双周迭代**: 每两周发布一个小版本 -- **月度规划**: 每月制定功能规划 -- **季度复盘**: 每季度进行产品复盘 -- **年度规划**: 每年制定产品路线图 - ---- - -## 附录 - -### A. 术语表 - -| 术语 | 说明 | -|-----|------| -| AIOps | 人工智能运维,将AI能力应用于IT运维 | -| SRE | 站点可靠性工程 | -| DevOps | 开发运维一体化 | -| CI/CD | 持续集成/持续部署 | -| MTTR | 平均修复时间 | -| MTBF | 平均故障间隔时间 | - -### B. 参考资料 - -- [Google SRE Book](https://sre.google/sre-book/table-of-contents/) -- [The Phoenix Project](https://itrevolution.com/the-phoenix-project/) -- [AIOps白皮书](https://www.gartner.com/en/documents/aiops) - ---- - -*本文档由AIOps产品团队维护,如有疑问请联系 product@ezops.io* diff --git a/docs/PROJECT_SUMMARY.md b/docs/PROJECT_SUMMARY.md deleted file mode 100644 index 10cc8d4..0000000 --- a/docs/PROJECT_SUMMARY.md +++ /dev/null @@ -1,125 +0,0 @@ -# AIOps 智能运维平台 - 项目总结 - -> 最新目录结构见 [project-structure.md](./project-structure.md),部署见 [deployment.md](./deployment.md)。 - -## 项目概述 - -AIOps 是一个企业级云原生智能运维平台,包含完整的前后端代码与独立 Agent 模块。平台聚焦资产管理、Kubernetes 管理、应用发布、监控告警、自动化运维、审批流程和 AI 智能分析。 - -## 项目结构 - -``` -aiops/ -├── frontend/ # React 19 + TypeScript + Vite -│ ├── src/api/ # API 封装(按菜单域拆分) -│ ├── src/pages/ # 复杂域级页面 -│ ├── src/components/ # 业务组件 + layout(PageShell 等) -│ ├── src/locales/ # i18n 翻译 (zh-CN / en-US) -│ └── src/i18n/ # i18next 初始化 -│ -├── backend/ # Go 1.24 + Gin + GORM -│ ├── main.go # 应用入口 -│ ├── embed.go # go:embed 前端静态资源 -│ ├── controller/ # HTTP 处理器(60+ 文件) -│ ├── model/ # GORM 数据模型(86+ 表) -│ ├── router/ # 路由注册 -│ └── pkg/ # agentcli / remoteexec / kubernetesclient 等 -│ -├── agent/ # 监控 Agent(独立 Go module) -├── docs/ # 开发文档 + Agent CLI 设计文档集 -├── deploy/kubernetes/ # K8s 部署清单 -├── Dockerfile # 推荐生产镜像 -├── docker-compose.yml # 3 容器部署 -└── run.sh # 统一入口脚本 -``` - -## 功能特性 - -### 1. 仪表盘 -- 资源概览统计、成本费用分析 -- Top5 CPU/内存/磁盘使用率 -- 证书到期提醒、最近告警和发布记录 -- 国际化支持(dashboard 命名空间) - -### 2. 服务器资产管理 -- 服务器列表 CRUD、批量导入(云主机 / Excel) -- 远程 WebShell(WebSocket + PTY) -- 云账号 / 域名 / 区域 / 云平台 / 账单管理 -- 资源模型管理 - -### 3. Kubernetes 集群管理 -- 多集群接入、节点 / Pod / Workload 管理 -- Deployment 三模式编辑器(表单 / YAML / 可视化) -- Pod 终端 / 文件浏览、拓扑架构图 -- 国际化支持(k8s 命名空间,184+ 翻译键) - -### 4. 监控告警 -- 告警规则 / 事件 / 通知渠道 / 静默规则 -- 主机指标采集(Agent 双通道)、SNMP / 站点拨测 -- **中间件监控**:Redis / MySQL 后端直连采集 -- **日志中心**:Loki / ES 统一查询、日志告警引擎 -- Prometheus / Zabbix / Grafana 数据源代理 - -### 5. 应用发布 -- CI/CD 流水线(GitLab CI 风格可视化) -- 发布配置 / 环境 / 权限管理 -- SSH + K8s 双路径部署 -- 凭证管理(7 种统一类型) - -### 6. 自动化运维 -- 定时任务(CronJob)、脚本库管理 -- **远程执行**(Go-native SSH,执行 / 上传) -- 任务执行记录 - -### 7. AIOps 智能 -- AI 智能助手、告警分析、容量预测 -- **Agent CLI**:ReAct 16 轮循环 + WebSocket 实时回传 + Evolver 自进化 -- 大模型设置(OpenAI 兼容 + Anthropic Messages 双协议) -- 终端审计回放 - -### 8. 审批流程 -- 审批模板 / 流程 / 分类 / 权限管理 -- 云服务器 / 应用部署申请 -- 国际化支持(approval 命名空间) - -### 9. 系统管理 -- 用户 / 角色 / RBAC 权限 -- 操作审计 / 登录日志 / 终端会话审计 -- LDAP 集成、凭证配置 -- 7 套主题(CSS 变量驱动) - -## UI 设计系统 - -- **PageShell**:全站统一页面骨架(标题 / 指标条 / 工具栏 / Tab / 内容区) -- **CSS 设计令牌**:`dg-page*` 类名 + `var(--dg-xxx)` 主题变量 -- **7 套主题**:跟随系统、深海蓝、明亮白 + One Dark / Dracula / Tokyo Night / Catppuccin - -## 技术亮点 - -| 特性 | 说明 | -|------|------| -| 单二进制部署 | go:embed 嵌入前端,零外部依赖 | -| 轻量 K8s 客户端 | 自研 HTTP 客户端,不引入 client-go | -| Go-native SSH | 替代 Ansible,零新增依赖 | -| Agent 双通道 | 指标 10s 上报 + 任务 5s 轮询 | -| 凭证驱动 | 7 种凭证类型统一加密存储 | -| 工具懒加载 | kubectl / maven / helm 首次使用自动下载 | -| 国际化 | i18next 中/英双语,按模块命名空间管理 | - -## 部署方式 - -| 方式 | 命令 | 说明 | -|------|------|------| -| 本地开发 | `./run.sh dev` | 5173 + 8080 热重载 | -| 独立二进制 | `./run.sh build` | 产出 `./aiops` | -| Docker Compose | `./run.sh docker up` | MySQL + Redis + AIOps | -| Kubernetes | `./run.sh kubernetes deploy` | devops 命名空间 | -| 一键发版 | `./run.sh release v1.5.0` | tag + 镜像 + 推送 | - -## 相关文档 - -- [开发文档索引](./README.md) -- [Agent CLI 设计集](./agent/00-README.md) -- [后端摘要](./BACKEND_SUMMARY.md) -- [API 接口](./api-reference.md) -- [数据库设计](./database-schema.md) \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 5b0f53f..d658ce4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,8 @@ 本目录包含 AIOps 智能运维平台的开发文档。 +根目录只保留通用入口文档:快速开始、部署、项目结构、开发规范和总索引。业务模块、实现规格和历史说明均放入对应子目录,避免根目录继续堆积长文档。// Logan.Li + ## 文档索引 | 文档 | 说明 | @@ -10,20 +12,30 @@ | [部署指南](./deployment.md) | 二进制 / Docker / K8s 部署、版本发版、镜像推送 | | [项目结构](./project-structure.md) | 前后端目录结构说明 | | [开发规范](./development-guide.md) | 代码规范、Git 工作流、PageShell / i18n | -| [API 接口](./api-reference.md) | 后端 API 接口文档 | -| [数据库设计](./database-schema.md) | 数据模型和表结构 | -| [开发日志](./dev-log.md) | 详细开发日志(按日期记录) | -| [Web RDP 远程桌面](./rdp.md) | 自研 RDP 协议栈、WebSocket 帧协议(归档:`/opt/rdp/`) | -| [Redis 缓存策略](./redis-caching-strategy.md) | Redis 缓存使用现状与优化方案 | +| [API 接口](./reference/api-reference.md) | 后端 API 接口文档 | +| [数据库设计](./reference/database-schema.md) | 数据模型和表结构 | +| [模块文档规划](./modules/README.md) | 日志中心、监控中心、安全审计/堡垒机、资产凭证等大模块文档路线 | +| [安全审计与堡垒机](./security-bastion/README.md) | SSH/RDP/Pod/kubectl 会话、录像、命令、策略、审批统一设计 | +| [监控中心](./monitoring/README.md) | Agent 指标、Prometheus/Zabbix、SNMP、站点拨测、告警引擎 | +| [日志中心](./log-center/README.md) | Loki/ES 查询、日志数据源、日志告警、日志 RBAC | +| [资产与凭证](./asset-credential/README.md) | 资产模型、统一凭证、云同步、堡垒机权限底座 | +| [Kubernetes 管理](./kubernetes/README.md) | 自研 K8s 客户端、Pod exec/files、YAML、AI 排障 | +| [自动化运维与发布](./automation-deploy/README.md) | 脚本库、定时任务、远程执行、流水线、Workspace | +| [客户端 Agent](./client-agent/README.md) | 主机采集 Agent 的指标采集、上报、任务轮询和安装升级设计 | +| [客户端 Agent 实现规格](./client-agent/implementation-spec.md) | 可复刻的 Linux 主机采集 Agent 和后端接收链路规格 | +| [终端体系设计](./terminal/README.md) | WebSSH、Pod exec、kubectl、Workspace、审计回放等 xterm 终端统一设计 | +| [终端实现规格](./terminal/implementation-spec.md) | 可复刻的 xterm/WebSocket/PTY/K8s 终端实现细节和验收清单 | +| [Web RDP 远程桌面](./rdp/README.md) | 自研 RDP 协议栈、剪贴板、RDPDR 虚拟盘、文件传输、会话录像与审计 | +| [RDP 实现规格](./rdp/implementation-spec.md) | 可复刻的 Web RDP 图形协议、虚拟通道、文件传输、审计回放实现细节 | +| [AI Agent / Agent CLI 设计集](./ai-agent/00-README.md) | AIOps 原生 Web Agent:ReAct、工具 ACI、安全治理、会话记忆、路线评测 | +| [Redis 缓存策略](./platform/redis-caching-strategy.md) | Redis/redka 缓存使用现状与优化方案 | +| [前端视觉规范](./frontend/design-system.md) | PageShell、主题变量、业务页面视觉规范 | ## 设计文档 | 文档 | 说明 | |------|------| -| [Agent CLI 设计集](./agent/00-README.md) | ReAct 引擎、Evolver、安全系统、会话管理(10 篇) | -| [PDD 计划](./PDD_PLAN.md) | 产品驱动开发计划 | -| [项目摘要](./PROJECT_SUMMARY.md) | 项目整体概述 | -| [后端摘要](./BACKEND_SUMMARY.md) | 后端架构概述 | +| [AI Agent / Agent CLI 设计集](./ai-agent/00-README.md) | 当前压缩为 7 篇:定位、架构、执行循环、工具 ACI、安全治理、会话记忆、路线评测 | ## 根目录文档 @@ -41,12 +53,13 @@ | PageShell 设计系统 | 全站统一页面骨架,`dg-page*` CSS 变量 + 7 套主题 | | 前端架构对齐 | `api/` + `pages/` 按菜单域拆分,复杂页面迁入 `pages/` | | 国际化 (i18n) | i18next 中/英双语,已覆盖 dashboard / server / alert / approval / audit / deploy / k8s | -| Agent CLI 优化 | 终端审计回放、ssh-key 连接修复、会话管理增强 | +| Agent CLI 优化 | Web Agent 执行流、工具 ACI、安全治理、会话记忆和评测路线文档统一 | | 中间件监控 | Redis / MySQL 后端直连采集引擎 | | 日志告警 | AlertEngine 支持 `type=log` 规则,查询 Loki / ES 触发/恢复 | | 远程执行 | 新增远程执行页(执行/上传),移除 ansible 命名 | | 大模型双协议 | OpenAI 兼容 + Anthropic Messages 协议接入 | -| Web RDP 自研引擎 | 移除 grdp,SessionEngine 完整图形会话,见 [rdp.md](./rdp.md) | +| Web RDP 自研引擎 | 移除 grdp,SessionEngine 完整图形会话,见 [docs/rdp](./rdp/README.md) | +| 文档模块化 | 根目录瘦身,新增 security-bastion / monitoring / log-center / asset-credential / kubernetes / automation-deploy | ## 部署速查 @@ -68,4 +81,4 @@ Compose: ./run.sh docker up → mysql + redis + aiops (embed, :8080) K8s: ./run.sh kubernetes → lghost/aiops: embed 单 Pod ``` -> 前后端分离的 Nginx 容器已废弃,统一使用 `Dockerfile` 单容器方案。 \ No newline at end of file +> 前后端分离的 Nginx 容器已废弃,统一使用 `Dockerfile` 单容器方案。 diff --git a/docs/agent/00-README.md b/docs/agent/00-README.md index 65890f4..41e1fc6 100644 --- a/docs/agent/00-README.md +++ b/docs/agent/00-README.md @@ -1,74 +1,7 @@ -# Agent CLI 设计文档(aiops) +# Agent CLI 设计文档已迁移 -> 本文由 opsxcli 同名设计文档集适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 +本文档已迁移到: -本系列文档是 **aiops Agent CLI**(Web 版 AI 运维 Agent)的设计文档集。Agent CLI 把 opsxcli 项目的 AI Agent 能力(ReAct 推理、工具调用、自进化、安全审批)移植到 aiops 的 Gin 后端,剥离 CLI/TUI 外壳,改造成一个由 WebSocket 驱动、与 React 前端对话的「类似 Claude Code / Codex」的运维 Agent 库。 +- [docs/ai-agent/00-README.md](../ai-agent/00-README.md) -一句话定位:**用户在 Agent CLI 页面选择目标主机,用自然语言描述任务,后端 ReAct 引擎自主通过 SSH 在远程服务器上排障、修复,并通过 WebSocket 把思考、命令、输出实时回传给前端。** - ---- - -## 文档目录 - -| 编号 | 文档 | 一句话简介 | -|------|------|-----------| -| 01 | [项目概述与设计哲学](01-项目概述与设计哲学.md) | Agent CLI 的定位、四大设计哲学、与 opsxcli 的关系 | -| 02 | [整体架构设计](02-整体架构设计.md) | 引擎五层结构(controller / model / pkg / frontend / evolver)与执行流程 | -| 03 | [Harness 工程化与反馈回路](03-Harness工程化与反馈回路.md) | Harness Engineering 理论在 aiops Agent CLI 中的落地(前馈 + 反馈 + Steering) | -| 04 | [插件架构设计](04-插件架构设计.md) | Tool 接口 + Registry 工具注册中心,Web 版精简为 2 个工具 | -| 05 | [Builtin 内置工具](05-Builtin内置命令.md) | ssh_execute 与 analyze_output 两个内置工具的设计 | -| 06 | [Agent 核心引擎设计](06-Agent核心引擎设计.md) | ReAct 16 轮主循环、自适应检查点、循环检测、Evolver 集成 | -| 07 | [Prompt 构建与记忆注入](07-Prompt构建与记忆注入.md) | 五层 System Prompt 与动态记忆注入 | -| 08 | [Evolver 自进化引擎](08-Evolver自进化引擎.md) | 四层记忆 + 精简进化循环(OBSERVE→…→PREDICT) | -| 09 | [安全系统与工具注册](09-安全系统与工具注册.md) | 三级安全模式、五级风险、黑名单、WebSocket 确认 | -| 10 | [会话管理](10-会话管理.md) | GORM 数据库表持久化会话与消息(多用户隔离) | - ---- - -## opsxcli → aiops 映射总览 - -| opsxcli 模块 | opsxcli 路径 | aiops 对应 | 处理方式 | -|--------------|-------------|-----------|----------| -| Agent ReAct 核心 | `internal/agent/core/` | `backend/pkg/agentcli/core/agent.go` | **适配**:剥离 TUI,改 EventCallback 推 WebSocket;保留 16 轮循环 / 检查点 / 循环检测 | -| 工具注册中心 | `internal/agent/tools/registry.go` | `backend/pkg/agentcli/tools/registry.go` | **原样移植**:Tool 接口(5 方法)+ DynamicRiskTool + Registry(线程安全) | -| 内置工具集 | `local_bash / ssh_execute / scp_transfer / file_read / file_search / analyze_output`(6 个) | `ssh_execute` + `analyze_output`(2 个) | **精简剥离**:去掉 local_bash(不在后端服务器本地执行)、scp、file_read、file_search | -| 安全系统 | `internal/agent/safety/` | `backend/pkg/agentcli/safety/safety.go` + `tools/risk.go` | **适配**:三级模式 / 五级风险 / 黑名单保留;确认机制由 CLI 终端交互改为 WebSocket confirm 事件 + 前端弹框 | -| Prompt 构建 | `internal/agent/prompt/` | `backend/pkg/agentcli/prompt/prompt.go` | **适配**:保留五层架构,角色改写为「aiops Agent CLI 智能运维 Agent」 | -| 自进化引擎 | `internal/agent/evolver/`(13 步 + 3300 行) | `backend/pkg/agentcli/evolver/engine.go` + `memory.go`(~400 行) | **精简适配**:保留四层记忆 + 进化循环思想;步骤从 13 步精简为核心 6 步(OBSERVE→CLASSIFY→LEARN→STORE→MEMORIZE→PREDICT) | -| LLM 客户端 | `internal/llm/`(多 Provider 配置向导 + claude.go/gemini.go 等 15 Provider) | `backend/pkg/agentcli/llm/openai.go` + `types.go` | **精简剥离**:剥离 wizard 配置向导和多 Provider,统一走 OpenAI 兼容接口;配置来自 DB 表 `ai_llm_configs`(model.LLMConfig) | -| 会话管理 | `internal/agent/session/`(JSONL 文件存储) | `backend/model/agent_cli.go` + `controller/agent_cli.go`(GORM DB) | **改造**:多用户 Web 场景下从本地文件改为数据库表 `agent_cli_sessions` / `agent_cli_messages`,按 user_id 隔离 | -| 记忆存储 | `~/.opsxcli/agent/*.jsonl` | `data/agentcli/*.jsonl`(工作目录) | **适配**:从用户主目录改为应用工作目录 | -| LLM 配置 | `~/.opsxcli/providers/*.json` | DB 表 `ai_llm_configs` + 「大模型设置」页面 | **改造**:从本地文件改为 DB 表 + Web 配置页 | -| CLI 命令层 | `cmd/`(Cobra) | `backend/controller/agent_cli.go`(Gin WebSocket handler) | **改造**:从 Cobra 命令改为 WebSocket 控制器 | -| TUI 终端界面 | `internal/agent/tui/`(Bubble Tea) | `frontend/src/components/aiops/AgentCLI.tsx`(React) | **改造**:从 Bubble Tea TUI 改为 React 对话流组件 | -| 远程执行 | `internal/agent/tools/` 内 SSH 实现 | `pkg/remoteexec/`(Go-native SSH,aiops 既有) | **复用**:直接复用 aiops 已有的远程执行包 | - ---- - -## 移植决策 - -### 剥离了什么 - -1. **CLI 外壳**:去掉 Cobra 命令定义、参数解析、帮助文本 —— 由 WebSocket 控制器替代。 -2. **TUI 终端界面**:去掉 Bubble Tea + 全套终端渲染 —— 由 React 对话流组件替代。 -3. **LLM 多 Provider 配置向导**:去掉 `wizard.go` 交互式向导、`claude.go` / `gemini.go` 等专有 Provider —— 统一走 OpenAI 兼容 `/chat/completions`,覆盖 deepseek / qwen / moonshot / ollama / openai / custom。 -4. **本地执行类工具**:去掉 `local_bash`(Web 后端不在服务器本地执行命令)、`scp_transfer`、`file_read`、`file_search` —— Web 版聚焦远程运维。 -5. **Evolver 复杂分析**:去掉 opsxcli 13 步循环中的反射评分、经验整合去重、Skill 自动提炼等重型子流程 —— 精简为核心学习链路。 - -### 适配了什么 - -1. **会话持久化**:从本地 JSONL 文件改为 GORM 数据库表,支持多用户并发与按用户隔离。 -2. **记忆路径**:从 `~/.opsxcli/agent/` 改为应用工作目录 `data/agentcli/`,便于容器化部署。 -3. **LLM 配置来源**:从 `~/.opsxcli/providers/*.json` 改为 DB 表 `ai_llm_configs`,通过「大模型设置」页面配置。 -4. **确认机制**:从 CLI 终端 `bufio.Reader` 改为 WebSocket `confirm` 事件 + 前端 `useConfirm()` 弹框。 -5. **SSH 凭据**:从 `~/.ssh/config` 解析改为关联 `model.Server` 凭据(凭证系统 v2),由 controller 注入引擎。 -6. **角色定位**:从「opsxcli 智能运维超级助手」改写为「aiops Agent CLI 智能运维 Agent(Web 版,类似 Claude Code)」。 - -### 精简了什么 - -1. **工具集**:6 个 → 2 个(`ssh_execute` + `analyze_output`)。 -2. **Evolver 步骤**:13 步 → 6 步(保留学习与注入能力,剥离重型分析)。 -3. **LLM 层**:15 个 Provider 文件 → 1 个 OpenAI 兼容客户端。 -4. **记忆层级**:保留四层(经验 / 环境 / 事实 / 程序),但程序层仅保留接口位、未实现自动 Skill 提炼。 - -> 作者:Logan.Li +保留当前文件是为了兼容历史链接。// Logan.Li diff --git a/docs/agent/01-项目概述与设计哲学.md b/docs/agent/01-项目概述与设计哲学.md deleted file mode 100644 index 34ed0ca..0000000 --- a/docs/agent/01-项目概述与设计哲学.md +++ /dev/null @@ -1,230 +0,0 @@ -# 01 - 项目概述与设计哲学 - -> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 -> opsxcli 原文档把 Agent 描述为 CLI 工具(`opsxcli agent -q "..."`),aiops 改为 Web 页面 + WebSocket 事件流。涉及 CLI 特性的地方已注明 aiops 的对应实现。 - -> 建议阅读顺序:01 → 02 → 03 → ... → 10 - -## 项目定位 - -**aiops Agent CLI** —— aiops 平台内置的 Web 版智能运维 Agent。 - -类似 Claude Code / Codex,但运行在浏览器里:用户在「Agent CLI」页面选择一台目标主机,用自然语言描述运维任务(如「检查磁盘空间」「nginx 启动失败的原因」),后端 ReAct 引擎自主通过 SSH 在远程服务器上排查、修复,并通过 WebSocket 把每一步思考、执行的命令、输出与最终结果实时回传给前端。 - -这是把 opsxcli(基于 Cobra 的 CLI 运维 Agent)的 AI 能力,剥离 CLI/TUI 外壳、适配为 aiops 的 Gin 后端库后的产物。 - -## 设计目标 - -| 维度 | 目标 | -|------|------| -| 形态 | Web 对话流(WebSocket 双向通信),不是命令行 | -| 部署 | 嵌入 aiops 后端二进制(go:embed),无需独立服务 | -| 安全 | 默认 balanced;高危命令前端确认;危险命令硬拒 | -| 智能 | 16 轮 ReAct 主循环 + 自进化记忆注入 | -| 隔离 | 多用户并发,会话/消息按 user_id 隔离,持久化到 DB | - -## 五层引擎结构 - -aiops Agent CLI 不是单一包,而是分散在 aiops 的标准 Gin 分层中: - -``` -┌─────────────────────────────────────────────────────┐ -│ frontend/ (React 对话流 UI) │ -│ components/aiops/AgentCLI.tsx │ -│ - WebSocket 客户端,渲染 thought/tool_call/ │ -│ tool_result/done/error 事件流 │ -│ - 高危命令 useConfirm() 弹框确认 │ -├─────────────────────────────────────────────────────┤ -│ controller/ (HTTP/WS handler) │ -│ controller/agent_cli.go │ -│ - WebSocket 入口 /ws/agent-cli?id= │ -│ - 构建 Engine、注入 EventCallback + ConfirmFn │ -│ - 事件落库 + 推 WS │ -├─────────────────────────────────────────────────────┤ -│ model/ (GORM 模型) │ -│ model/agent_cli.go │ -│ - AgentCLISession / AgentCLIMessage │ -│ - LLMConfig (ai_llm_configs) │ -├─────────────────────────────────────────────────────┤ -│ pkg/agentcli/ (引擎库,零业务依赖) │ -│ ├── agentcli.go Engine 顶层入口 │ -│ ├── core/ ReAct 主循环 │ -│ ├── tools/ 工具注册 + ssh_execute 等 │ -│ ├── safety/ 安全控制器 │ -│ ├── prompt/ 五层 System Prompt │ -│ ├── evolver/ 自进化引擎(四层记忆) │ -│ └── llm/ OpenAI 兼容客户端 │ -├─────────────────────────────────────────────────────┤ -│ pkg/remoteexec/ (复用 aiops 既有 Go-native SSH) │ -└─────────────────────────────────────────────────────┘ -``` - -**关键设计原则**:`pkg/agentcli/` 零业务依赖 —— 不 import `model` / `controller`,所有持久化与 WebSocket 由 controller 层负责。引擎只关心「思考-行动-观察」循环本身。 - -## Agent 引擎子模块全景 - -`backend/pkg/agentcli/` 包含 7 个子包: - -``` -backend/pkg/agentcli/ -├── agentcli.go ── Engine 顶层入口:组装 LLM + 工具 + 安全 + Evolver + ReAct 核心 -├── core/ ── ReAct 核心引擎:16 轮主循环 + 自适应检查点 + 循环检测 -├── prompt/ ── Prompt 构建:五层 System Prompt + 动态记忆注入 -├── evolver/ ── 自进化引擎:四层记忆 + 精简进化循环 -├── safety/ ── 安全系统:三级模式 + 阈值确认(ConfirmFn 回调) -├── tools/ ── 工具注册中心 + 2 个内置工具 + 风险评估 -└── llm/ ── LLM 抽象层(OpenAI 兼容,剥离多 Provider) -``` - -> 与 opsxcli 的差异:opsxcli 有 `tui/` 子包(Bubble Tea 终端界面),aiops 把它替换为前端 React 组件 `AgentCLI.tsx`,由 controller 序列化事件推 WS。 - -## 四大设计哲学 - -### 1. 安全第一 (Safety First) - -> 运维工具的底线是「不搞砸」。 - -- 所有工具执行前必须通过安全评估(`safety.Controller.Check`) -- 三级安全模式:`strict` / `balanced`(默认)/ `permissive` -- 5 级风险等级:Safe → Low → Medium → High → Critical -- 危险命令黑名单硬拒(不可确认放行):`rm -rf /`、`mkfs`、`dd if=/dev/zero`、fork bomb 等 -- 高危命令经 WebSocket `confirm` 事件 + 前端 `useConfirm()` 弹框由用户确认 -- 会话与每条消息落库,事后可审计(`agent_cli_messages` 表) - -### 2. 渐进式复杂度 (Progressive Complexity) - -> 简单任务零配置,复杂任务有能力。 - -- 默认配置即可运行(`core.DefaultConfig()`) -- LLM 配置通过「大模型设置」页面填一次即生效(DB 表 `ai_llm_configs`) -- 引擎初始化时优雅降级:Evolver 失败设为 `nil`,不影响主 ReAct 循环 -- 安全模式按需升级(balanced 默认 → strict 高敏生产环境) - -### 3. 工具化思维 (Tool-Oriented) - -> 万物皆 Tool,一切可注册。 - -- 统一的 `Tool` 接口(5 方法):`Name` / `Description` / `Parameters` / `RiskLevel` / `Execute` -- `DynamicRiskTool` 扩展接口:依据运行时参数动态评估风险(如 `ssh_execute` 看具体命令) -- `Registry` 线程安全注册中心 -- 实现接口即可扩展,无需修改核心代码 -- **Web 版内置工具精简为 2 个**(剥离 opsxcli 的 local_bash / scp / file_read / file_search): - - `ssh_execute` —— 在目标主机远程执行命令(复用 `pkg/remoteexec`) - - `analyze_output` —— 对冗长输出做摘要/错误提取,避免 token 爆炸 - -### 4. 自我进化 (Self-Evolution) - -> 用得越多,越懂你。 - -- Evolver 引擎在任务完成后异步学习(30s 超时 + panic 恢复,绝不阻塞主循环) -- 四层记忆(`data/agentcli/`): - - **经验记忆**(`experience.jsonl`)—— 任务类型 → 工具序列 + 提示,按成功率优先注入 - - **环境记忆**(`environment.json`)—— 已知主机列表 - - **事实记忆**(`factual.jsonl`)—— LLM 提炼的持久事实 - - **程序记忆**(`procedural.jsonl`)—— 程序性知识(接口位,未实现自动 Skill 提炼) -- 经验按任务类型分类(磁盘空间 / 网络 / 服务管理 / 软件安装 等 11 类) -- 下次任务开始时,Evolver 通过 `GetContextForPrompt()` 把相关历史经验注入 System Prompt - -## Agent 核心工作流 - -``` -用户在 Agent CLI 页面选择主机,输入 "内存使用率" - │ - ▼ -┌──────────────────────┐ -│ WebSocket 入口 │ ← controller/agent_cli.go: Run() -│ /ws/agent-cli?id=… │ 校验 JWT、加载 model.Server、解析 SSH 凭据 -└────────┬─────────────┘ - │ - ▼ -┌──────────────────────┐ -│ Engine 组装 │ ← pkg/agentcli/agentcli.go: NewEngine() -│ LLM(OpenAI兼容) │ 注入 EventCallback + ConfirmFn -│ + tools(ssh/analyze)│ -│ + safety + Evolver │ -└────────┬─────────────┘ - │ - ▼ -┌──────────────────────┐ -│ Prompt Builder │ ← prompt/prompt.go: BuildSystemPrompt() -│ 五层 System Prompt │ Layer 1 角色 / 2 安全 / 3 工具 / 4 工作方式 / 5 记忆 -│ + Evolver 记忆注入 │ -└────────┬─────────────┘ - │ - ▼ -┌──────────────────────┐ ┌──────────────┐ -│ LLM 推理 │────▶│ 工具调用 │ -│ (ReAct 16 轮循环) │◀────│ ssh_execute │ -└────────┬─────────────┘ └──────┬───────┘ - │ │ - │ ┌──────────────────────┘ - │ │ 每步都 emit Event - │ ▼ -│ 自适应检查点 │ ┌──────────────┐ -│ (Round 4/8/12) │ │ 安全检查 │ -│ 循环检测(>3次拒绝) │ │ (RiskLevel) │ -│ 连续失败追踪 │ │ 高危→confirm│ -└────────┬─────────────┘ └──────────────┘ - │ EventCallback → controller - │ ├─ 推 WS: thought/tool_call/tool_result/done - │ └─ 落库: agent_cli_messages - ▼ (16 轮迭代或 LLM 不再返回 tool_calls) -┌──────────────────────┐ -│ Evolver 学习 │ ← 后台 goroutine,不影响响应速度 -│ (经验 + 主机 + 事实) │ -└──────────────────────┘ -``` - -## LLM 客户端策略 - -> opsxcli 有 15 个 Provider(注册制工厂 + `claude.go` / `gemini.go` 等专有实现 + 配置向导 `wizard.go`)。 -> aiops 精简为单一 OpenAI 兼容客户端,覆盖所有 OpenAI 协议兼容后端。 - -```go -// backend/pkg/agentcli/llm/openai.go -// 唯一客户端实现:走标准 /chat/completions + Bearer 鉴权 -// 支持后端:openai / deepseek / qwen / moonshot / ollama / custom -func NewClient(cfg Config) Client -``` - -配置不再来自 `~/.opsxcli/providers/*.json`,而是 DB 表 `ai_llm_configs`(`model.LLMConfig`),由 controller 在握手阶段加载并注入 Engine。 - -## 文档阅读指南 - -| 编号 | 文档 | 核心内容 | -|------|------|----------| -| 01 | 本文档 | 项目概述、设计哲学、引擎全景 | -| 02 | [整体架构设计](02-整体架构设计.md) | 五层结构、执行流程、依赖关系 | -| 03 | [Harness 工程化与反馈回路](03-Harness工程化与反馈回路.md) | Harness 理论、前馈/反馈、Steering Loop | -| 04 | [插件架构设计](04-插件架构设计.md) | Tool 接口、Registry、扩展规范 | -| 05 | [Builtin 内置工具](05-Builtin内置命令.md) | ssh_execute 与 analyze_output 设计 | -| 06 | [Agent 核心引擎设计](06-Agent核心引擎设计.md) | ReAct 主循环、自适应检查点 | -| 07 | [Prompt 构建与记忆注入](07-Prompt构建与记忆注入.md) | 五层 Prompt、动态记忆层 | -| 08 | [Evolver 自进化引擎](08-Evolver自进化引擎.md) | 四层记忆、精简进化循环 | -| 09 | [安全系统与工具注册](09-安全系统与工具注册.md) | 风险评估、安全模式、Tool 接口 | -| 10 | [会话管理](10-会话管理.md) | GORM 持久化、按用户隔离 | - -> 注:opsxcli 还有第 11 篇「TUI 终端界面设计」,aiops 已剥离 Bubble Tea TUI,由 React 组件 `AgentCLI.tsx` 替代,故本系列不含该篇。 - -## 快速开始 - -```bash -# 1. 在「大模型设置」页面配置 LLM(DB 表 ai_llm_configs,Enabled=true) -# 2. 在「服务器管理」确保目标主机凭据(凭证系统 v2)已配置 -# 3. 进入「Agent CLI」页面,选择主机 → 输入任务 → 观察 WebSocket 推送的事件流 -``` - -代码侧调用示例(controller): - -```go -engine, _ := agentcli.NewEngine(agentcli.EngineConfig{ - LLM: llm.Config{Endpoint: ..., APIKey: ..., Model: ...}, - SSH: sshCfg, // 来自 model.Server 凭据 - SafetyMode: safety.ModeBalanced, -}) -engine.SetCallback(onEvent) // 推 WS + 落库 -engine.SetConfirmFn(confirmFn) // 高危确认 -output, _ := engine.Run(ctx, query, serverInfo) -``` - -> 作者:Logan.Li diff --git a/docs/agent/02-整体架构设计.md b/docs/agent/02-整体架构设计.md deleted file mode 100644 index 413434e..0000000 --- a/docs/agent/02-整体架构设计.md +++ /dev/null @@ -1,303 +0,0 @@ -# 02 - 整体架构设计 - -> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 -> opsxcli 的「cmd/ + plugins/ + internal/」CLI 三层在 aiops 中改造为「controller/(HTTP/WS handler)+ model/(GORM)+ pkg/agentcli/(引擎库)+ frontend/(React UI)」四层。 - -## 项目结构(Agent CLI 相关部分) - -``` -aiops/ -├── backend/ -│ ├── controller/ -│ │ └── agent_cli.go # WebSocket 控制器(HTTP/WS handler 层) -│ │ # - /ws/agent-cli?id= 入口 -│ │ # - 构建 Engine、注入回调、事件落库 -│ │ # - ListSessions/GetSession/DeleteSession REST -│ ├── model/ -│ │ ├── agent_cli.go # AgentCLISession / AgentCLIMessage(GORM 模型) -│ │ └── llm.go # LLMConfig(ai_llm_configs 表) -│ ├── pkg/ -│ │ ├── agentcli/ # Agent 引擎库(零业务依赖,核心) -│ │ │ ├── agentcli.go # Engine 顶层入口 -│ │ │ ├── core/ # ReAct 主循环 + EventCallback -│ │ │ ├── tools/ # Registry + ssh_execute + analyze_output + risk -│ │ │ ├── safety/ # 安全控制器(ConfirmFn 注入) -│ │ │ ├── prompt/ # 五层 System Prompt -│ │ │ ├── evolver/ # 四层记忆 + 精简进化循环 -│ │ │ └── llm/ # OpenAI 兼容客户端 + 类型定义 -│ │ └── remoteexec/ # 复用 aiops 既有 Go-native SSH -│ └── router/routes.go # 路由注册:WS + REST 会话管理 -│ -├── frontend/ -│ ├── src/ -│ │ ├── components/aiops/AgentCLI.tsx # React 对话流组件 -│ │ ├── api/agentCli.ts # 会话历史 REST 客户端 -│ │ └── components/ui/confirm-dialog.tsx# useConfirm() 高危确认 -│ └── ... -│ -└── data/agentcli/ # Evolver 记忆目录(运行时生成) - ├── experience.jsonl # 经验记忆 - ├── factual.jsonl # 事实记忆 - ├── procedural.jsonl # 程序记忆(接口位) - └── environment.json # 环境记忆(已知主机) -``` - -## 架构设计原则 - -### 1. 分层 + 依赖注入 - -aiops Agent CLI 的核心约束是「引擎库零业务依赖」: - -- **controller 层** —— 负责协议适配(WebSocket)、凭据构建、事件持久化、用户隔离 -- **model 层** —— GORM 模型(会话、消息、LLM 配置、Server) -- **pkg/agentcli 层** —— 纯算法库,只依赖 `pkg/remoteexec` 和自己的子包,**不 import model/controller** -- **frontend 层** —— React 对话流 UI,纯事件消费 + 控制消息发送 - -依赖通过函数参数注入: - -```go -// controller/agent_cli.go 关键注入点 -engine.SetCallback(ac.onEvent) // EventCallback:推 WS + 落库 -engine.SetConfirmFn(ac.confirmFn) // ConfirmFn:高危确认(阻塞等前端回传) -``` - -引擎因此可被任何调度方复用(不仅是 WebSocket,未来也可被 cron 或审批流调用)。 - -### 2. 工具化设计 - -每个工具都是独立的 struct,实现 `Tool` 接口即可注册: - -``` -pkg/agentcli/tools/ -├── registry.go # Registry(线程安全) + Tool/DynamicRiskTool 接口 + RiskLevel -├── ssh_execute.go # 远程命令执行(DynamicRiskTool) -├── builtin.go # analyze_output(只读 Safe) -└── risk.go # 黑名单 + AssessBashRisk 动态评估 -``` - -> 与 opsxcli 的差异:opsxcli 把风险评估放在 `safety/` 包,aiops 为避免 `tools` ↔ `safety` 循环依赖,把 `risk.go` 置于 `tools` 包,由 `ssh_execute` 直接调用。 - -### 3. 配置集中化(DB 驱动) - -opsxcli 通过 `~/.opsxcli/config.yaml` 和环境变量加载配置;aiops 配置来自 DB: - -| 配置项 | opsxcli | aiops | -|--------|---------|-------| -| LLM 配置 | `~/.opsxcli/providers/*.json` | DB 表 `ai_llm_configs`(model.LLMConfig) | -| LLM 配置入口 | CLI 向导 `wizard.go` | 「大模型设置」Web 页面 | -| SSH 凭据 | `~/.ssh/config` 解析 | model.Server 关联凭证系统 v2 | -| 引擎运行参数 | config.yaml + 环境变量 | `core.DefaultConfig()`(硬编码默认,必要时通过 EngineConfig 覆盖) | -| 记忆目录 | `~/.opsxcli/agent/` | 工作目录 `data/agentcli/`(容器化友好) | - -### 4. 协议适配层(controller) - -controller 是「Web 世界」与「引擎世界」的翻译器: - -| Web 协议 | 引擎调用 | 引擎事件 → Web | -|----------|----------|----------------| -| `WS start {query}` | `engine.Run(ctx, query, serverInfo)` | emit `thought`/`tool_call`/`tool_result`/`progress`/`done`/`error` → 序列化为 `wsEvent` 推 WS | -| `WS confirm/deny {confirm_id}` | 解锁 `ConfirmFn` 的 chan | emit 前由 controller 推 `confirm` 事件 | -| `WS abort` | `cancel()` 取消 ctx | emit `error`(ctx.Err()) | -| `GET /sessions` | — | 直接查 `agent_cli_sessions` 表 | - ---- - -## 核心流程图 - -### WebSocket 握手 + 任务执行完整流程 - -以用户在 Agent CLI 页面输入「检查 nginx 启动失败的原因」为例: - -``` -用户在 Agent CLI 页面选择主机、输入任务 "检查 nginx 启动失败的原因" - │ - ▼ -┌──────────────────────────────────────────────────┐ -│ 1. WebSocket 握手 (controller/agent_cli.go: Run) │ -│ ├─ 校验 JWT (middleware.JWTAuth) │ -│ ├─ 加载 model.Server (按 ?id=) │ -│ ├─ buildSSHConfig: 解析凭证系统 v2 凭据 │ -│ ├─ 加载 model.LLMConfig (Enabled=true) │ -│ └─ upgrader.Upgrade 升级 WS │ -└────────────────────┬─────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────┐ -│ 2. 推送 session 事件(主机上下文) │ -│ wsEvent{type:"session", message:"已连接主机…"}│ -└────────────────────┬─────────────────────────────┘ - │ - ▼ (前端发来 {type:"start", query:"..."}) -┌──────────────────────────────────────────────────┐ -│ 3. startTask │ -│ ├─ DB 创建 AgentCLISession (status=running) │ -│ ├─ DB 存用户消息 (role=user) │ -│ ├─ 构建 Engine (注入 LLM/SSH/SafetyMode) │ -│ ├─ engine.SetCallback(ac.onEvent) │ -│ ├─ engine.SetConfirmFn(ac.confirmFn) │ -│ └─ go engine.Run(ctx, query, serverInfo) │ -└────────────────────┬─────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────┐ -│ 4. Engine.Run → core.Agent.Run(ReAct 循环) │ -│ │ -│ ┌───────────────────────────────────┐ │ -│ │ Prompt 构建(五层) │ │ -│ │ L1 角色 / L2 安全 / L3 工具 │ │ -│ │ L4 工作方式 / L5 Evolver 记忆 │ │ -│ └──────────────┬────────────────────┘ │ -│ ▼ │ -│ ┌───────────────────────────────────┐ │ -│ │ for iter := 0; iter < 16; iter++ { │ │ -│ │ emit progress(round) │ │ -│ │ 自适应检查点(iter 3/7/11) │ │ -│ │ trimMessages(裁剪历史) │ │ -│ │ resp = llm.Complete(...) │ │ -│ │ emit thought(resp.Content) │ │ -│ │ if 无 tool_calls: │ │ -│ │ emit done → triggerEvolve │ │ -│ │ return │ │ -│ │ for tc in resp.ToolCalls: │ │ -│ │ risk = toolRisk(...) │ │ -│ │ emit tool_call(name,args,risk) │ │ -│ │ approved = safetyCtl.Check(..)│ ←──┐ │ -│ │ if !approved: 跳过 │ │ 高危 │ -│ │ result = tool.Execute(ctx,args)│ │ 触发 │ -│ │ emit tool_result(output,...) │ │ confirm │ -│ │ records.append(..) │ │ 事件 │ -│ │ } │ │ │ -│ │ 循环检测 / 连续失败追踪 │ │ │ -│ │ } │ │ │ -│ └────────────────────────────────────┘ │ │ -└──────────────────────────────────────────┘ │ │ - │ │ - ┌───────────────────────────┐ - │ confirmFn (controller) │ - │ 推 wsEvent{type:"confirm"}│ - │ ch := make(chan bool) │ - │ select { │ - │ case approved := <-ch: │ - │ case <-5min超时: │ - │ } │ - └─────────┬─────────────────┘ - │ 前端 useConfirm() 弹框 - │ 发回 {type:"confirm"/"deny"} - ▼ - resolveConfirm(confirmID, approved) - │ ch <- approved - ▼ 解锁 safety.Check -``` - -### 事件落库映射 - -`onEvent` 回调把每个事件同时推送 WS 与落库 `agent_cli_messages`: - -| Event.Type | DB 字段 type | DB 字段 role | 备注 | -|------------|--------------|--------------|------| -| `thought` | thought | assistant | AI 思考文本 | -| `tool_call` | tool_call | tool | 含工具名、参数 JSON、风险等级 | -| `tool_result` | tool_result | tool | 含输出、成败、耗时 | -| `done` | done | assistant | 最终答案 + token 统计 | -| `error` | error | assistant | 错误信息(含达到最大轮次) | -| `progress` | — | — | 太频繁,不入库 | - -### 系统数据流 - -``` -┌────── controller 层(协议适配) ──────┐ -│ agent_cli.go (WS handler) │ -│ ├─ 加载 Server/LLMConfig │ -│ ├─ DB 创建 Session/Message │ -│ └─ 注入 Callback/ConfirmFn │ -└─────────────────┬─────────────────────┘ - │ 依赖注入 - ▼ -┌────── pkg/agentcli 层(引擎库) ──────┐ -│ agentcli.go (Engine) │ -│ ├─ core.Agent (ReAct) │ -│ │ ├─ llm.Client (OpenAI 兼容) │ -│ │ ├─ tools.Registry │ -│ │ │ ├─ ssh_execute │ -│ │ │ └─ analyze_output │ -│ │ ├─ safety.Controller │ -│ │ ├─ prompt.BuildSystemPrompt │ -│ │ └─ evolver.EvolverEngine │ -│ └─ (零 model/controller 依赖) │ -└─────────────────┬─────────────────────┘ - │ 调用 - ▼ -┌────── pkg/remoteexec(Go-native SSH)─┐ -│ Exec(cfg, command, timeout) │ -└───────────────────────────────────────┘ -``` - -## 技术栈 - -| 层 | 技术 | -|----|------| -| 后端框架 | Gin + GORM + gorilla/websocket | -| LLM 协议 | OpenAI 兼容 `/chat/completions` + function calling | -| SSH | aiops 既有 `pkg/remoteexec`(Go-native,无外部依赖) | -| 前端 | React 19 + TypeScript + shadcn/ui(`useConfirm` 弹框) | -| 持久化 | MySQL(主库)/ SQLite(降级) —— 复用 aiops 既有 DB | -| 记忆存储 | 文件 `data/agentcli/*.jsonl`(与 opsxcli 原生一致,零 DB 依赖) | - -> 为什么记忆用文件而会话用 DB?记忆是引擎库内部的产物(学习沉淀),与业务无关;会话需要多用户隔离、列表查询、删除等业务操作,必须走 DB。这是「引擎库零业务依赖」原则的延伸。 - -## 路由注册 - -`backend/router/routes.go`: - -```go -// Agent CLI — AI 运维 Agent(WebSocket 推流 + 会话历史回放) -agentCLIHandler := controller.NewAgentCLIHandler(db) -r.GET("/ws/agent-cli", middleware.JWTAuth(&cfg.JWT, db), agentCLIHandler.Run) - -agentCLIGroup := authorized.Group("/aiops/agent-cli") -{ - agentCLIGroup.GET("/sessions", agentCLIHandler.ListSessions) - agentCLIGroup.GET("/sessions/:id", agentCLIHandler.GetSession) - agentCLIGroup.DELETE("/sessions/:id", agentCLIHandler.DeleteSession) -} -``` - -WebSocket 因无法设自定义 header,JWT 通过 `?token=` query param 由 `JWTAuth` 中间件兼容解析。 - -## 添加新模块示例 - -以「增加一个新工具 `ping_check`」为例: - -1. **实现 Tool 接口**(`pkg/agentcli/tools/ping_check.go`): - -```go -type PingCheckTool struct{ cfg remoteexec.SSHConfig } - -func (t *PingCheckTool) Name() string { return "ping_check" } -func (t *PingCheckTool) Description() string { return "在目标主机 ping 指定地址" } -func (t *PingCheckTool) Parameters() map[string]interface{} { /* JSON Schema */ } -func (t *PingCheckTool) RiskLevel() tools.RiskLevel { return tools.RiskSafe } -func (t *PingCheckTool) Execute(ctx context.Context, args map[string]interface{}) (*tools.Result, error) { - // 调用 remoteexec.Exec 执行 ping -} -``` - -2. **在 Engine 中注册**(`pkg/agentcli/agentcli.go: NewEngine`): - -```go -registry.Register(tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)) -registry.Register(tools.NewAnalyzeOutputTool()) -registry.Register(tools.NewPingCheckTool(cfg.SSH)) // 新增 -``` - -3. 工具自动出现在 LLM 的 `tools` 参数中(`registry.ToLLMTools()`),Prompt Layer 3 工具描述自动生成(`buildToolDescriptions()`)。无需改 Prompt、无需改 controller、无需改前端。 - -## 设计目标 - -1. **Web 友好**:所有交互经 WebSocket,适配浏览器对话流 -2. **零业务耦合**:引擎库可独立测试与复用 -3. **复用 aiops 既有能力**:LLM 配置、SSH、凭证系统、DB -4. **易于扩展**:Tool 接口 + Registry,实现接口即扩展 -5. **可审计**:每条事件落库,会话历史可回放 - -> 作者:Logan.Li diff --git a/docs/agent/03-Harness工程化与反馈回路.md b/docs/agent/03-Harness工程化与反馈回路.md deleted file mode 100644 index 7969d00..0000000 --- a/docs/agent/03-Harness工程化与反馈回路.md +++ /dev/null @@ -1,276 +0,0 @@ -# 03 - Harness 工程化与反馈回路 - -> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 -> Harness Engineering 是与具体语言/形态无关的理论框架,原文的思想性内容(控制论、Steering Loop、四类调节 Harness)几乎完全保留;落地实例部分从「opsxcli CLI + golangci-lint」改写为「aiops 后端库 + WebSocket 反馈回路 + Evolver 自进化」。 - ---- - -## 1. 核心定义:Agent = Model + Harness - -### 1.1 什么是 Harness - -> **Agent = Model + Harness** — LangChain - -Harness 是 Agent 中除 Model 本身之外的一切。在大模型运维 Agent 的有界上下文中,Harness 分为两个层次: - -- **内置 Harness(built-in harness)**:引擎作者构建的部分 —— System Prompt、工具注册机制、ReAct 编排循环 -- **外部 Harness(outer harness)**:业务方为自身场景构建的部分 —— 安全模式、确认机制、记忆注入、审计落库 - -Model 是「大脑」,Harness 是「身体 + 环境 + 工具 + 规则」。大脑决定智能,但身体和环境决定智能能否被正确、可靠、持续地发挥。 - -### 1.2 两个目标 - -> 一个构建良好的 Harness 服务于两个目标: -> 1. **提高智能体首次就做对的概率**(feedforward) -> 2. **提供反馈回路,在问题到达人类前尽可能自我纠正**(feedback) -> — Martin Fowler / Birgitta Böckeler - -这两个目标分别对应**前馈指导**和**反馈传感器**,是 Harness 的双引擎。 - -### 1.3 核心理念 - -> **Humans steer. Agents execute.** — OpenAI - -人类的工作重心从「敲每一条命令」,转向**设计环境、明确意图和构建反馈回路**。在 aiops Agent CLI 中,运维人员的角色从「亲自 SSH 排障」变为「在 Web 上描述任务、审阅高危确认、监督 Agent 执行」。 - ---- - -## 2. 控制论基础:前馈与反馈 - -### 2.1 前馈指导 vs 反馈传感器 - -| 维度 | 前馈指导(Feedforward Guides) | 反馈传感器(Feedback Sensors) | -|------|-------------------------------|-------------------------------| -| **时机** | 智能体行动**之前** | 智能体行动**之后** | -| **目的** | 预期行为并加以引导 | 观察产出并帮助自我纠正 | -| **效果** | 提高首次做对的概率 | 在问题到达人类前自我纠正 | -| **aiops 实例** | 五层 System Prompt、工具描述、安全规则文本 | 安全 Check 拦截、循环检测、连续失败追踪、自适应检查点 | - -> 仅有前馈,规则永远无法验证是否奏效;仅有反馈,智能体会重复同样的错误。两者缺一不可。 - -### 2.2 计算型 vs 推理型 - -| 类型 | 特点 | 例子 | -|------|------|------| -| **计算型(Computational)** | 确定性、快速、CPU 执行 | 黑名单匹配、命令风险分级、循环检测、token 估算 | -| **推理型(Inferential)** | 语义分析、AI 审查、LLM 调用 | Evolver 用 LLM 提炼经验、LLM 决定下一步工具 | - -计算型传感器成本低、速度快,适合每次工具调用都运行(如 `safety.Controller.Check`、`AssessBashRisk`)。推理型传感器更强大但更贵、不确定,适合关键节点(如任务完成后的经验提炼)。 - -推理型传感器的特别威力:当反馈信号被优化为 LLM 可消费的格式时(如把「连续失败」翻译为「请换一种思路」的中文提示注入对话),形成一种**积极的 prompt injection** —— 这正是 aiops 自适应检查点的设计依据。 - -### 2.3 Steering Loop 掌舵循环 - -Steering Loop 是 Harness Engineering 的元过程 —— 通过迭代改进 Harness 来引导智能体。 - -``` -┌──────────┐ Agent 工作 ┌──────────┐ 系统改进 ┌──────────┐ -│ Harness │ ──────────► │ 观察问题 │ ──────────► │ 更新Harness│ -│ (当前版本) │ │(多次发生) │ │ (新版本) │ -└──────────┘ └──────────┘ └──────────┘ - ▲ │ - └────────────────────────────────────────────────────┘ - 循环往复 -``` - -在 aiops Agent CLI 中,Steering Loop 有两种实现: - -1. **人工 Steering**:管理员在「大模型设置」调整 SystemPrompt、温度等 → 影响 Agent 下次行为 -2. **自动 Steering**:Evolver 引擎观察任务执行 → 自动学习经验 → 注入下次 Prompt(详见第 08 篇) - -后者是 opsxcli 设计思想的核心延伸 —— 用代码自动化 Steering Loop。 - -### 2.4 Keep Quality Left — 把质量左移 - -在 aiops Agent CLI 的执行链路中,反馈传感器按成本分布: - -| 阶段 | 应运行的传感器 | 特点 | -|------|---------------|------| -| **工具执行前** | 黑名单硬拒、`AssessBashRisk` 分级、安全模式阈值 | 毫秒级,每次必跑 | -| **工具执行中** | context 超时(`ToolTimeout` 120s 兜底) | 防卡死 | -| **轮次间** | 自适应检查点(Round 4/8/12)、循环检测(>3 次拒绝)、连续失败追踪 | 防 LLM 跑偏 | -| **任务完成后** | Evolver 异步学习(30s 超时) | 较贵,异步 | - ---- - -## 3. 三类调节 Harness - -> Agent Harness 如同一个控制论调节器(governor),通过前馈和反馈将系统调节到期望状态。 - -### 3.1 可维护性 Harness(Maintainability) - -- 计算型:文件大小红线(Go handler ≤400 行、组件 ≤700 行)、`go vet`、`tsc --noEmit` -- 推理型:代码审查(review) -- 在 Agent CLI 代码中体现为「引擎库零业务依赖」「单文件 ≤300 行」等约束 - -### 3.2 架构适应度 Harness(Architecture Fitness) - -- 前馈:分层依赖约定(controller → model + pkg/agentcli,引擎库不反向 import) -- 反馈:编译失败即拦截(Go 强类型天然传感器) - -### 3.3 行为 Harness(Behaviour)— 本项目的重点 - -如何引导和感知 Agent 是否按预期功能运行: - -- 前馈:五层 System Prompt(角色 + 安全规则 + 工作方式 ReAct 范式) -- 反馈: - - 安全层硬拦截 + 高危确认(防破坏性操作) - - ReAct 循环中的自适应检查点(防 LLM 死循环或编造输出) - - WebSocket 事件流的可观测性(人类可随时观察思考与命令) - - Evolver 经验学习(成功的工具序列被强化,失败的被记录为「换法」提示) - ---- - -## 4. Harnessability 与设计启示 - -### 4.1 可 Harness 性 - -aiops 后端是 Go 强类型 + 清晰的包边界,天然适合被 Harness: - -- 强类型使 LLM 工具的 JSON Schema 可以严格约束参数 -- 清晰的 controller / model / pkg 分层使「引擎库零业务依赖」可行 -- 复用 aiops 既有的凭证系统、SSH 包、DB,大幅缩减 Harness 代码量 - -### 4.2 Ashby 必要多样性定律 - -> 调节器必须拥有至少与被调节系统相同的多样性。 - -aiops Agent CLI 通过两层多样性缩减: - -1. **工具集精简**(6 → 2):只保留 `ssh_execute` + `analyze_output`,把「在远程主机做任何事」收敛为「执行 shell + 分析输出」,让 LLM 的决策空间可控 -2. **Prompt 工作方式约束**:明确告诉 LLM「一次只做关键的、可验证的一步」「优先只读命令」「失败要换法重试」 - -这是把潜在的「无限可能的 Agent」收敛为「可控的运维 Agent」的关键策略。 - -### 4.3 Harness 模板 - -aiops Agent CLI 本身就是一个「Web 版运维 Agent Harness 模板」—— 它的前馈(五层 Prompt)、反馈(安全 + 检查点 + Evolver)、工具协议(Tool 接口)可以被视为同类产品的参考实现。 - ---- - -## 5. 反馈回路设计 - -### 5.1 多轮自我评审循环 - -aiops ReAct 循环本质就是一种多轮自我评审: - -``` -Round 1:LLM 思考 + 决定工具 - │ -Round 2:执行工具 → 观察输出(exit_code 是关键信号) - │ -Round 3:根据输出判断 → 继续排障或修复 - │ -═══ 检查点 Round 4 ═══ - ├─ 若重复调用率 >50% 或连续失败 ≥3 → 注入「换思路」提示 - │ -Round N:… 直到 LLM 不再返回 tool_calls - │ -═══ 检查点 Round 8 ═══ 强制中间总结 -═══ 检查点 Round 12 ═══ 敦促收尾 - │ -最终答案 + Evolver 学习(后台) -``` - -### 5.2 黄金原则 - -从 OpenAI Codex 实践提炼的四条原则在 aiops Agent CLI 中的体现: - -1. **优先使用共享抽象**:`pkg/remoteexec`(SSH)、`pkg/agentcli/tools`(Tool 接口)、`pkg/agentcli/llm`(Client 接口),将不变式集中管理 -2. **不使用「YOLO 式」探测**:所有命令先经 `AssessBashRisk` 评估,黑名单硬拒、高危确认,绝不盲目执行 -3. **统一错误处理**:工具返回 `Result{Success, Output, Error}`,错误信息以中文回到对话供 LLM 理解;Evolver 失败优雅降级不阻断主循环 -4. **避免全局状态**:`Engine` 每次任务一个实例,`Registry` 通过 `sync.RWMutex` 保护;会话状态走 DB 不走全局变量 - ---- - -## 6. aiops Agent CLI 代码对照 - -### 6.1 前馈指导实例 - -| 理论概念 | aiops 实现 | 代码位置 | -|----------|-----------|----------| -| 角色与能力引导 | 五层 System Prompt | `pkg/agentcli/prompt/prompt.go` | -| 工具能力披露 | Registry 动态生成工具描述 | `core/agent.go: buildToolDescriptions` | -| 工作范式引导 | ReAct 范式文本(思考-行动-观察-循环-收尾) | `prompt.go` Layer 4 | -| 安全规则文本 | 黑名单 + 优先只读 + 高危确认 | `prompt.go` Layer 2 | - -### 6.2 反馈传感器实例 - -| 理论概念 | aiops 实现 | 代码位置 | -|----------|-----------|----------| -| 计算型反馈(执行前) | 黑名单硬拒 + 风险分级 | `tools/risk.go` + `safety/safety.go: Check` | -| 计算型反馈(轮次间) | 循环检测(同参 >3 拒绝) | `core/agent.go: processToolCalls` | -| 计算型反馈(轮次间) | 连续失败追踪 | `core/agent.go: updateConsecutiveFailures` | -| 结构化反馈 | 自适应检查点(Round 4/8/12 注入提示) | `core/agent.go: checkAdaptiveCheckpoint` | -| 上下文保护 | 消息裁剪 + tool 消息完整性修复 | `core/agent.go: trimMessages` + `ensureToolMessageIntegrity` | -| 安全反馈 | 三级模式阈值 + 高危确认 | `safety/safety.go` | - -### 6.3 Steering Loop 实例 - -aiops 的 **Evolver 自进化引擎**是自动 Steering Loop 的代码级实现: - -``` -EvolverEngine (pkg/agentcli/evolver/) - │ - ├── engine.go 精简进化循环(OBSERVE→CLASSIFY→LEARN→STORE→MEMORIZE→PREDICT) - └── memory.go 四层记忆(experience/factual/procedural/environment) -``` - -每次任务执行后,Evolver 异步学习并存储经验;下次任务开始时,`GetContextForPrompt()` 把相关历史经验注入 System Prompt 的 Layer 5 —— 这就是「观察问题 → 改进 Harness」的自动化实现。 - -### 6.4 品味不变式实例 - -| 类别 | 不变式 | 理由 | -|------|--------|------| -| 语言 | 注释和错误信息使用中文,署名 `// Logan.Li` | 项目规范 | -| 命名 | 导出函数 PascalCase,包名全小写 | Go 标准 | -| 错误 | `Result.Error` 携带中文错误,Evolver 失败降级不 panic | 保留错误链,不阻断主流程 | -| 依赖 | controller → model + pkg/agentcli(单向) | 引擎库零业务依赖 | -| 日志 | 引擎 emit Event,由 controller 决定落库还是推 WS | 引擎不直接依赖 DB/WS | -| 安全 | 黑名单硬拒不可被确认放行 | 安全红线 | - ---- - -## 7. Web 可观测性 - -> opsxcli 是 CLI 工具,可观测性 = slog + 审计日志 + `--debug`。 -> aiops Agent CLI 是 Web 服务,可观测性 = **WebSocket 事件流 + DB 持久化 + 会话历史回放**。 - -### 7.1 事件流(实时) - -引擎通过 `EventCallback` 推送结构化事件,controller 序列化为 `wsEvent` 后推 WebSocket: - -| Event.Type | 含义 | 前端渲染 | -|------------|------|----------| -| `progress` | 轮次 + token | 顶部进度条 | -| `thought` | AI 思考文本 | 思考气泡 | -| `tool_call` | 即将执行的工具 + 风险 | 工具调用卡片(含风险标签) | -| `tool_result` | 工具输出 + 成败 + 耗时 | 工具结果卡片 | -| `confirm` | 高危确认请求 | useConfirm 弹框 | -| `done` | 最终答案 | 完成气泡 | -| `error` | 错误(含达到最大轮次) | 错误气泡 | - -### 7.2 持久化(事后审计) - -每条事件(除 progress)落库 `agent_cli_messages`,前端「会话历史」页面可按 session_id 回放完整 ReAct 推理过程: - -```go -// controller/agent_cli.go: persistMsg -// 每条消息记录:round / type / role / content / tool_name / tool_args / -// tool_output / risk / success / duration / tokens -``` - -> 与 opsxcli 的差异:opsxcli 把审计日志写到 `~/.opsxcli/audit/audit.log`(文件),aiops 直接走 DB(多用户、可查询、可关联用户与主机)。 - ---- - -## 8. 参考资源 - -| 资源 | 说明 | -|------|------| -| [Martin Fowler — Harness Engineering](https://martinfowler.com/articles/harness-engineering.html) | 理论框架,控制论视角 | -| [OpenAI — Harness Engineering](https://openai.com/zh-Hans-CN/index/harness-engineering) | 实践报告,Codex 实战经验 | -| [LangChain — Agent = Model + Harness](https://blog.langchain.com/the-anatomy-of-an-agent-harness/) | 术语定义 | -| [Anthropic — Effective Harnesses for Long-Running Agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) | 长时间运行 Agent 的 Harness 设计 | - -> 作者:Logan.Li diff --git a/docs/agent/04-插件架构设计.md b/docs/agent/04-插件架构设计.md deleted file mode 100644 index 8eb0988..0000000 --- a/docs/agent/04-插件架构设计.md +++ /dev/null @@ -1,177 +0,0 @@ -# 04 - 插件架构设计 - -> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 -> opsxcli 的「plugins/」CLI 插件层(mysql/ssh/ping 等 46+ 命令)在 aiops 中不适用 —— Web 版聚焦远程运维,工具精简为 2 个并改由 `tools.Tool` 接口承载。 - -## 工具(Tool)= aiops 版的「插件」 - -opsxcli 有完整的 CLI 插件生态(46+ 命令插件);aiops Agent CLI 把「插件」概念收敛为更纯粹的 **Tool 接口**: - -- 不再有 CLI 命令、参数解析、帮助文本 -- 工具的唯一职责是:被 LLM 调用 → 在目标主机做事 → 返回结构化结果 -- LLM 通过 function calling 协议感知工具的存在与用法 - -> 这是合理的精简:Web Agent 的能力边界由「Tool 接口 + LLM 推理」共同决定,不再需要 CLI 命令那一层。 - -## Tool 接口(5 方法) - -```go -// backend/pkg/agentcli/tools/registry.go - -type Tool interface { - Name() string // 工具名(LLM 调用时用) - Description() string // 给 LLM 看的描述 - Parameters() map[string]interface{} // JSON Schema 参数定义 - RiskLevel() RiskLevel // 静态风险等级 - Execute(ctx context.Context, args map[string]interface{}) (*Result, error) -} -``` - -设计哲学:**接口最小化** —— 5 个方法覆盖工具的完整生命周期。 - -### DynamicRiskTool 扩展接口 - -```go -type DynamicRiskTool interface { - Tool - RiskLevelForArgs(args map[string]interface{}) RiskLevel -} -``` - -部分工具的「静态风险」无法反映真实风险(如 `ssh_execute` 静态是 High,但 `df -h` 是 Safe、`rm -rf /` 是 Critical)。这些工具额外实现 `DynamicRiskTool`,安全控制器 `Check` 时优先调用动态评估。 - -### Result 结构 - -```go -type Result struct { - Success bool `json:"success"` - Output string `json:"output"` - Error string `json:"error,omitempty"` -} -``` - -## Registry 注册中心 - -```go -type Registry struct { - tools map[string]Tool - mu sync.RWMutex // 读写锁,支持并发注册和查询 -} -``` - -核心方法: - -| 方法 | 说明 | -|------|------| -| `NewRegistry()` | 创建空注册中心 | -| `Register(tool)` | 注册工具(线程安全) | -| `Get(name)` | 按名获取工具(LLM 调用时按名查找) | -| `List()` | 列出所有工具(构建 Prompt Layer 3 工具描述) | -| `ToLLMTools()` | 转换为 OpenAI function calling 格式(自动注入到 LLM `tools` 参数) | - -`ToLLMTools()` 是 Registry 与 LLM 协议的桥梁:注册的工具自动出现在 LLM 的 function 列表中,无需手动维护。 - -## 已注册工具一览 - -> 与 opsxcli 的关键差异:opsxcli 有 6 个工具(local_bash / ssh_execute / scp_transfer / file_read / file_search / analyze_output),aiops 精简为 2 个。 - -| 工具 | 风险等级 | 功能 | 来源 | -|------|----------|------|------| -| `ssh_execute` | High(静态)/ Safe~Critical(动态) | 在目标主机远程执行 shell 命令 | aiops 既有 `pkg/remoteexec` | -| `analyze_output` | Safe | 对冗长输出做摘要/错误提取 | 内置 Go 实现 | - -### 为什么精简? - -| opsxcli 工具 | aiops 处理 | 原因 | -|--------------|-----------|------| -| `local_bash` | **剥离** | Web 后端不应在服务器本地执行命令(安全风险 + 多用户隔离) | -| `scp_transfer` | **剥离** | 文件传输走 aiops 既有的「文件管理」模块,不混入 Agent | -| `file_read` | **剥离** | 远程读文件用 `ssh_execute cat`,无需独立工具 | -| `file_search` | **剥离** | 远程搜文件用 `ssh_execute grep/find` | -| `ssh_execute` | **保留** | 核心远程运维能力 | -| `analyze_output` | **保留** | 防止长输出 token 爆炸,辅助 LLM 聚焦关键信息 | - -工具越少,LLM 的决策空间越收敛(呼应 Ashby 定律),首次做对的概率越高。 - -## 工具分类标准(aiops 版) - -### 放入内置工具集的条件(需同时满足) - -1. 属于运维 Agent 高频需要的能力 -2. 无法用现有工具简单组合完成(如 `analyze_output` 不能用 `ssh_execute` 替代 —— 它操作的是已有文本而非远程命令) -3. 实现简洁(单文件 < 300 行) - -### 通过 `ssh_execute` 间接覆盖的能力 - -opsxcli 用独立插件实现的能力,在 aiops 中全部由 `ssh_execute` 执行远程命令覆盖: - -| opsxcli 插件能力 | aiops 等效 | -|------------------|-----------| -| `mysql -e "..."` | `ssh_execute "mysql -e \"...\""` | -| `redis-cli ...` | `ssh_execute "redis-cli ..."` | -| `ping / traceroute` | `ssh_execute "ping ..."` | -| `cat / grep / find` | `ssh_execute "cat ..."` | -| `systemctl status nginx` | `ssh_execute "systemctl status nginx"` | - -LLM 会根据任务自主决定执行什么命令,无需为每类命令提供独立工具。 - -## 混合架构的好处 - -1. **职责单一**:每个工具只做一件事,`ssh_execute` 执行、`analyze_output` 分析 -2. **协议简单**:LLM 只需理解 2 个 function,决策快、错误少 -3. **风险可控**:所有远程操作统一走 `ssh_execute` 的风险评估(`AssessBashRisk`),不会因为工具增多而出现安全盲区 -4. **扩展灵活**:未来若需要新工具(如 `kubectl_apply`),实现 Tool 接口注册即可 - -## 扩展指南 - -### 添加新工具 - -1. 实现 `Tool` 接口(必要时实现 `DynamicRiskTool`): - -```go -// pkg/agentcli/tools/kubectl_get.go -type KubectlGetTool struct{ cfg remoteexec.SSHConfig } - -func (t *KubectlGetTool) Name() string { return "kubectl_get" } -func (t *KubectlGetTool) Description() string { - return "在目标主机执行 kubectl get 并返回结果" -} -func (t *KubectlGetTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "resource": map[string]interface{}{"type": "string", "description": "如 pods/deployments"}, - }, - "required": []string{"resource"}, - } -} -func (t *KubectlGetTool) RiskLevel() tools.RiskLevel { return tools.RiskSafe } -func (t *KubectlGetTool) Execute(ctx context.Context, args map[string]interface{}) (*tools.Result, error) { - // 复用 remoteexec 执行 kubectl get -} -``` - -2. 在 `agentcli.NewEngine` 中注册: - -```go -registry.Register(tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)) -registry.Register(tools.NewAnalyzeOutputTool()) -registry.Register(tools.NewKubectlGetTool(cfg.SSH)) // 新增 -``` - -3. 工具自动出现在 LLM 的 function 列表与 Prompt Layer 3 工具描述中。无需改 Prompt、controller、前端。 - -## 常见陷阱 - -| 陷阱 | 解决方案 | -|------|----------| -| 忘记设置 `RiskLevel` 导致误判 | 默认按接口返回值,高危工具必须显式声明 | -| 高危工具未实现 `DynamicRiskTool` | 静态等级无法反映参数差异(如 `ssh_execute` 看 `df` 还是 `rm -rf`),必须实现动态评估 | -| 工具 Execute 无超时 | `Config.ToolTimeout=120s` 兜底(`context.WithTimeout`) | -| 工具输出过长导致 token 爆炸 | `OutputMaxLength=10000` 截断 + 引导 LLM 调用 `analyze_output` | - -## 总结 - -aiops Agent CLI 的「插件架构」是 opsxcli 的精简版:从「46+ CLI 插件」收敛为「2 个 Tool 接口实现」。这不是能力的退化,而是**收敛决策空间、聚焦远程运维、降低 LLM 误判概率**的有意设计。详细工具实现见 [05 - Builtin 内置工具](05-Builtin内置命令.md)。 - -> 作者:Logan.Li diff --git a/docs/agent/05-Builtin内置命令.md b/docs/agent/05-Builtin内置命令.md deleted file mode 100644 index e611296..0000000 --- a/docs/agent/05-Builtin内置命令.md +++ /dev/null @@ -1,248 +0,0 @@ -# 05 - Builtin 内置工具 - -> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/`)。 -> opsxcli 原文讨论「builtin 基础命令(ls/cat/grep 等)的 Go 原生实现 vs 命令转发」抉择;aiops 版剥离了所有本地命令,仅保留 2 个工具:`ssh_execute`(远程执行)+ `analyze_output`(输出分析)。本文聚焦这 2 个工具的设计。 - -## 设计目标 - -为 aiops Agent CLI 提供**最小而充分**的工具集: - -- `ssh_execute` 覆盖所有「在远程主机做事」的需求(排障、安装、配置、重启) -- `analyze_output` 解决「命令输出过长导致 LLM token 爆炸」的问题 - -工具数量从 opsxcli 的 6 个精简为 2 个,但能力边界通过 LLM 自主编排命令来扩展,而非通过堆工具。 - -## 工具 1:ssh_execute(远程命令执行) - -### 定位 - -在目标主机上执行任意 shell 命令,返回合并的 stdout + stderr + exit_code。这是 Agent 真正「动手」的唯一通道。 - -### 实现 - -```go -// backend/pkg/agentcli/tools/ssh_execute.go - -type SSHExecuteTool struct { - cfg remoteexec.SSHConfig // 目标主机连接配置(含凭据,由 controller 注入) - timeout time.Duration // 默认命令超时(60s) -} - -func (t *SSHExecuteTool) Name() string { return "ssh_execute" } - -func (t *SSHExecuteTool) Description() string { - return fmt.Sprintf( - "在远程服务器 %s(用户 %s)上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。"+ - "用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。"+ - "高危命令(rm -rf /、mkfs、reboot 等)需用户确认。", host, user) -} - -func (t *SSHExecuteTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "command": map[string]interface{}{ - "type": "string", - "description": "要执行的 shell 命令,例如 df -h、systemctl status nginx、tail -n 100 /var/log/messages", - }, - "timeout": map[string]interface{}{ - "type": "integer", - "description": "命令超时秒数(默认 60)", - }, - }, - "required": []string{"command"}, - } -} -``` - -### 风险评估:DynamicRiskTool - -`ssh_execute` 是典型「静态等级无法反映真实风险」的工具,因此实现 `DynamicRiskTool`: - -```go -// 静态等级:High(保守) -func (t *SSHExecuteTool) RiskLevel() RiskLevel { return RiskHigh } - -// 动态等级:看具体命令 -func (t *SSHExecuteTool) RiskLevelForArgs(args map[string]interface{}) RiskLevel { - return AssessBashRisk(StringParam(args, "command")) -} -``` - -| 命令示例 | 动态风险 | 安全模式 balanced 下的行为 | -|----------|----------|--------------------------| -| `df -h` / `ps aux` / `cat /etc/hosts` | Safe | 直接执行 | -| `mkdir /tmp/foo` / `touch x` | Low | 直接执行 | -| `chmod 644 /etc/cfg` / `cp a b` | Medium | strict 需确认,balanced 放行 | -| `systemctl restart nginx` / `kill 1234` | High | 弹框确认 | -| `rm -rf /var/log/*` | Critical | **硬拒**(不可确认放行) | - -### Execute 流程 - -```go -func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) { - command := strings.TrimSpace(StringParam(args, "command")) - if command == "" { - return &Result{Success: false, Error: "参数 command 不能为空"}, nil - } - // ... timeout 解析 - res, err := remoteexec.Exec(t.cfg, command, timeout) - if err != nil { - return &Result{Success: false, Error: "SSH 连接失败: " + err.Error()}, nil - } - // 关键:附带 exit_code,让 LLM 判断命令是否成功(排障关键信号) - output := res.Output + fmt.Sprintf("[exit_code=%d]", res.ExitCode) - return &Result{Success: res.Success, Output: output, Error: res.Error}, nil -} -``` - -**设计要点**: - -- **复用 aiops 既有 `pkg/remoteexec`**:Go-native SSH,无外部依赖,凭据由 controller 从 `model.Server` 关联凭证系统构建后注入 -- **`exit_code` 是关键信号**:LLM 通过 `exit_code` 判断命令是否真正成功(避免 LLM 误以为「输出有内容」就是「执行成功」) -- **连接级失败与命令级失败分离**:SSH 连不上返回 `Error`,命令执行返回 `exit_code != 0`,让 LLM 区分排查方向 -- **凭据不暴露给 LLM**:`SSHConfig` 是工具私有的,LLM 只看到 `command` 参数 - -## 工具 2:analyze_output(输出分析) - -### 定位 - -对一段(可能很长的)命令输出做摘要:统计行数、提取疑似错误/告警行、必要时截断保留首尾。**纯文本处理,不执行任何命令**,因此 `RiskSafe`。 - -### 为什么需要它? - -LLM 上下文窗口有限(即使 128k 模型,超长输出也会显著增加成本和延迟)。`ssh_execute` 单次输出截断在 10000 字符,但即便如此,日志类输出(如 `journalctl -n 1000`)仍可能让 LLM 难以聚焦。 - -`analyze_output` 让 LLM 自主决定「输出太长时先分析再推理」: - -``` -Round 3: ssh_execute "journalctl -u nginx -n 500" - → 输出 500 行 -Round 4: analyze_output(output, focus="error") - → 提取 8 行疑似错误 + 截断保留首尾 -Round 5: LLM 基于精炼后的信息推理 -``` - -### 实现 - -```go -// backend/pkg/agentcli/tools/builtin.go - -func (t *AnalyzeOutputTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) { - output := StringParam(args, "output") - focus := strings.ToLower(StringParam(args, "focus")) - // 1. 超长截断:保留前 40 + 后 40 行,中间用省略标注 - // 2. 提取疑似错误/告警行(errorKeywords,含中英文) - // - 英文:error / fail / exception / traceback / denied / timeout / refused / critical / fatal - // - 中文:错误 / 失败 / 拒绝 / 超时 / 无法 / 异常 - // - 若传 focus,把 focus 作为额外关键词优先匹配 - // 3. 最多 20 条疑似错误行 - // 4. 组装摘要:总行数 + 截断标注 + 错误行 + 输出片段 -} -``` - -**错误关键词列表**: - -```go -errorKeywords := []string{ - "error", "fail", "exception", "traceback", "denied", - "permission", "timeout", "refused", "unavailable", "critical", "fatal", - "错误", "失败", "拒绝", "超时", "无法", "异常", -} -``` - -**输出格式示例**: - -``` -总行数: 500(已截断保留首尾) -疑似错误/告警行 (8): - nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use) - nginx: [emerg] bind() to [::]:80 failed (98: Address already in use) - ... - -输出片段: -... (前 40 行) -... [已省略 420 行] ... -... (后 40 行) -``` - -## 工具组合:典型 ReAct 序列 - -`ssh_execute` 与 `analyze_output` 的组合覆盖绝大多数运维场景: - -### 场景 1:磁盘满排障 - -``` -Round 1: ssh_execute "df -h" → 发现 /var 满 -Round 2: ssh_execute "du -sh /var/*" → 定位 /var/log 最大 -Round 3: ssh_execute "ls -lah /var/log | head -20" → 找到 access.log 巨大 -Round 4: 给出清理建议(不直接 rm,让用户确认) -``` - -### 场景 2:服务启动失败 - -``` -Round 1: ssh_execute "systemctl status nginx" → failed -Round 2: ssh_execute "journalctl -u nginx -n 500" → 500 行日志 -Round 3: analyze_output(output, focus="error") → 提取 8 行错误,端口被占 -Round 4: ssh_execute "ss -tlnp | grep :80" → 确认 80 端口被占用 -Round 5: 给出根因 + 解决建议 -``` - -### 场景 3:性能排查 - -``` -Round 1: ssh_execute "top -bn1 | head -20" → CPU 100% 进程 -Round 2: ssh_execute "ps aux --sort=-%cpu | head -10" → 定位 java 进程 -Round 3: ssh_execute "jstack " → 线程栈 -Round 4: analyze_output(jstack_output, focus="BLOCKED") → 死锁线索 -Round 5: 给出根因(线程死锁) -``` - -## 工具与 Prompt 的协作 - -Prompt Layer 2 明确告诉 LLM: - -> 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。 - -Prompt Layer 3 自动列出工具描述(来自 `buildToolDescriptions()`): - -``` -【可用工具】 -- ssh_execute: 在远程服务器 ... 上执行 shell 命令 ... -- analyze_output: 分析一段(可能很长的)命令输出文本 ... -``` - -工具的命名和描述本身就是给 LLM 的「使用说明书」,描述写得好,LLM 就能正确选用。 - -## 扩展指南 - -### 何时应该新增工具(而非用 ssh_execute)? - -满足以下任一条件: - -1. **命令协议复杂、LLM 容易写错**(如 `kubectl apply -f` 需要 YAML 参数,用 `ssh_execute` 难以表达) -2. **需要结构化输入/输出**(如数据库查询返回表结构) -3. **需要专门的风险评估逻辑**(如 `kubectl delete` 需要按资源类型分级) - -如果只是「执行某条命令」,用 `ssh_execute` 即可,不需要新工具。 - -### 实现步骤 - -见 [04 - 插件架构设计](04-插件架构设计.md) 的「添加新工具」章节。 - -## 常见陷阱 - -| 陷阱 | 解决方案 | -|------|----------| -| `ssh_execute` 输出过长撑爆上下文 | `OutputMaxLength=10000` 截断 + 引导调用 `analyze_output` | -| LLM 把「输出有内容」误判为「命令成功」 | 输出末尾附 `[exit_code=N]`,Prompt 显式提醒 | -| `analyze_output` 关键词覆盖不全 | 扩展 `errorKeywords` 列表(含中英文) | -| 命令超时无反馈 | `context.WithTimeout(ctx, ToolTimeout)` + 失败信息回写 | -| 凭据泄漏给 LLM | `SSHConfig` 是工具私有字段,不在 `Parameters` 中暴露 | - -## 总结 - -aiops Agent CLI 的工具集设计遵循「少即是多」:2 个工具 + LLM 自主编排,覆盖绝大多数远程运维场景。`ssh_execute` 是「手」(动手做事),`analyze_output` 是「放大镜」(聚焦关键信息),二者配合让 Agent 既有行动力又有判断力。 - -> 作者:Logan.Li diff --git a/docs/agent/06-Agent核心引擎设计.md b/docs/agent/06-Agent核心引擎设计.md deleted file mode 100644 index f047134..0000000 --- a/docs/agent/06-Agent核心引擎设计.md +++ /dev/null @@ -1,415 +0,0 @@ -# 06 - Agent 核心引擎设计 - -> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/core/agent.go`)。 -> 核心算法(16 轮 ReAct 主循环、自适应检查点、循环检测、连续失败追踪、Evolver 集成)全部保留;TUI 回调(Bubble Tea)剥离,改为 `EventCallback` 推 WebSocket 事件流。 - -## 设计理念 - -Agent 核心引擎遵循 **ReAct (Reasoning + Acting)** 范式: - -- **Reasoning**:LLM 推理决定下一步行动(产出 `thought` 文本 + 工具调用计划) -- **Acting**:调用工具执行具体操作(`ssh_execute` / `analyze_output`) -- **Observing**:观察工具结果(`exit_code`、输出、耗时),反馈到下一轮推理 - -通过 16 轮迭代循环,让 Agent 从「盲目执行命令」进化为「有目的地探索和解决问题」。 - -## 核心结构 - -```go -// backend/pkg/agentcli/core/agent.go - -type Agent struct { - llm llm.Client // LLM 客户端(OpenAI 兼容) - registry *tools.Registry // 工具注册中心 - config *Config // 运行配置 - safetyCtl *safety.Controller // 安全控制器 - evolver *evolver.EvolverEngine // 自进化引擎(可为 nil,降级运行) - callback EventCallback // 事件回调(controller 注入,推 WS + 落库) - totalTokens int // token 消耗统计 - mu sync.Mutex -} -``` - -> 与 opsxcli 的差异: -> - 剥离 `tokenizer *TokenEstimator` 独立结构,改为内联的 `estimateTokens` 函数 -> - 剥离 `lastEvolveResult` + `evolveResultMu`,改为 Evolver 内部维护 -> - 剥离 `evolveWg sync.WaitGroup`,进化 goroutine 自带 `recover()`,不阻塞主循环 - -### 依赖关系 - -``` - ┌─────────┐ - │ Agent │ - └────┬────┘ - ┌───────┬───────┼───────┬──────────┐ - │ │ │ │ │ - ▼ ▼ ▼ ▼ ▼ - ┌────────┐┌──────┐┌──────┐┌──────┐┌────────┐ - │Config ││Safety││Tools ││Evolver││Prompt │ - │配置 ││安全 ││工具 ││进化 ││构建器 │ - └────────┘└──────┘└──────┘└──────┘└────────┘ -``` - -## EventCallback —— 替代 TUI 的事件流 - -opsxcli 通过 `ToolCallback` 驱动 Bubble Tea TUI 实时渲染;aiops 改为 `EventCallback`,由 controller 序列化后推 WebSocket + 落库: - -```go -type EventType string - -const ( - EventThought EventType = "thought" // AI 思考文本 - EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险) - EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时) - EventProgress EventType = "progress" // 轮次 + token - EventDone EventType = "done" // 任务完成(最终答案) - EventError EventType = "error" // 错误(含达到最大轮次) -) - -type EventCallback func(Event) -``` - -controller 的 `onEvent` 实现同时做两件事: - -```go -func (ac *agentConn) onEvent(e core.Event) { - ac.write(wsEvent{...}) // 1. 推 WebSocket - ac.persistMsg(...) // 2. 落库 agent_cli_messages(progress 除外) -} -``` - -## Config 配置 - -```go -// backend/pkg/agentcli/core/agent.go - -type Config struct { - MaxIterations int // 16 - 最大迭代次数 - Temperature float64 // 0.3 - LLM 温度(偏低 = 更确定) - ToolTimeout time.Duration // 120s - 工具执行超时 - MaxTokens int // 4096 - 单次 LLM 最大输出 token - SafetyMode safety.Mode // balanced - OutputMaxLength int // 10000 - 输出截断长度 - MaxContextTokens int // 8000 - 上下文窗口(裁剪阈值 90%) - MemoryDir string // data/agentcli - Evolver 记忆目录 -} -``` - -默认值通过 `DefaultConfig()` 提供。controller 可通过 `EngineConfig.Core` 覆盖。 - -> 与 opsxcli 的差异:opsxcli 用 `LoadConfig()` 从环境变量加载(`OPSXCLI_AGENT_*`),aiops 改为代码默认值 + `EngineConfig` 显式覆盖(Web 场景下环境变量管理不直观)。 - -## 16 轮 ReAct 主循环 - -### 流程图 - -``` -用户输入 - │ - ▼ -┌─────────────────────────────────────────────┐ -│ ReAct Main Loop │ -│ │ -│ Round 1 ──→ LLM 推理 → 工具调用 → 观察 │ -│ Round 2 ──→ LLM 推理 → 工具调用 → 观察 │ -│ Round 3 ──→ LLM 推理 → 工具调用 → 观察 │ -│ ═══════ CHECKPOINT 1 (Round 4) ═══════ │ -│ Round 4 ──→ 启发式评估 + 纠偏提示 │ -│ ... │ -│ Round 7 ──→ LLM 推理 → 工具调用 → 观察 │ -│ ═══════ CHECKPOINT 2 (Round 8) ═══════ │ -│ Round 8 ──→ 强制中间总结 │ -│ ... │ -│ Round 11 ──→ LLM 推理 → 工具调用 → 观察 │ -│ ═══════ CHECKPOINT 3 (Round 12) ═════ │ -│ Round 12 ──→ 最终提醒,敦促收尾 │ -│ ... │ -│ Round 16 ──→ 最大迭代,强制结束 │ -└──────────────┬──────────────────────────────┘ - │ - ▼ - 最终回答 / 总结 - │ - ▼ (后台异步) - ┌──────────────┐ - │ Evolver 学习 │ - └──────────────┘ -``` - -### 为什么是 16 轮? - -| 迭代次数 | 优点 | 缺点 | -|----------|------|------| -| 8 轮 | 快速响应 | 复杂运维任务不够用 | -| **16 轮** | **覆盖 90% 运维场景** | **偶尔多消耗 token** | -| 32 轮 | 兜底极端场景 | 成本高、用户等待久 | - -16 轮是**能力与效率的平衡点**,配合自适应检查点避免无意义循环。 - -### 主循环核心代码(精简版) - -```go -for iter := 0; iter < a.config.MaxIterations; iter++ { - if err := ctx.Err(); err != nil { return nil, err } // 中断检查 - a.emit(Event{Type: EventProgress, Round: iter + 1}) - - // 自适应检查点 - if hint := a.checkAdaptiveCheckpoint(iter, ...); hint != "" { - messages = append(messages, llm.Message{Role: "user", Content: hint}) - } - - trimmed := a.trimMessages(messages) // 裁剪历史 - resp, err := a.llm.Complete(ctx, &llm.CompletionRequest{ - Messages: trimmed, Tools: llmTools, - Temperature: a.config.Temperature, MaxTokens: a.config.MaxTokens, - }) - // ... - a.emit(Event{Type: EventThought, Round: iter + 1, Thought: resp.Message.Content}) - - if len(resp.Message.ToolCalls) == 0 { - // 最终答案 - a.emit(Event{Type: EventDone, Answer: resp.Message.Content, ...}) - a.triggerEvolve(ctx, query, serverInfo, answer, toolRecords, true, start) - return &tools.Result{Success: true, Output: answer}, nil - } - - // 处理工具调用(含安全检查、循环检测、风险事件) - observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, ...) - messages = append(messages, observations...) -} -// 达到最大轮次 -a.triggerEvolve(ctx, query, serverInfo, "", toolRecords, false, start) -return nil, fmt.Errorf("达到最大迭代次数(%d)...", a.config.MaxIterations) -``` - -## 自适应检查点机制 - -### checkAdaptiveCheckpoint() - -```go -func (a *Agent) checkAdaptiveCheckpoint(iter int, history map[string]int, consecFail, totalCalls int) string { - switch iter { - case 3: // 第 4 轮(0-indexed 为 3) - repeat := 0 - for _, c := range history { - if c > 1 { repeat += c - 1 } - } - if totalCalls > 0 && (float64(repeat)/float64(totalCalls) > 0.5 || consecFail >= 3) { - return "【系统提示】检测到重复使用相同工具或连续失败,请尝试换一种思路或不同命令。" - } - case 7: // 第 8 轮 - return "【系统提示】已执行到中段,请总结进展。信息足够就直接给出最终答案,否则说明剩余步骤。" - case 11: // 第 12 轮 - return fmt.Sprintf("【系统提示】仅剩 %d 步,请立即整理已有信息给出最终答案,不要再调用新工具。", - a.config.MaxIterations-iter-1) - } - return "" -} -``` - -| 检查点 | 轮次 | 策略 | 触发条件 | -|--------|------|------|----------| -| **Tier 1** | Round 4 | 启发式纠偏 | 重复工具调用率 >50% **或** 连续失败 ≥3 | -| **Tier 2** | Round 8 | 强制中间总结 | 始终注入 | -| **Tier 3** | Round 12 | 敦促收尾 | 始终注入 | - -### 设计决策:为什么用消息注入而不是额外 LLM 调用? - -| 方案 | 优点 | 缺点 | -|------|------|------| -| ❌ 额外 LLM 调用评估状态 | 精确 | 每次 +1 API 调用,成本翻倍 | -| ✅ **消息注入** | **零额外成本** | 依赖 LLM 自主理解提示 | - -把引导消息以 `user` 角色注入对话,LLM 在下一轮推理时自然调整行为。这是「计算型反馈 + 积极的 prompt injection」的典型应用(见 [03 - Harness 工程化](03-Harness工程化与反馈回路.md))。 - -## 循环检测 - -防止 LLM 陷入「用相同参数反复调用同一工具」的死循环: - -```go -key := tc.Function.Name + ":" + tc.Function.Arguments -history[key]++ -if history[key] > 3 { - obs = append(obs, llm.Message{ - Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, - Content: "错误: 检测到循环调用,相同参数已调用超过3次,请换一种方式", - }) - continue -} -``` - -阈值 >3 次:允许 LLM 重试 2~3 次(合理调试),超过则强制换法。 - -## 连续失败追踪 - -```go -func (a *Agent) updateConsecutiveFailures(obs []llm.Message, current int) int { - errs := 0 - for _, o := range obs { - c := o.Content - if strings.HasPrefix(c, "错误") || strings.HasPrefix(c, "失败") || - strings.HasPrefix(c, "执行错误") || strings.HasPrefix(c, "执行失败") { - errs++ - } - } - if errs == 0 { return 0 } // 有成功就重置 - return current + errs -} -``` - -连续失败 ≥3 次会触发 Tier 1 检查点的纠偏提示。 - -## Token 估算与消息裁剪 - -### estimateTokens(粗略估算) - -```go -func estimateTokens(s string) int { - return len([]rune(s))/3 + 1 -} -``` - -中文约 1 字/token,英文约 1/4,折中 rune/3。够用即可,不需要精确 tokenizer。 - -### trimMessages - -```go -func (a *Agent) trimMessages(msgs []llm.Message) []llm.Message { - maxT := a.config.MaxContextTokens * 9 / 10 // 留 10% 余量 - sysT := estimateTokens(msgs[0].Content) - kept := ... // 从末尾向前累加,超 maxT 停止 - if len(kept) < 4 && len(msgs) > 4 { - kept = msgs[len(msgs)-4:] // 最少保留最近 4 条 - } - result := append([]llm.Message{msgs[0]}, kept...) // system + 最近消息 - return ensureToolMessageIntegrity(result) -} -``` - -### ensureToolMessageIntegrity(关键) - -LLM API 要求 `role: tool` 消息必须紧跟在带 `tool_calls` 的 `assistant` 消息之后,且 ID 匹配。裁剪后可能出现孤立 tool 消息,导致 API 报错: - -``` -Messages with role 'tool' must be a response to a preceding message with 'tool_calls' -``` - -`ensureToolMessageIntegrity` 修复这种孤立: - -```go -// 1. 收集所有 assistant.tool_calls 的 ID -// 2. 遍历 tool 消息: -// - 若 ID 不在 validIDs 中 → 删除 -// - 若前一条不是 assistant 或 ID 不匹配 → 删除 -``` - -## 工具调用处理(processToolCalls) - -完整流程: - -``` -对每个 tool_call: - ├─ 循环检测(>3 次拒绝) - ├─ registry.Get(name) 查找工具 - ├─ json.Unmarshal 解析参数 - ├─ risk = toolRisk(tool, args) ← 优先 DynamicRiskTool - ├─ emit EventToolCall(name, args, risk) - ├─ safetyCtl.Check(tool, args) ← 高危触发 ConfirmFn - │ ├─ 黑名单(Critical)→ 拒绝 - │ ├─ 阈值内 / AutoApprove → 放行 - │ └─ 达阈值 → confirmFn → controller 推 WS confirm → 等前端回传 - ├─ tool.Execute(ctx, args) ← context.WithTimeout(ToolTimeout) - ├─ 输出截断(> OutputMaxLength) - ├─ emit EventToolResult(output, success, duration) - └─ 构建 observation 消息追加到 messages -``` - -## Evolver 集成 - -### triggerEvolve(异步进化) - -```go -func (a *Agent) triggerEvolve(ctx context.Context, query, serverHost, answer string, - records []evolver.ToolCallRecord, success bool, start time.Time) { - if a.evolver == nil { return } // 降级 - exec := &evolver.TaskExecution{...} - go func() { - defer func() { _ = recover() }() // panic 恢复 - eCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = a.evolver.Evolve(eCtx, exec) // 错误忽略 - }() -} -``` - -**三层保护**: - -1. **后台 goroutine**:不阻塞主循环,用户立即拿到答案 -2. **30 秒超时**:防止进化过程无限运行 -3. **panic 恢复**:捕获异常,确保 goroutine 崩溃不影响主流程 - -### GetContextForPrompt(经验注入) - -任务开始时(`Run` 入口): - -```go -evolveCtx := "" -if a.evolver != nil { - evolveCtx = a.evolver.GetContextForPrompt(query) // 按 task_type 取历史经验 -} -systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx) -``` - -注入的内容会被放入 Prompt Layer 5(详见 [07 - Prompt 构建](07-Prompt构建与记忆注入.md) 与 [08 - Evolver](08-Evolver自进化引擎.md))。 - -## Agent 初始化流程 - -``` -NewAgent(client, registry, cfg, sc, ev) - │ - ├─ cfg == nil → DefaultConfig() - ├─ sc == nil → safety.NewController(balanced) - ├─ MaxContextTokens <= 0 → 强制设为 8000(防御性校验) - └─ return Agent -``` - -**优雅降级设计**:Evolver 初始化失败时,`agentcli.NewEngine` 把 `ev` 设为 `nil`,Agent 继续正常工作,只是没有自学习能力。 - -## 扩展指南 - -### 增加新的迭代策略 - -1. 在 `Config` 中增加策略配置项(如新的检查点轮次) -2. 在 `checkAdaptiveCheckpoint()` 中添加新 case -3. 编写对应的中文提示文本 - -### 自定义事件类型 - -1. 在 `EventType` 常量中新增(如 `EventWarning`) -2. 在 `Event` 结构中增加字段 -3. 在 controller 的 `onEvent` 中增加 case 处理(推 WS + 落库) - -## 常见陷阱 - -| 陷阱 | 解决方案 | -|------|----------| -| MaxIterations 设太大导致 token 失控 | 默认 16 + 自适应检查点 | -| trimMessages 后 tool 消息孤立 | `ensureToolMessageIntegrity()` 修复 | -| Evolver 失败导致 Agent 不可用 | 优雅降级,`evolver=nil` 时跳过进化 | -| MaxContextTokens=0 导致上下文异常 | 防御性校验,<=0 时强制 8000 | -| 高并发下 messages 切片竞争 | Agent 不是并发安全的,**每个任务用独立实例**(controller 已保证:每次 startTask 新建 Engine) | -| LLM 不返回 tool_calls 也不返回 content | 视为完成,emit done(空答案) | - -## 与 opsxcli 的差异总结 - -| 维度 | opsxcli | aiops | -|------|---------|-------| -| 回调机制 | `ToolCallback` 驱动 Bubble Tea TUI | `EventCallback` 推 WebSocket + 落库 | -| Token 估算 | 独立 `TokenEstimator` 结构 | 内联 `estimateTokens` 函数 | -| 配置加载 | 环境变量 `OPSXCLI_AGENT_*` | `DefaultConfig()` + `EngineConfig` 覆盖 | -| 进化 goroutine 同步 | `evolveWg.Wait()` | 自带 recover + 30s 超时,不等 | -| MaxContextTokens 默认 | 6000(为 8k 模型留余量) | 8000(适配现代 128k 模型) | - -核心算法(16 轮循环 / 检查点 / 循环检测 / 连续失败追踪 / Evolver 集成)完全保留,差异仅在工程外壳。 - -> 作者:Logan.Li diff --git a/docs/agent/07-Prompt构建与记忆注入.md b/docs/agent/07-Prompt构建与记忆注入.md deleted file mode 100644 index 538758f..0000000 --- a/docs/agent/07-Prompt构建与记忆注入.md +++ /dev/null @@ -1,301 +0,0 @@ -# 07 - Prompt 构建与记忆注入 - -> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/prompt/prompt.go`)。 -> 五层 System Prompt 架构保留;opsxcli 的「personaLayer / capabilityLayer / constraintLayer / fewShotLayer / memoryLayer」五常量在 aiops 中合并为单个 `BuildSystemPrompt()` 函数;角色从「opsxcli 智能运维超级助手」改写为「aiops Agent CLI 智能运维 Agent(Web 版,类似 Claude Code)」。 - -## 设计理念 - -Prompt 是 LLM Agent 的灵魂。aiops Agent CLI 沿用 opsxcli 的 **五层 System Prompt 架构**,将静态知识与动态记忆分层组装: - -- **Layer 1-4 静态层**:编译期确定,不随运行时变化(角色、安全、工具、工作方式) -- **Layer 5 动态层**:运行时由 Evolver 注入历史经验,越用越聪明 - -## 五层 System Prompt 架构 - -``` -┌──────────────────────────────────────────┐ -│ Layer 1: 角色定位 │ ← 我是谁 -│ 「aiops Agent CLI 智能运维 Agent」 │ -├──────────────────────────────────────────┤ -│ Layer 2: 安全规则 │ ← 我必须遵守什么 -│ 黑名单 / 高危确认 / 优先只读 │ -├──────────────────────────────────────────┤ -│ Layer 3: 工具说明 │ ← 我能用什么 -│ 由 Registry 动态生成(ssh_execute 等) │ -├──────────────────────────────────────────┤ -│ Layer 4: 工作方式(ReAct 范式) │ ← 我怎么工作 -│ 思考 → 行动 → 观察 → 循环 → 收尾 │ -├──────────────────────────────────────────┤ -│ Layer 5: 记忆注入 │ ← 我学到了什么(动态) -│ Evolver 提供的历史经验 │ -└──────────────────────────────────────────┘ -``` - -## BuildSystemPrompt 函数 - -```go -// backend/pkg/agentcli/prompt/prompt.go - -func BuildSystemPrompt(serverInfo, toolDescs, evolveContext string) string { - var sb strings.Builder - - // Layer 1: 角色 - sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级智能运维 Agent," + - "类似 Claude Code / Codex,但运行在 Web 页面上,通过 SSH 工具远程操作真实服务器。\n" + - "你的职责是:根据用户的自然语言任务描述,自主在目标服务器上排查故障、安装与配置软件、修复问题," + - "并通过 WebSocket 把每一步思考、执行的命令和输出实时反馈给用户。\n\n") - - // 目标主机(动态片段,但不属于记忆层) - if strings.TrimSpace(serverInfo) != "" { - sb.WriteString("【目标主机】\n" + serverInfo + "\n\n") - } - - // Layer 2: 安全规则 - sb.WriteString("【安全规则】\n") - sb.WriteString("1. 破坏性命令(rm -rf /、mkfs、dd 覆写磁盘等)被严格禁止,绝不执行。\n") - sb.WriteString("2. 高危操作(reboot、systemctl restart、kill、卸载软件等)需要用户确认后才能执行。\n") - sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息...\n") - sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n\n") - - // Layer 3: 工具(动态生成) - if strings.TrimSpace(toolDescs) != "" { - sb.WriteString("【可用工具】\n" + toolDescs + "\n\n") - } - - // Layer 4: 工作方式(ReAct) - sb.WriteString("【工作方式(ReAct 循环)】\n") - sb.WriteString("1. 思考:分析当前状态,决定下一步...\n") - sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n") - sb.WriteString("3. 观察:仔细阅读命令输出(含 exit_code,0 表示成功)。\n") - sb.WriteString("4. 循环:如果命令失败或问题未解决,分析错误原因,换一种方法重试,直到真正解决。\n") - sb.WriteString("5. 收尾:问题解决后,用清晰的中文总结:问题根因、你做了什么、最终结果、后续建议。\n\n") - sb.WriteString("【重要】务必真正解决问题,而不是假装解决。每一步都要基于实际命令输出判断," + - "禁止编造不存在的输出。\n\n") - - // Layer 5: 记忆注入(动态) - if strings.TrimSpace(evolveContext) != "" { - sb.WriteString("【历史经验(来自自进化引擎)】\n" + evolveContext + "\n\n") - } - - // 输出要求 - sb.WriteString("【输出要求】使用简体中文,简洁专业。思考与命令穿插呈现,避免冗长空话。") - return sb.String() -} -``` - -> 与 opsxcli 的差异: -> - opsxcli 把五层拆为五个独立常量(`personaLayer` / `capabilityLayer` / `constraintLayer` / `fewShotLayer` / `memoryLayerPrefix`),aiops 合并为单个函数,更紧凑 -> - opsxcli 有独立的 `BuilderV2` 结构和 `NewBuilderV2WithMemory` 工厂,aiops 直接用函数(Web 场景下不需要构建器状态) -> - aiops 移除了独立的 fewShot 示例层,把 ReAct 工作方式说明合并到 Layer 4 - -## Layer 1: 角色定位 - -> **关键改写**:opsxcli 的角色是「opsxcli 智能运维超级助手」(CLI 工具语境);aiops 改写为「aiops 平台的 Agent CLI —— 企业级智能运维 Agent,类似 Claude Code / Codex,但运行在 Web 页面上」。 - -**设计要点**: - -- **明确类比**:用「类似 Claude Code / Codex」让用户和 LLM 都快速理解定位 -- **强调 Web 语境**:「运行在 Web 页面上」「通过 SSH 远程操作」「通过 WebSocket 反馈」 -- **明确职责边界**:排障、安装配置、修复 —— 不是「无所不能的 AI」 -- **不包含版本号**(与 opsxcli 一致) - -### 目标主机片段 - -`serverInfo` 由 controller 构建,注入到 Layer 1 之后: - -```go -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) -``` - -让 LLM 知道当前操作的是哪台主机、什么系统、用什么用户。 - -## Layer 2: 安全规则 - -四条硬规则: - -1. **破坏性命令严格禁止**(呼应代码层的黑名单硬拒) -2. **高危操作需用户确认**(呼应代码层的 ConfirmFn) -3. **排障优先只读命令**(引导 LLM 先收集信息再动手) -4. **不确定时不盲目操作**(宁可多问) - -> **注意**:Layer 2 是 Prompt 级别的「软约束引导」,与 `safety.Controller.Check` 的代码级「硬拦截」是不同层面的机制。两者互补:Prompt 引导 LLM 主动避免危险操作,代码层兜底防止 LLM 失控。 - -## Layer 3: 工具说明 - -由 `core.Agent.buildToolDescriptions()` 动态生成: - -```go -func (a *Agent) buildToolDescriptions() string { - var sb strings.Builder - for _, t := range a.registry.List() { - sb.WriteString(fmt.Sprintf("- %s: %s\n", t.Name(), t.Description())) - } - return sb.String() -} -``` - -**为什么动态生成?** - -- 工具集可扩展(实现 Tool 接口注册即可),硬编码 Prompt 会与代码脱节 -- 工具描述本身就是给 LLM 的「使用说明书」,由工具自己提供最准确 -- `ssh_execute` 的描述会包含具体的目标主机和用户信息(动态拼接) - -输出示例: - -``` -【可用工具】 -- ssh_execute: 在远程服务器 192.168.1.100(用户 root)上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码... -- analyze_output: 分析一段(可能很长的)命令输出文本:统计行数、提取疑似错误/告警行... -``` - -## Layer 4: 工作方式(ReAct 范式) - -五步循环: - -1. **思考** —— 分析状态,决定下一步(产出 `thought` 文本) -2. **行动** —— 调用 `ssh_execute` 执行命令(一次只做关键的、可验证的一步) -3. **观察** —— 仔细阅读输出(含 `exit_code`,0 表示成功) -4. **循环** —— 失败则换法重试,直到真正解决 -5. **收尾** —— 清晰中文总结(根因、做了什么、结果、建议) - -**关键约束**(红线): - -> 务必真正解决问题,而不是假装解决。每一步都要基于实际命令输出判断,禁止编造不存在的输出。 - -这条约束非常重要 —— LLM 有「编造输出」的倾向,明确禁止 + 配合 `exit_code` 信号,能显著降低幻觉。 - -## Layer 5: 记忆注入(动态) - -由 `evolver.EvolverEngine.GetContextForPrompt(query)` 提供: - -```go -func (e *EvolverEngine) GetContextForPrompt(query string) string { - taskType := classifyTask(query, nil) - var sb strings.Builder - - // 同类任务的历史经验(优先成功) - if exps := e.mem.FindExperiences(taskType, 3); len(exps) > 0 { - sb.WriteString(fmt.Sprintf(" 【%s 类历史经验】\n", taskType)) - for _, exp := range exps { - mark := "成功" - if !exp.Success { mark = "未成功" } - sb.WriteString(fmt.Sprintf(" - (%s) %s\n", mark, exp.Hint)) - } - } - // 相关事实 - if facts := e.mem.RelevantFacts(query, 3); len(facts) > 0 { - sb.WriteString(" 【已知事实】\n") - for _, f := range facts { - sb.WriteString(fmt.Sprintf(" - %s\n", f.Fact)) - } - } - // 已知主机 - if hosts := e.mem.Hosts(); len(hosts) > 0 { - sb.WriteString(fmt.Sprintf(" 【已知主机】%s\n", strings.Join(hosts, ", "))) - } - return sb.String() -} -``` - -注入示例: - -``` -【历史经验(来自自进化引擎)】 - 【磁盘空间 类历史经验】 - - (成功) 完成「磁盘空间」类任务时,工具序列 ssh_execute → analyze_output 最终成功,可作为参考。 - - (成功) 对于磁盘满问题,先检查大日志文件再清理 - 【已知事实】 - - 生产 nginx 日志目录是 /var/log/nginx - 【已知主机】192.168.1.10, 192.168.1.11 -``` - -**设计要点**: - -- 按 `query` 分类后只注入**同类**经验,避免无关信息污染 -- 优先注入**成功**经验(`FindExperiences` 内部排序) -- 限制条数(经验 3 条、事实 3 条),控制 token 开销 - -> 与 opsxcli 的差异:opsxcli 把记忆层细分为 5.0 事实 / 5.1 经验 / 5.1.5 技能 / 5.2 环境 / 5.3 偏好;aiops 精简为 3 段(经验 / 事实 / 已知主机),剥离了用户偏好(Web 场景下偏好由安全模式等显式配置承载)和技能层(未实现自动 Skill 提炼)。 - -## 注入流程 - -``` -core.Agent.Run() - │ - ├─ evolveCtx = evolver.GetContextForPrompt(query) ← Layer 5 内容 - │ - ├─ toolDescs = buildToolDescriptions() ← Layer 3 内容 - │ - ├─ systemPrompt = prompt.BuildSystemPrompt( - │ serverInfo, ← Layer 1 目标主机片段 - │ toolDescs, ← Layer 3 - │ evolveCtx, ← Layer 5 - │ ) - │ - └─ messages = [{system: systemPrompt}, {user: query}] -``` - -## 为什么 Layer 1-4 是静态的? - -- 角色定位和工作方式在编译期确定 -- 安全规则不应该被动态修改(防止 Evolver 污染安全约束) -- 静态内容可以利用 LLM Provider 的 Prompt Caching(减少 token 消耗,部分 Provider 如 Anthropic 支持) -- 只有事实/经验需要动态更新 - -## 输出要求 - -最后一段统一约束输出风格: - -> 使用简体中文,简洁专业。思考与命令穿插呈现,避免冗长空话。 - -这与 opsxcli 一致 —— 中文运维语境的统一规范。 - -## 扩展指南 - -### 修改角色描述 - -直接修改 `BuildSystemPrompt` 中 Layer 1 的字符串。注意: - -- 不要包含版本号 -- 明确能力边界(不要吹成「无所不能」) -- 场景化描述优于抽象形容 - -### 增加新的 Prompt 层 - -1. 在 `BuildSystemPrompt` 中增加新的 `sb.WriteString(...)` 片段 -2. 在本文档更新架构图 -3. 若是动态层,需在 `Agent.Run` 中准备数据并传入 - -### 调整记忆注入策略 - -修改 `evolver.GetContextForPrompt`: - -- 增加新的记忆段(如「最近失败的任务」) -- 调整条数限制 -- 改进分类匹配逻辑 - -## 常见陷阱 - -| 陷阱 | 解决方案 | -|------|----------| -| Prompt 过长导致 token 超限 | Layer 1-4 精简 + Layer 5 按需注入 + 条数限制 | -| 记忆注入了无关经验 | `GetContextForPrompt` 按 `task_type` 匹配,不全量注入 | -| Layer 1-4 被 Evolver 污染 | 静态层硬编码在函数中,Evolver 只能影响 Layer 5 | -| LLM 编造命令输出 | Layer 4 显式禁止 + 输出附 `exit_code` 验证 | -| 角色描述含版本号 | 已清理,禁止再加版本号 | -| 工具描述与实际工具脱节 | Layer 3 由 Registry 动态生成,永远一致 | - -## 与 opsxcli 的差异总结 - -| 维度 | opsxcli | aiops | -|------|---------|-------| -| 代码组织 | 5 个独立常量 + BuilderV2 结构 | 单个 `BuildSystemPrompt` 函数 | -| 角色 | opsxcli 智能运维超级助手(CLI) | aiops Agent CLI(Web 版,类比 Claude Code) | -| fewShot 层 | 独立 Layer 4(运维场景示例) | 合并进 Layer 4 工作方式 | -| 记忆子层 | 5 个(事实/经验/技能/环境/偏好) | 3 个(经验/事实/已知主机) | -| 静态/动态分离 | 严格(BuilderV2.enableMemory 开关) | 严格(函数内 if 判断) | - -思想完全保留(五层架构 + 静态动态分离 + 记忆按需注入),实现更紧凑。 - -> 作者:Logan.Li diff --git a/docs/agent/08-Evolver自进化引擎.md b/docs/agent/08-Evolver自进化引擎.md deleted file mode 100644 index 63bf4d9..0000000 --- a/docs/agent/08-Evolver自进化引擎.md +++ /dev/null @@ -1,515 +0,0 @@ -# 08 - Evolver 自进化引擎 - -> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/evolver/`)。 -> **保留**:四层记忆架构(经验/环境/事实/程序)+ 进化循环思想(OBSERVE → ... → PREDICT)+ 异步非阻塞 + 优雅降级。 -> **精简**:opsxcli 13 步循环 + 3300 行精简为 6 步核心循环 + ~400 行;剥离反射评分、经验整合去重、自动 Skill 提炼等重型子流程。这是有意的精简适配,保留学习能力和注入机制。 - -## 包位置 - -``` -backend/pkg/agentcli/evolver/ -├── engine.go # 进化引擎核心(精简进化循环) -└── memory.go # 四层记忆 + JSONL/JSON 文件存储 -``` - -## 设计哲学 - -> **让 Agent 越用越聪明** —— 每次任务完成后自动学习经验,优化后续表现。 - -Evolver 借鉴 opsxcli 的设计理念:通过经验积累、LLM 反射分析和环境自适应,实现 Agent 的持续自我优化。用户使用越多,Agent 越了解服务器环境、最佳工具组合和已知事实。 - -**精简原则**:保留「学习 → 存储 → 注入」的核心闭环,剥离重型分析(评分、整合、Skill 提炼)。精简后的 Evolver 仍是真正的自学习引擎,而非装饰品 —— 它真的会把上次任务的经验存下来,并在下次同类任务时注入 Prompt。 - -## 核心数据结构 - -### EvolverEngine - -```go -// backend/pkg/agentcli/evolver/engine.go - -type EvolverEngine struct { - mem *Memories // 四层记忆集合 - llm llm.Client // 可选,用于提炼经验/事实(nil 时退化为规则学习) - memDir string // 记忆目录(默认 data/agentcli) -} -``` - -> 与 opsxcli 的差异:剥离了 `minSteps` / `simpleThresh` / `moderateThresh` / `onProgress` 等配置字段;剥离 `reflectionClient` 独立接口(直接用 `llm.Client`)。 - -### TaskExecution(任务执行记录) - -每次任务完成后由 `core.Agent.triggerEvolve` 构造: - -```go -type TaskExecution struct { - Query string // 用户原始查询 - ToolCalls []ToolCallRecord // 工具调用序列 - TotalSteps int // 总迭代步数 - TotalTokens int // 消耗的 token 数 - Duration time.Duration // 执行耗时 - Success bool // 是否成功 - FinalAnswer string // 最终回答 - Timestamp time.Time // 执行时间 - ServerHost string // 目标主机(用于环境记忆) -} -``` - -### ToolCallRecord / EvolveResult - -```go -type ToolCallRecord struct { - ToolName string - Args map[string]interface{} - Output string - Duration time.Duration - Success bool - RiskLevel int -} - -type EvolveResult struct { - TaskType string // 自动分类的任务类型 - LearnedHint string // 学到的经验提示 - ToolSequence []string // 工具序列 - ExperienceAdded bool // 是否新增经验 - Consolidated bool // 是否触发整合(aiops 版保留字段,未实现) -} -``` - -## 精简进化循环(6 步) - -opsxcli 是 13 步(OBSERVE → EXTRACT → REFLECT → SCORE → COMPARE → LEARN → STORE → CONSOLIDATE → PREDICT → ADAPT → MEMORIZE → DISTILL → FEEDBACK),aiops 精简为 6 步核心: - -``` -┌─────────────────────────────────────────────┐ -│ Evolve 6 步核心循环 │ -├─────────────────────────────────────────────┤ -│ │ -│ Step 1: OBSERVE ── 观察工具序列 │ -│ ↓ 提取 ToolSequence(如 ssh → analyze) │ -│ │ -│ Step 2: CLASSIFY ── 任务类型分类 │ -│ ↓ 关键词规则匹配(11 类,无需 LLM) │ -│ │ -│ Step 3: LEARN ── 生成经验提示 │ -│ ↓ LLM 优先提炼一句经验,失败降级规则生成 │ -│ │ -│ Step 4: STORE ── 存入经验 + 环境记忆 │ -│ ↓ experience.jsonl + environment.json │ -│ │ -│ Step 5: MEMORIZE ── LLM 提炼持久事实 │ -│ ↓ 异步,从 FinalAnswer 提炼事实存 factual │ -│ │ -│ Step 6: PREDICT ── 注入下次 Prompt │ -│ ↓ GetContextForPrompt(下次任务开始时) │ -│ │ -└─────────────────────────────────────────────┘ -``` - -### 各步骤详解 - -#### Step 1: OBSERVE(观察) - -提取工具序列: - -```go -for _, tc := range exec.ToolCalls { - result.ToolSequence = append(result.ToolSequence, tc.ToolName) -} -``` - -输出如 `["ssh_execute", "analyze_output", "ssh_execute"]`。 - -#### Step 2: CLASSIFY(任务分类) - -关键词规则匹配(不调 LLM,零成本): - -```go -func classifyTask(query string, calls []ToolCallRecord) string { - text := strings.ToLower(query) - for _, c := range calls { - if cmd, ok := c.Args["command"].(string); ok { - text += " " + strings.ToLower(cmd) - } - } - // 11 类规则匹配 - rules := []struct{ typ string; kws []string }{ - {"磁盘空间", []string{"disk", "空间", "df", "du", "inode", "磁盘", "满了"}}, - {"网络", []string{"network", "网络", "ping", "netstat", "ss ", "端口", "tcp", "dns"}}, - {"服务管理", []string{"service", "服务", "systemctl", "nginx", "mysql", "redis", "restart"}}, - {"软件安装", []string{"install", "安装", "yum", "apt", "docker", "部署", "deploy"}}, - {"进程排查", []string{"process", "进程", "ps ", "kill", "cpu", "top", "卡死"}}, - {"内存排查", []string{"memory", "内存", "mem", "free", "oom", "swap"}}, - {"日志排查", []string{"log", "日志", "journalctl", "tail", "grep"}}, - {"权限问题", []string{"permission", "权限", "chmod", "chown", "denied", "拒绝"}}, - {"性能调优", []string{"performance", "性能", "负载", "load", "慢", "slow"}}, - {"配置管理", []string{"config", "配置", "conf", "vim", "sed"}}, - } - // ... 命中即返回,否则 "通用运维" -} -``` - -**设计要点**:把 query + 实际命令拼起来分类,比单看 query 更准(用户问「检查 nginx」,实际命令可能是 `systemctl status nginx`,分类为「服务管理」更合理)。 - -#### Step 3: LEARN(生成经验提示) - -LLM 优先,失败降级规则: - -```go -func (e *EvolverEngine) learnHint(ctx context.Context, exec *TaskExecution, taskType string) string { - tools := strings.Join(uniqueStrings(resultToolNames(exec.ToolCalls)), " → ") - - // 尝试用 LLM 提炼一句经验 - if e.llm != nil { - if hint := e.llmLearnHint(ctx, exec, taskType, tools, exec.Success); hint != "" { - return hint - } - } - // 降级:规则生成 - if exec.Success { - return fmt.Sprintf("完成「%s」类任务时,工具序列 %s 最终成功,可作为参考。", taskType, tools) - } - return fmt.Sprintf("尝试「%s」类任务时,工具序列 %s 未成功,下次可换用不同命令或思路。", taskType, tools) -} -``` - -LLM 提炼的 prompt(中文): - -``` -你是运维经验提炼器。根据以下任务执行记录,用一句中文(不超过80字)提炼经验提示, -说明下次做同类任务的关键要点或推荐工具顺序。只返回经验文本,不要多余解释。 -任务: -工具序列: -结果: 成功/失败 -``` - -**降级策略**:LLM 不可用或超时 → 用规则模板生成(仍然有价值,只是不够精炼)。 - -#### Step 4: STORE(存储经验 + 环境记忆) - -```go -exp := ExperienceEntry{ - TaskType: result.TaskType, - Query: truncate(exec.Query, 200), - Hint: result.LearnedHint, - ToolSequence: result.ToolSequence, - Success: exec.Success, - Timestamp: exec.Timestamp.Unix(), -} -e.mem.AddExperience(exp) -if exec.ServerHost != "" { - e.mem.AddHost(exec.ServerHost) // 环境记忆 -} -``` - -写入 `data/agentcli/experience.jsonl`(追加)和 `environment.json`(去重合并)。 - -#### Step 5: MEMORIZE(LLM 提炼持久事实) - -仅在任务成功 + 有 FinalAnswer + LLM 可用时触发,**异步执行不阻塞**: - -```go -if e.llm != nil && exec.Success && exec.FinalAnswer != "" { - go e.memorizeFact(ctx, exec) // 异步 -} -``` - -LLM 提炼 prompt: - -``` -从以下运维任务记录中,提炼 1 条值得长期记住的事实(如某主机的固定配置、已知问题、约定)。 -如果没有明显可沉淀的事实,回复「无」。只返回事实本身,一句话。 -任务: -结论: -``` - -提炼出的事实存入 `factual.jsonl`。 - -> 与 opsxcli 的差异:opsxcli 的 MEMORIZE 从执行过程中提取结构化事实(key-value),aiops 简化为让 LLM 自由提炼一句事实。更简单,覆盖面更广。 - -#### Step 6: PREDICT(注入下次 Prompt) - -`GetContextForPrompt(query)` 在下次任务开始时被 `core.Agent.Run` 调用: - -```go -evolveCtx := a.evolver.GetContextForPrompt(query) -systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx) -``` - -注入内容会被放入 Prompt Layer 5(详见 [07 - Prompt 构建](07-Prompt构建与记忆注入.md))。 - -## 四层记忆系统 - -aiops 保留 opsxcli 的四层记忆架构: - -``` -┌──────────────────────────────────────────────────────────────┐ -│ 四层记忆架构 │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ Layer 1: ExperienceMemory(经验层) │ -│ 任务完成后的经验,按 task_type 分类 │ -│ 存储: data/agentcli/experience.jsonl │ -│ │ -│ Layer 2: EnvironmentMemory(环境层) │ -│ 已知主机列表 │ -│ 存储: data/agentcli/environment.json │ -│ │ -│ Layer 3: FactualMemory(事实层) │ -│ LLM 提炼的持久事实 │ -│ 存储: data/agentcli/factual.jsonl │ -│ │ -│ Layer 4: ProceduralMemory(程序层) │ -│ 程序性知识(接口位,未实现自动 Skill 提炼) │ -│ 存储: data/agentcli/procedural.jsonl │ -│ │ -└──────────────────────────────────────────────────────────────┘ -``` - -### Memories 结构 - -```go -// backend/pkg/agentcli/evolver/memory.go - -type Memories struct { - dir string - mu sync.Mutex - - expPath string // experience.jsonl - facPath string // factual.jsonl - sklPath string // procedural.jsonl - envPath string // environment.json -} -``` - -### ExperienceEntry / FactEntry / SkillEntry - -```go -type ExperienceEntry struct { - TaskType string `json:"task_type"` - Query string `json:"query"` - Hint string `json:"hint"` - ToolSequence []string `json:"tool_sequence"` - Success bool `json:"success"` - Timestamp int64 `json:"timestamp"` -} - -type FactEntry struct { - Fact string `json:"fact"` - Source string `json:"source"` - Timestamp int64 `json:"timestamp"` -} - -type SkillEntry struct { - Name string `json:"name"` - When string `json:"when"` - Steps []string `json:"steps"` - Timestamp int64 `json:"timestamp"` -} -``` - -> 与 opsxcli 的差异: -> - opsxcli 的 `Experience` 有 `SuccessRate` / `UsageCount` / `Tags`(用于整合去重),aiops 简化为 `Success` 布尔(剥离整合逻辑) -> - opsxcli 的 `FactEntry` 是 key-value(`Key` / `Value` / `Category`),aiops 简化为单字段 `Fact`(一句话) -> - opsxcli 的 `SkillEntry` 有完整的 ID/Version/Triggers/Pitfalls/SuccessRate,aiops 简化为 Name/When/Steps(接口位,未实际使用) - -### 存储格式 - -#### experience.jsonl(追加写入) - -```json -{"task_type":"磁盘空间","query":"检查磁盘空间","hint":"对于磁盘满问题,先检查大日志文件再清理","tool_sequence":["ssh_execute","analyze_output"],"success":true,"timestamp":1748000000} -``` - -#### environment.json(整体读写) - -```json -{ - "hosts": ["192.168.1.10", "192.168.1.11"] -} -``` - -#### factual.jsonl(追加写入) - -```json -{"fact":"生产 nginx 日志目录是 /var/log/nginx","source":"检查 nginx 启动失败","timestamp":1748000000} -``` - -### 核心查询方法 - -| 方法 | 说明 | -|------|------| -| `AddExperience(e)` | 追加经验 | -| `FindExperiences(taskType, n)` | 按类型查找,**优先成功**,取最近 n 条 | -| `AddFact(f)` | 追加事实 | -| `RelevantFacts(query, n)` | 按 query 关键词匹配事实(双向 Contains) | -| `AddHost(host)` | 记录已知主机(去重) | -| `Hosts()` | 已知主机列表 | -| `CountStats()` | 各层条目数(供审计展示) | - -**`FindExperiences` 的排序策略**: - -```go -// 优先成功经验,按时间取最近 n 条 -successFirst := ... // 过滤 success=true -others := ... // 过滤 success=false -out := append(pick(successFirst), pick(others)...) -``` - -让 LLM 优先看到成功的经验。 - -## 与 Agent 的集成 - -### 初始化与优雅降级 - -在 `agentcli.NewEngine` 中: - -```go -ev, err := evolver.NewEvolverEngine(coreCfg.MemoryDir, client) -if err != nil { - ev = nil // 降级:无自学习,不影响主流程 -} -agent := core.NewAgent(client, registry, coreCfg, sc, ev) -``` - -**降级场景**: - -- 记忆目录无法创建(权限问题)→ `ev = nil` -- 此时 Agent 正常运行,只是 Layer 5 为空 - -### 异步进化流程 - -``` -用户查询 → core.Agent.Run() ReAct 循环 → 任务完成 - ↓ - triggerEvolve()(异步 goroutine) - ↓ - 30s 超时上下文 + panic 恢复 - ↓ - EvolverEngine.Evolve() - ↓ - 存入 experience.jsonl / factual.jsonl - ↓ - 下次 Run() → GetContextForPrompt() → 注入 Layer 5 -``` - -**三层保护**: - -1. **后台 goroutine**:不阻塞用户,答案立即返回 -2. **30 秒超时**:`context.WithTimeout(ctx, 30*time.Second)` -3. **panic 恢复**:`defer func() { _ = recover() }()` - -### 经验注入 - -下次任务开始时,`GetContextForPrompt(query)` 按 `task_type` 取相关经验注入 Layer 5: - -``` -【历史经验(来自自进化引擎)】 - 【磁盘空间 类历史经验】 - - (成功) 对于磁盘满问题,先检查大日志文件再清理 - - (成功) 完成「磁盘空间」类任务时,工具序列 ssh_execute → analyze_output 最终成功 - 【已知事实】 - - 生产 nginx 日志目录是 /var/log/nginx - 【已知主机】192.168.1.10, 192.168.1.11 -``` - -## 任务分类系统(11 类) - -`classifyTask` 把用户查询 + 实际命令分类为 11 类: - -| 任务类型 | 关键词(部分) | -|----------|---------------| -| 磁盘空间 | disk / 空间 / df / du / inode / 磁盘 / 满了 | -| 网络 | network / 网络 / ping / netstat / ss / 端口 / tcp / dns | -| 服务管理 | service / 服务 / systemctl / nginx / mysql / redis / restart | -| 软件安装 | install / 安装 / yum / apt / docker / 部署 | -| 进程排查 | process / 进程 / ps / kill / cpu / top / 卡死 | -| 内存排查 | memory / 内存 / mem / free / oom / swap | -| 日志排查 | log / 日志 / journalctl / tail / grep | -| 权限问题 | permission / 权限 / chmod / chown / denied / 拒绝 | -| 性能调优 | performance / 性能 / 负载 / load / 慢 / slow | -| 配置管理 | config / 配置 / conf / vim / sed | -| 通用运维 | (兜底) | - -## 创建方式 - -```go -// 基础引擎(无 LLM 提炼,纯规则学习) -engine, _ := evolver.NewEvolverEngine("data/agentcli", nil) - -// 带 LLM 提炼的引擎(推荐) -engine, _ := evolver.NewEvolverEngine("data/agentcli", llmClient) -``` - -> 与 opsxcli 的差异:opsxcli 有 `NewEvolverEngine` 和 `NewEvolverEngineWithLLM` 两个工厂;aiops 合并为一个,通过 `client` 参数是否为 nil 区分。 - -## 统计与监控 - -```go -func (e *EvolverEngine) GetStats() map[string]interface{} { - return map[string]interface{}{ - "enabled": true, - "memory": e.mem.CountStats(), // {experience: N, factual: N, skill: N, hosts: N} - } -} -``` - -由 `Engine.EvolveStats()` 暴露,供审计展示。 - -## 精简决策说明 - -### 剥离了什么 - -| opsxcli 步骤 | aiops 处理 | 原因 | -|--------------|-----------|------| -| Step 2.5 REFLECT(LLM 反射分析) | 合并进 Step 3 LEARN | 单次 LLM 调用同时做「分析 + 提炼」更高效 | -| Step 3 SCORE(效率评分) | 剥离 | 评分用于整合决策,aiops 不做整合 | -| Step 4 COMPARE(历史对比) | 简化为 `FindExperiences` 查询 | 不需要复杂相似度计算 | -| Step 7 CONSOLIDATE(经验整合) | 剥离 | JSONL 追加 + 查询时去重足够,不需要主动合并 | -| Step 9.7 DISTILL(自动 Skill 提炼) | 剥离 | Skill 提炼需要复杂条件判断 + 文件管理,价值/复杂比不高 | -| Step 10 FEEDBACK(用户反馈消息) | 剥离 | Web 场景下经验学习对用户透明,不需要弹消息 | - -### 保留了什么 - -- **四层记忆架构** —— 思想完整保留,只是程序层未实际使用 -- **OBSERVE → LEARN → STORE → MEMORIZE → PREDICT 闭环** —— 真正的学习与注入 -- **LLM 提炼 + 规则降级** —— 学习质量有保障 -- **异步非阻塞 + 优雅降级** —— 工程可靠性 -- **按 task_type 分类注入** —— 避免无关污染 - -精简后的 Evolver 仍是「真正的自学习引擎」,而非装饰品。 - -## 设计亮点 - -1. **异步非阻塞**:进化在后台 goroutine,不增加用户等待 -2. **优雅降级**:LLM 失败退化为规则学习,Evolver 初始化失败 Agent 正常运行 -3. **四层记忆**:经验 + 环境 + 事实 + 程序,覆盖从瞬时到持久的认知链路 -4. **会话级反馈**:上次任务的经验立即注入下次 Prompt -5. **线程安全**:`Memories.mu sync.Mutex` 保护文件读写 -6. **零 DB 依赖**:纯文件存储,与引擎库「零业务依赖」原则一致 - -## 常见陷阱 - -| 陷阱 | 解决方案 | -|------|----------| -| 记忆目录权限不足 | `NewEvolverEngine` 返回 error,`NewEngine` 把 ev 设为 nil 降级 | -| LLM 提炼经验超时 | 30s 超时 + 降级为规则模板 | -| 经验注入污染上下文 | 按 `task_type` 匹配 + 条数限制(3 条) | -| 事实记忆累积过多无关项 | LLM prompt 明确「无明显事实回复『无』」+ 不写入 | -| 并发写入 JSONL 冲突 | `Memories.mu sync.Mutex` 串行化写入 | -| 程序层未实现却被引用 | 当前版本 `procedural.jsonl` 仅作为接口位,`GetContextForPrompt` 不读它 | - -## 与 opsxcli 的差异总结 - -| 维度 | opsxcli | aiops | -|------|---------|-------| -| 进化步骤 | 13 步 | 6 步 | -| 代码规模 | ~3300 行 | ~400 行 | -| 记忆查询 | 复杂相似度 + 整合去重 | 简单 task_type 匹配 + 时间倒序 | -| 经验字段 | SuccessRate / UsageCount / Tags | Success(布尔) | -| Skill 提炼 | 自动(Step 9.7) | 未实现(接口位) | -| 反射分析 | 独立 Step + JSON 结构 | 合并进 LEARN | -| 用户反馈 | Step 10 弹消息 | 剥离(Web 透明) | - -**核心思想保留度**:四层记忆 ✓ / 进化闭环 ✓ / 异步降级 ✓ / 经验注入 ✓。精简的是重型分析,不是学习能力本身。 - -> 作者:Logan.Li diff --git a/docs/agent/09-安全系统与工具注册.md b/docs/agent/09-安全系统与工具注册.md deleted file mode 100644 index 79740a7..0000000 --- a/docs/agent/09-安全系统与工具注册.md +++ /dev/null @@ -1,468 +0,0 @@ -# 09 - 安全系统与工具注册 - -> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/pkg/agentcli/safety/safety.go` + `tools/registry.go` + `tools/risk.go`)。 -> **保留**:三级安全模式 / 五级风险 / 危险命令黑名单 / AssessBashRisk 动态评估 / Tool 接口 + Registry。 -> **适配**:确认机制从 CLI 终端 `bufio.Reader` 改为 WebSocket `confirm` 事件 + 前端 `useConfirm()` 弹框;审计日志从文件改为 DB 落库。 - -## 设计理念 - -安全系统是运维 Agent 的生命线。aiops Agent CLI 安全系统遵循两个核心原则: - -1. **默认安全**:balanced 模式下,high+ 操作需要确认,覆盖 95% 日常运维 -2. **可审计**:每次工具调用都通过 EventCallback 落库,事后可追溯 - -工具注册系统遵循**工具化思维**:万物皆 Tool,实现接口即可扩展。 - ---- - -## Part 1: 安全系统 - -### 架构总览 - -``` -┌────────────────────────────────────────────────────┐ -│ safety.Controller │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ │ -│ │ risk.go │ │ controller.go │ │ -│ │ (在 tools 包)│ │ (在 safety 包)│ │ -│ │ 黑名单 + 分级 │ │ 阈值 + ConfirmFn│ │ -│ └──────────────┘ └──────────────┘ │ -│ │ -│ Safety Modes: │ -│ strict → medium+ 确认 │ -│ balanced → high+ 确认(默认) │ -│ permissive → critical 仅处理 │ -└────────────────────────────────────────────────────┘ -``` - -> 与 opsxcli 的差异:opsxcli 把风险评估和审计都放在 `safety/` 包;aiops 为避免 `tools` ↔ `safety` 循环依赖,把 `risk.go`(黑名单 + AssessBashRisk)置于 `tools` 包,由 `ssh_execute` 的 `DynamicRiskTool` 直接调用;`safety` 包只做「是否需确认」的策略。 - -### Controller 结构 - -```go -// backend/pkg/agentcli/safety/safety.go - -type Controller struct { - mode Mode // 当前安全模式 - confirmFn ConfirmFn // 确认回调(controller 注入:推 WS confirm + 阻塞等回传) - autoApprove bool // 自动批准(仅测试用,生产禁用) -} -``` - -> 与 opsxcli 的差异:剥离 `reader *bufio.Reader`(CLI 终端交互)和 `history []ExecutionRecord`(内存审计历史),改为通过 EventCallback 落库 DB;剥离 `auditLog *AuditLogWriter`(文件审计)。 - -### ConfirmFn —— Web 版的确认机制 - -```go -type ConfirmFn func(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) -``` - -controller 实现的 `confirmFn`: - -```go -// backend/controller/agent_cli.go - -func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) { - confirmID := fmt.Sprintf("cf-%d", time.Now().UnixNano()) - cmd, _ := args["command"].(string) - // 1. 推 WS confirm 事件(前端 useConfirm 弹框) - ac.write(wsEvent{ - Type: "confirm", ConfirmID: confirmID, - ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args, - }) - - // 2. 注册 channel 等待前端回传 - ch := make(chan bool, 1) - ac.cfMu.Lock() - ac.confirms[confirmID] = ch - ac.cfMu.Unlock() - - // 3. 阻塞等待(5 分钟超时) - select { - case approved := <-ch: - return approved, nil - case <-time.After(5 * time.Minute): - ac.delConfirm(confirmID) - return false, fmt.Errorf("确认超时") - } -} -``` - -前端处理(`AgentCLI.tsx`): - -```tsx -case 'confirm': { - const ok = await confirm({ - title: '确认执行高危命令', - description: `即将执行:${ev.command}`, - confirmText: '确认执行', - }); - wsRef.current?.send(JSON.stringify({ - type: ok ? 'confirm' : 'deny', - confirm_id: ev.confirm_id, - })); - break; -} -``` - -这是「Web 版 CLI 终端确认」的完整替代 —— 用户在浏览器看到弹框,点确认/拒绝,结果通过 WebSocket 回传解锁引擎。 - -### 三级安全模式 - -```go -const ( - ModeStrict Mode = "strict" // medium+ 需确认 - ModeBalanced Mode = "balanced" // high+ 需确认(默认) - ModePermissive Mode = "permissive" // 仅 critical 处理 -) -``` - -| 模式 | 需要确认的等级 | 适用场景 | -|------|---------------|----------| -| `strict` | medium 及以上 | 生产环境、关键主机、新手操作 | -| `balanced`(默认) | high 及以上 | 日常运维 | -| `permissive` | 仅 critical | 测试环境、自动化脚本 | - -阈值映射: - -```go -func (c *Controller) riskThreshold() tools.RiskLevel { - switch c.mode { - case ModeStrict: return tools.RiskMedium - case ModePermissive: return tools.RiskCritical - default: return tools.RiskHigh - } -} -``` - -### 五级风险评估 - -```go -// backend/pkg/agentcli/tools/registry.go - -const ( - RiskSafe RiskLevel = iota // 0 - 只读(cat, grep, df, ps) - RiskLow // 1 - 低风险(mkdir, touch) - RiskMedium // 2 - 中风险(chmod, 文件修改) - RiskHigh // 3 - 高风险(ssh_execute, 服务控制) - RiskCritical // 4 - 危险(rm -rf /, mkfs, dd, 数据删除) -) -``` - -每级都有中文显示(`Display()`): - -```go -case RiskSafe: return "安全" -case RiskLow: return "低风险" -case RiskMedium: return "中风险" -case RiskHigh: return "高风险" -case RiskCritical: return "危险" -``` - -前端在 `tool_call` 事件中拿到 `risk` 字段,渲染为风险标签。 - -### 危险命令黑名单(硬红线) - -```go -// backend/pkg/agentcli/tools/risk.go - -var dangerousCommands = []string{ - // 文件系统毁灭性操作 - "rm -rf /", "rm -rf /*", "rm -rf /bin", "rm -rf /boot", "rm -rf /etc", - "rm -rf /home", "rm -rf /lib", "rm -rf /lib64", "rm -rf /proc", "rm -rf /root", - "rm -rf /sbin", "rm -rf /sys", "rm -rf /usr", "rm -rf /var", - // 磁盘格式化 - "mkfs", "mkfs.ext", "mkfs.xfs", "mkfs.btrfs", "mkfs.vfat", - // 磁盘覆写 - "dd if=/dev/zero", "dd if=/dev/random", "dd if=/dev/urandom", - // Fork bomb - ":(){ :|:& };:", ":() { : | : & }; :", - // 设备重定向 - "> /dev/sda", "> /dev/sdb", "> /dev/sdc", "> /dev/hda", - // 数据销毁 - "mv / /dev/null", "mv /* /dev/null", - // 权限破坏 - "chmod -R 000 /", "chmod -R 777 /", - // 服务自毁 - "systemctl stop ssh", "systemctl disable ssh", "kill -9 1", - // 防火墙清空 - "iptables -F", "iptables --flush", -} -``` - -**特殊检测**:`rm -rf` + 根目录路径(即使不在黑名单字面中): - -```go -if strings.HasPrefix(lower, "rm") && strings.Contains(lower, "-rf") && containsRootPath(lower) { - return true, "rm -rf 包含根目录路径" -} -``` - -`containsRootPath` 检测 `/boot`、`/etc`、`/usr`、`/var` 等系统目录。 - -### Check 策略 - -```go -func (c *Controller) Check(tool tools.Tool, args map[string]interface{}) (bool, error) { - risk := assessToolRisk(tool, args) - - // 1. 黑名单硬红线:绝不允许(不可确认放行) - if risk >= tools.RiskCritical { - return false, nil - } - - // 2. 未达阈值或自动批准:直接放行 - if c.autoApprove || risk < c.riskThreshold() { - return true, nil - } - - // 3. 达阈值:请求用户确认 - if c.confirmFn == nil { - return false, nil // 无确认回调时保守拒绝(Web 场景应始终注入 confirmFn) - } - return c.confirmFn(tool.Name(), args, risk) -} -``` - -**三层策略**: - -1. **硬红线**(Critical):黑名单命令直接拒绝,**不可被确认放行** -2. **阈值放行**:低于模式阈值的操作直接执行 -3. **用户确认**(达阈值):调 `confirmFn` 推 WS 弹框等用户决定 - -### AssessBashRisk 动态评估 - -```go -func AssessBashRisk(command string) RiskLevel { - // 1. 黑名单精确匹配 → Critical - if isDangerous, _ := IsDangerousCommand(command); isDangerous { - return RiskCritical - } - // 2. 纯只读命令 → Safe - if isReadOnlyCommand(lower) { - return RiskSafe - } - // 3. 组合命令(&& || | ;)→ 取子命令最高风险 - if isCompound(lower) { - return assessCompoundCommand(lower) - } - // 4. 单命令分类 - isSudo := strings.HasPrefix(lower, "sudo ") - return assessSingleCommand(strings.TrimPrefix(lower, "sudo "), isSudo) -} -``` - -### 命令分类表 - -| 类别 | 命令示例 | 风险等级 | -|------|---------|---------| -| 只读(Safe) | cat, grep, awk, sed, head, tail, ps, top, df, du, free, uptime, uname, hostname, ifconfig, ip addr, netstat, ss, ls, pwd, who, w, last, vmstat, iostat, lsof, find, which, file, stat, date, echo, id, whoami, dmesg, systemctl status, journalctl, lsblk, mount | Safe | -| 修改(Low/Medium) | cp, mv, mkdir, rmdir, touch, chmod, chown, chgrp, ln, tar, gzip, gunzip, zip, unzip, rsync, tee | Low(sudo 时 Medium) | -| 高危(Medium/High) | rm, dd, shutdown, reboot, halt, poweroff, init, systemctl restart/stop/start, kill, killall, pkill, userdel, groupdel, fdisk, parted, mkfs | Medium(sudo 时 High) | -| 危险(Critical) | rm -rf /, mkfs, dd if=/dev/zero, fork bomb, > /dev/sda, chmod -R 000 / | Critical(硬拒) | - -### sudo 提升 - -`sudo` 会让风险等级 +1(最高到 Critical 前): - -```go -// assessSingleCommand -if isSudo { - return RiskHigh // 高危命令 + sudo → High -} -return RiskMedium // 高危命令无 sudo → Medium -``` - -### 组合命令取最高 - -```go -// assessCompoundCommand: 拆分 && || | ; 后取子命令最高风险 -// "df -h && rm /tmp/file" → max(Safe, Medium) = Medium -// "cat /etc/passwd; rm -rf /tmp" → max(Safe, Low) = Low -``` - -### 审计落库(替代文件审计) - -> opsxcli 把审计写到 `~/.opsxcli/audit/audit.log`;aiops 直接走 DB(`agent_cli_messages` 表),多用户场景下可按 user_id / session_id 查询。 - -controller 的 `onEvent` 回调把每个 `tool_call` / `tool_result` 落库: - -```go -case core.EventToolCall: - argsJSON, _ := json.Marshal(e.ToolArgs) - ac.persistMsg(sid, e.Round, "tool_call", "tool", "", - e.ToolName, string(argsJSON), "", e.Risk, false, "", 0) -case core.EventToolResult: - ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, - e.ToolName, "", "", "", e.Success, e.Duration, 0) -``` - -每条记录包含:工具名、参数 JSON、风险等级、成败、耗时、轮次、会话 ID、用户 ID(通过 session 关联)。前端「会话历史」页面可完整回放。 - ---- - -## Part 2: 工具注册系统 - -### Registry 注册中心 - -```go -// backend/pkg/agentcli/tools/registry.go - -type Registry struct { - tools map[string]Tool - mu sync.RWMutex -} -``` - -核心方法: - -| 方法 | 说明 | -|------|------| -| `NewRegistry()` | 创建空注册中心 | -| `Register(tool)` | 注册工具(线程安全) | -| `Get(name)` | 按名获取工具(LLM 调用时按名查找) | -| `List()` | 列出所有工具 | -| `ToLLMTools()` | 转换为 OpenAI function calling 格式 | - -### Tool 接口(5 方法) - -```go -type Tool interface { - Name() string - Description() string - Parameters() map[string]interface{} // JSON Schema - RiskLevel() RiskLevel - Execute(ctx context.Context, args map[string]interface{}) (*Result, error) -} -``` - -### DynamicRiskTool 扩展接口 - -```go -type DynamicRiskTool interface { - Tool - RiskLevelForArgs(args map[string]interface{}) RiskLevel -} -``` - -安全控制器 `Check` 时优先调用动态评估: - -```go -func assessToolRisk(tool tools.Tool, args map[string]interface{}) tools.RiskLevel { - if dt, ok := tool.(tools.DynamicRiskTool); ok { - return dt.RiskLevelForArgs(args) // 动态优先 - } - return tool.RiskLevel() -} -``` - -### Result 结构 - -```go -type Result struct { - Success bool `json:"success"` - Output string `json:"output"` - Error string `json:"error,omitempty"` -} -``` - -### 参数解析辅助 - -```go -func StringParam(args map[string]interface{}, key string) string -func IntParam(args map[string]interface{}, key string) int // 兼容 int/float64/int64 -``` - -LLM 返回的 JSON 参数反序列化后,数字可能是 `float64`(JSON 原生),`IntParam` 统一处理。 - -### 已注册工具一览 - -| 工具 | 静态风险 | 动态风险 | 功能 | -|------|---------|---------|------| -| `ssh_execute` | High | Safe~Critical | 远程命令执行(DynamicRiskTool) | -| `analyze_output` | Safe | — | 输出分析(纯文本,只读) | - -详细设计见 [05 - Builtin 内置工具](05-Builtin内置命令.md)。 - -> 与 opsxcli 的差异:剥离了 `local_bash` / `scp_transfer` / `file_read` / `file_search` 四个工具;剥离 SSH 连接池(aiops 直接用 `pkg/remoteexec`,无连接池需求);剥离 `Closer` 接口(Web 工具无资源需释放)。 - -## 扩展指南 - -### 添加新工具 - -```go -// 1. 实现 Tool 接口(必要时实现 DynamicRiskTool) -type MyTool struct{} -func (t *MyTool) Name() string { return "my_tool" } -func (t *MyTool) Description() string { return "我的工具" } -func (t *MyTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "target": map[string]interface{}{"type": "string", "description": "目标"}, - }, - "required": []string{"target"}, - } -} -func (t *MyTool) RiskLevel() tools.RiskLevel { return tools.RiskMedium } -func (t *MyTool) Execute(ctx context.Context, args map[string]interface{}) (*tools.Result, error) { - // 实现 - return &tools.Result{Success: true, Output: "done"}, nil -} - -// 2. 在 agentcli.NewEngine 中注册 -registry.Register(&MyTool{}) -``` - -### 扩展危险命令黑名单 - -修改 `tools/risk.go` 的 `dangerousCommands` 切片: - -```go -var dangerousCommands = []string{ - // ... 现有 - "your-critical-command", -} -``` - -### 自定义确认逻辑 - -controller 的 `confirmFn` 可对接企业审批系统(如先走工单再确认): - -```go -engine.SetConfirmFn(func(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) { - // 1. 推 WS 弹框让用户初步确认 - // 2. 高危场景额外对接审批工单系统 - // 3. 返回最终批准结果 -}) -``` - -## 常见陷阱 - -| 陷阱 | 解决方案 | -|------|----------| -| 忘记设置 RiskLevel 导致误判 | 默认按接口返回值,高危工具必须显式声明 | -| 高危工具未实现 DynamicRiskTool | 静态等级无法反映参数差异,必须实现动态评估 | -| 黑名单只看前缀匹配 | `IsDangerousCommand` 同时做精确匹配 + `rm -rf` 根路径特殊检测 | -| 工具 Execute 无超时 | `Config.ToolTimeout=120s` 兜底(`context.WithTimeout`) | -| 确认回调无超时 | controller 的 `confirmFn` 内置 5 分钟超时 | -| 黑名单可被确认放行 | Check 策略第一层 Critical 直接 `return false, nil`,不走 confirmFn | -| LLM 编造工具名 | `registry.Get` 找不到时返回 observation「工具未找到」,LLM 自动换法 | -| 参数 JSON 解析失败 | `json.Unmarshal` 失败时返回 observation「参数解析失败」,不 panic | - -## 与 opsxcli 的差异总结 - -| 维度 | opsxcli | aiops | -|------|---------|-------| -| 确认机制 | `bufio.Reader` CLI 终端交互 | WebSocket `confirm` 事件 + 前端 `useConfirm()` 弹框 | -| 审计日志 | 文件 `~/.opsxcli/audit/audit.log` | DB 表 `agent_cli_messages` | -| 风险评估位置 | `safety/risk.go` | `tools/risk.go`(避免循环依赖) | -| 内存审计历史 | `history []ExecutionRecord`(上限 1000 FIFO) | 剥离(直接走 DB) | -| 工具集 | 6 个 | 2 个 | -| SSH 连接池 | 有(`ssh_pool.go`) | 剥离(用 `pkg/remoteexec`) | -| Closer 接口 | 有 | 剥离 | - -安全核心(三级模式 / 五级风险 / 黑名单 / 动态评估)完全保留,差异仅在确认机制和审计载体。 - -> 作者:Logan.Li diff --git a/docs/agent/10-会话管理.md b/docs/agent/10-会话管理.md deleted file mode 100644 index e33c2a0..0000000 --- a/docs/agent/10-会话管理.md +++ /dev/null @@ -1,388 +0,0 @@ -# 10 - 会话管理 - -> 本文由 opsxcli 同名设计文档适配改写,结合 aiops Agent CLI 的 Web 实现(`backend/model/agent_cli.go` + `controller/agent_cli.go`)。 -> **核心改造**:opsxcli 用本地 JSONL 文件存储会话(单用户 CLI 场景);aiops 用 **GORM 数据库表**存储会话与消息,支持多用户并发、按 user_id 隔离、REST 查询与历史回放。 - -## 设计理念 - -- **会话管理**:Agent 引擎本身无状态(每次任务独立),所有对话过程持久化到会话层,支持历史回溯、审计、断点排查 -- **多用户隔离**:Web 场景下多个用户同时使用 Agent CLI,会话必须按 user_id 隔离 -- **结构化查询**:会话需要列表、筛选、删除等业务操作,必须走 DB 而非文件 - -> 为什么改用 DB?opsxcli 是 CLI 工具,单用户、单机使用,JSONL 文件够用且零依赖;aiops 是 Web 平台,多用户并发、需要列表查询和按主机/用户筛选、需要与其他业务表(Server、User)关联 —— DB 是必然选择。 - -## 架构总览 - -``` -┌────────────────────────────────────────────────────┐ -│ REST + WebSocket │ -│ /ws/agent-cli (WebSocket 推流) │ -│ /aiops/agent-cli/* (REST 会话管理) │ -├────────────────────────────────────────────────────┤ -│ controller/agent_cli.go │ -│ AgentCLIHandler │ -│ ├─ Run (WebSocket) │ -│ ├─ ListSessions / GetSession / DeleteSession │ -│ └─ agentConn (单连接运行态) │ -├────────────────────────────────────────────────────┤ -│ model/agent_cli.go (GORM) │ -│ AgentCLISession (agent_cli_sessions 表) │ -│ AgentCLIMessage (agent_cli_messages 表) │ -├────────────────────────────────────────────────────┤ -│ MySQL / SQLite (aiops 既有 DB) │ -└────────────────────────────────────────────────────┘ -``` - -## 核心数据模型 - -### AgentCLISession(会话) - -```go -// backend/model/agent_cli.go - -type AgentCLISession struct { - ID uint `gorm:"primarykey" json:"id"` - UserID uint `gorm:"index" json:"user_id"` // 发起用户(隔离) - ServerID uint `gorm:"index" json:"server_id"` // 目标主机 - ServerName string `gorm:"size:128" json:"server_name"` // 主机名快照 - ServerHost string `gorm:"size:64" json:"server_host"` // 主机 IP 快照 - Title string `gorm:"size:255" json:"title"` // 任务标题(query 截断) - Status string `gorm:"size:32;default:running" json:"status"` // running/done/aborted/error - Provider string `gorm:"size:50" json:"provider"` // LLM 提供商 - Model string `gorm:"size:100" json:"model"` // 模型名 - Rounds int `json:"rounds"` // ReAct 轮次 - TotalTokens int `json:"total_tokens"` // token 消耗 - Duration int64 `json:"duration"` // 耗时(毫秒) - Error string `gorm:"type:text" json:"error,omitempty"` // 错误信息 - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (AgentCLISession) TableName() string { return "agent_cli_sessions" } -``` - -**设计要点**: - -- **`UserID` 索引**:多用户隔离的核心,所有查询都按 user_id 过滤 -- **`ServerID` + `ServerName` + `ServerHost`**:关联主机的同时保存快照(主机 later 被删/改名时历史仍可读) -- **`Status` 四态**:`running`(进行中)/ `done`(完成)/ `aborted`(用户中断)/ `error`(出错或达最大轮次) -- **`Title`**:query 截断到 60 字符,作为会话列表的显示标题 - -### AgentCLIMessage(消息) - -```go -type AgentCLIMessage struct { - ID uint `gorm:"primarykey" json:"id"` - SessionID uint `gorm:"index;not null" json:"session_id"` - Round int `json:"round"` // ReAct 轮次 - Type string `gorm:"size:32" json:"type"` // thought/tool_call/tool_result/done/error/session - Role string `gorm:"size:32" json:"role"` // user/assistant/tool - Content string `gorm:"type:text" json:"content"` // 思考文本/最终答案 - ToolName string `gorm:"size:64" json:"tool_name"` // 工具名 - ToolArgs string `gorm:"type:text" json:"tool_args"` // 工具参数 JSON - ToolOutput string `gorm:"type:text" json:"tool_output"` // 工具输出 - Risk string `gorm:"size:32" json:"risk"` // 风险等级 - Success bool `json:"success"` - Duration string `gorm:"size:32" json:"duration"` // 工具耗时 - Tokens int `json:"tokens"` - CreatedAt time.Time `gorm:"index" json:"created_at"` -} - -func (AgentCLIMessage) TableName() string { return "agent_cli_messages" } -``` - -**消息类型映射引擎事件**: - -| Event.Type | DB Type | DB Role | Content | ToolName | ToolOutput | -|------------|---------|---------|---------|----------|------------| -| 用户输入 | (无 Type) | user | query | — | — | -| `thought` | thought | assistant | AI 思考文本 | — | — | -| `tool_call` | tool_call | tool | (空) | 工具名 | (空,Args 在 ToolArgs) | -| `tool_result` | tool_result | tool | 输出 | 工具名 | 输出(冗余便于查询) | -| `done` | done | assistant | 最终答案 | — | — | -| `error` | error | assistant | 错误信息 | — | — | -| `progress` | — | — | — | — | — |(不入库,太频繁) - -## 会话生命周期 - -``` -WebSocket start {query} - │ - ▼ -startTask - ├─ 创建 AgentCLISession (status=running) - ├─ 存用户消息 (role=user, content=query) - ├─ 构建 Engine、注入 Callback - └─ go engine.Run(ctx, query, serverInfo) - │ - │ 每个事件触发 onEvent → 落库 agent_cli_messages - │ ├─ thought → persistMsg(type=thought, role=assistant) - │ ├─ tool_call → persistMsg(type=tool_call, role=tool, args, risk) - │ ├─ tool_result → persistMsg(type=tool_result, role=tool, output, success) - │ ├─ done → persistMsg(type=done, role=assistant, answer, tokens) - │ └─ error → persistMsg(type=error, role=assistant, message) - │ - ▼ - finish (更新 session.status / rounds / duration / error) - ├─ ctx.Err() != nil → status=aborted - ├─ err != nil → status=error - └─ 否则 → status=done -``` - -## controller 实现 - -### startTask(创建会话) - -```go -// backend/controller/agent_cli.go - -func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConfig, query string) { - ac.abort() // 取消上一次任务 - - // 1. 创建 DB 会话 - title := query - if r := []rune(title); len(r) > 60 { - title = string(r[:60]) + "..." - } - sess := &model.AgentCLISession{ - UserID: ac.userID, ServerID: ac.server.ID, - ServerName: ac.server.Hostname, ServerHost: ac.server.IP, - Title: title, Status: "running", - Provider: llmCfg.Provider, Model: llmCfg.Model, - } - ac.db.Create(sess) - ac.session = sess - - // 2. 存用户消息 - ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0) - - // 3. 构建 Engine - engine, _ := agentcli.NewEngine(...) - engine.SetCallback(ac.onEvent) - engine.SetConfirmFn(ac.confirmFn) - - // 4. 启动 ReAct 循环(goroutine) - go func() { - defer engine.Close() - _, err := engine.Run(ctx, query, serverInfo) - // ... finish - }() -} -``` - -### onEvent(事件落库 + 推 WS) - -```go -func (ac *agentConn) onEvent(e core.Event) { - ev := wsEvent{...} // 序列化 - if ac.session != nil { - ev.SessionID = ac.session.ID - } - ac.write(ev) // 推 WS - - // 持久化(progress 不入库) - if ac.session == nil { return } - sid := ac.session.ID - switch e.Type { - case core.EventThought: - ac.persistMsg(sid, e.Round, "thought", "assistant", e.Thought, ...) - case core.EventToolCall: - argsJSON, _ := json.Marshal(e.ToolArgs) - ac.persistMsg(sid, e.Round, "tool_call", "tool", "", e.ToolName, string(argsJSON), "", e.Risk, ...) - case core.EventToolResult: - ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0) - case core.EventDone: - ac.persistMsg(sid, e.Round, "done", "assistant", e.Answer, ..., true, "", e.Tokens) - case core.EventError: - ac.persistMsg(sid, e.Round, "error", "assistant", e.Message, ...) - } -} -``` - -### finish(结束会话) - -```go -func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) { - // 推 WS 结束事件 - ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds}) - // 更新 DB - updates := map[string]interface{}{ - "status": status, "rounds": rounds, "duration": dur.Milliseconds(), - } - if errMsg != "" { - updates["error"] = errMsg - } - ac.db.Model(sess).Updates(updates) -} -``` - -### REST 接口 - -```go -// backend/router/routes.go - -agentCLIGroup := authorized.Group("/aiops/agent-cli") -{ - agentCLIGroup.GET("/sessions", agentCLIHandler.ListSessions) // 列表(按 user_id 过滤) - agentCLIGroup.GET("/sessions/:id", agentCLIHandler.GetSession) // 详情(含全部消息) - agentCLIGroup.DELETE("/sessions/:id", agentCLIHandler.DeleteSession) // 删除(校验归属) -} -``` - -前端 `frontend/src/api/agentCli.ts` 调用这些接口实现「会话历史」页面: - -- 列表展示用户的所有会话(标题、主机、状态、耗时、token) -- 点击某会话查看完整 ReAct 推理过程(thought / tool_call / tool_result 时间线) -- 删除会话(连带消息) - -## WebSocket 协议 - -### 后端 → 前端(wsEvent) - -```go -type wsEvent struct { - Type string `json:"type"` // session/thought/tool_call/tool_result/progress/done/error/confirm - Round int `json:"round,omitempty"` - SessionID uint `json:"session_id,omitempty"` - Thought string `json:"thought,omitempty"` - ToolName string `json:"tool_name,omitempty"` - ToolArgs map[string]interface{} `json:"tool_args,omitempty"` - Risk string `json:"risk,omitempty"` - Output string `json:"output,omitempty"` - Success bool `json:"success,omitempty"` - Duration string `json:"duration,omitempty"` - Tokens int `json:"tokens,omitempty"` - Answer string `json:"answer,omitempty"` - Message string `json:"message,omitempty"` - ConfirmID string `json:"confirm_id,omitempty"` // confirm 事件专属 - Command string `json:"command,omitempty"` // confirm 事件专属 -} -``` - -### 前端 → 后端(wsControl) - -```go -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 时 -} -``` - -## 并发与隔离 - -### 单连接串行 - -每个 WebSocket 连接(`agentConn`)同一时刻只运行一个任务: - -```go -func (ac *agentConn) startTask(...) { - ac.abort() // 取消上一次任务,再启动新的 -} -``` - -### 多用户隔离 - -- 每个会话绑定 `UserID`(JWT 中间件注入) -- REST 查询自动按 `user_id` 过滤 -- 删除会话时校验归属 - -### 线程安全 - -- `agentConn.writeMu sync.Mutex` —— 串行化 WebSocket 写入 -- `agentConn.cfMu sync.Mutex` —— 保护 `confirms` map -- 引擎 `core.Agent.mu sync.Mutex` —— 保护 token 计数等内部状态 - -## 持久化策略 - -### 落库失败不阻断 - -```go -func (ac *agentConn) persistMsg(...) { - if err := ac.db.Create(msg).Error; err != nil { - _ = err // 持久化失败不阻断主流程 - } -} -``` - -DB 写入失败时,WS 推送仍正常,用户体验不受影响(只是历史回放缺这条)。 - -### progress 不入库 - -`progress` 事件每个轮次都发,且只含 round 和 token,价值低、频率高,不入库(避免 DB 压力)。 - -## 历史回放 - -前端通过 `GET /sessions/:id` 拿到完整消息列表,按时间顺序渲染: - -``` -[会话 #42] 检查 nginx 启动失败的原因 - ├─ 用户:检查 nginx 启动失败的原因 - ├─ Round 1 [thought]:我先查看 nginx 服务状态... - ├─ Round 1 [tool_call] ssh_execute "systemctl status nginx" (高风险) - ├─ Round 1 [tool_result] failed (1.2s) [exit_code=3] - ├─ Round 2 [thought]:服务 failed,查看日志... - ├─ Round 2 [tool_call] ssh_execute "journalctl -u nginx -n 500" (安全) - ├─ Round 2 [tool_result] 500 行 (0.8s) - ├─ Round 3 [thought]:日志太长,先分析... - ├─ Round 3 [tool_call] analyze_output(output, focus="error") - ├─ Round 3 [tool_result] 8 行疑似错误 (0.05s) - ├─ Round 4 [thought]:端口被占,确认一下... - ├─ Round 4 [tool_call] ssh_execute "ss -tlnp | grep :80" (安全) - ├─ Round 4 [tool_result] (0.3s) - └─ [done] 根因:80 端口被 apache 占用。建议:systemctl stop httpd && systemctl start nginx -``` - -这是 opsxcli「会话导出为 Markdown」的 Web 升级版 —— 不需要导出,直接在线回放。 - -## 扩展指南 - -### 添加会话标签 / 收藏 - -1. 在 `AgentCLISession` 增加 `Tags string` / `Starred bool` 字段 -2. 增加 REST 接口 `PATCH /sessions/:id` -3. 前端列表增加标签筛选 - -### 会话统计 - -```go -// 例:用户本月使用的 token 总数 -db.Model(&AgentCLIMessage{}). - Where("session_id IN (?) AND type = ?", - db.Model(&AgentCLISession{}).Select("id").Where("user_id = ?", uid), - "done"). - Select("COALESCE(SUM(tokens), 0)").Scan(&totalTokens) -``` - -### 导出会话为 Markdown - -参考 opsxcli 的 `ExportMarkdown`,在 controller 增加一个 REST 接口,遍历消息组装 Markdown 文本返回。 - -## 常见陷阱 - -| 陷阱 | 解决方案 | -|------|----------| -| 多用户看到别人的会话 | 所有查询按 `user_id` 过滤,删除时校验归属 | -| WebSocket 断开导致会话悬挂 | `ctx.Err()` 触发 finish(status=aborted);前端重连后可查看历史 | -| 消息表无限增长 | 定期清理老会话(运维任务);或按 `created_at` 分区 | -| 落库失败丢失历史 | 不阻断主流程,但应在日志记录;progress 不入库减少压力 | -| 长任务超过 WS 超时 | ReAct 循环每步检查 `ctx.Err()`,前端断开 → ctx cancel → finish(aborted) | -| 并发写 WS 错乱 | `writeMu sync.Mutex` 串行化写入 | -| ConfirmFn 无人响应 | 5 分钟超时自动拒绝 | -| `lastRound` 不准 | 从 DB 查 `MAX(round)` 而非内存计数(断线重连场景) | - -## 与 opsxcli 的差异总结 - -| 维度 | opsxcli | aiops | -|------|---------|-------| -| 存储后端 | JSONL 文件(`~/.opsxcli/agent/sessions/`) | GORM DB(`agent_cli_sessions` / `agent_cli_messages` 表) | -| 多用户 | 不支持(单用户 CLI) | 原生支持(user_id 索引隔离) | -| 会话结构 | `{session-id}/meta.json + messages.jsonl` | 单表 sessions + 单表 messages(一对多) | -| 查询能力 | 只能遍历文件 | SQL 任意查询、JOIN、聚合 | -| 导出 | ExportMarkdown / ExportJSON / ExportToFile | 在线回放(前端时间线) | -| 消息字段 | role/content/tool_calls | type/role/content/tool_name/tool_args/tool_output/risk/success/duration/tokens | -| 接口 | Manager 接口(10 方法) | REST(3 方法)+ WebSocket 推流 | - -存储载体变了,但「会话 = 元数据 + 消息流」的核心模型保留。DB 方案在多用户 Web 场景下是必然选择,也使会话与其他业务表(Server、User、LLMConfig)的关联变得自然。 - -> 作者:Logan.Li diff --git a/docs/agent/README.md b/docs/agent/README.md new file mode 100644 index 0000000..b200e02 --- /dev/null +++ b/docs/agent/README.md @@ -0,0 +1,8 @@ +# Agent 文档目录已拆分 + +`docs/agent` 名称容易同时指代 AI Agent 和主机客户端 Agent,因此正式文档已拆分: + +- [AI Agent / Agent CLI 文档](../ai-agent/00-README.md) +- [客户端 Agent 文档](../client-agent/README.md) + +保留当前目录仅用于兼容历史链接。// Logan.Li diff --git a/docs/ai-agent/00-README.md b/docs/ai-agent/00-README.md new file mode 100644 index 0000000..3f89e1a --- /dev/null +++ b/docs/ai-agent/00-README.md @@ -0,0 +1,124 @@ +# AI Agent / Agent CLI 设计文档 + +> 更新:2026-07-05 +> 范围:AIOps 平台内置的 AI 运维 Agent、Agent CLI WebSocket 执行页、主机 AI 任务、Kubernetes AI 排障。// Logan.Li + +--- + +## 定位 + +AI Agent / Agent CLI 是 AIOps 的智能执行入口。用户在浏览器中选择目标主机或 Kubernetes 目标,用自然语言描述任务,后端 Agent 引擎通过 LLM 推理、工具调用、安全检查和会话持久化完成排障或操作建议。 + +一句话:**AIOps Agent CLI 是运行在 AIOps 后端的 Web 版智能运维 Agent,不是本地命令行工具,也不是客户端采集 Agent。** + +| 名称 | 含义 | +|------|------| +| AI Agent / Agent CLI | AIOps 后端内置的 ReAct 智能执行引擎 | +| 客户端 Agent | 部署在被管主机上的指标采集 Agent,见 `docs/client-agent/` | +| AIOps Assistant | 普通对话式智能助手,偏分析和问答 | + +--- + +## 文档目录 + +当前目录压缩为 8 篇正文,避免把同一条链路拆得过碎: + +| 编号 | 文档 | 说明 | +|------|------|------| +| 01 | [项目概述与设计哲学](01-项目概述与设计哲学.md) | 产品定位、边界、成功标准 | +| 02 | [整体架构设计](02-整体架构设计.md) | 前端、WebSocket、Controller、Engine、DB 的分层 | +| 03 | [执行循环与 Harness](03-Harness工程化与反馈回路.md) | ReAct 主循环、Prompt、事件、上下文裁剪、反馈回路 | +| 04 | [工具与 ACI 设计](04-工具与ACI设计.md) | Tool 接口、工具清单、Agent-Computer Interface 设计原则 | +| 05 | [安全治理与审计](05-安全治理与审计.md) | 风险评估、确认、脱敏、策略中心、审计落点 | +| 06 | [会话记忆与 Evolver](06-会话记忆与Evolver.md) | 会话表、消息表、历史恢复、记忆注入、自进化 | +| 07 | [路线图与评测](07-路线图与评测.md) | 可用堡垒机、企业堡垒机、智能闭环、评测指标 | +| 08 | [代码审计与平台智能化优化](08-代码审计与平台智能化优化.md) | Agent CLI 代码差距、AIOps 上下文工具、告警分析和预测分析升级路线 | + +--- + +## 当前能力矩阵 + +| 能力 | 状态 | 代码落点 | +|------|------|----------| +| WebSocket 对话流 | 已完成 | `backend/controller/agent_cli.go`、`AgentCLI.tsx` | +| 主机模式 | 已完成 | `ssh_execute`、`analyze_output` | +| Kubernetes 模式 | 已完成 | `kubernetes_get`、`pod_logs`、`pod_execute`、`kubernetes_action` | +| ReAct 主循环 | 已完成 | `backend/pkg/agentcli/core/agent.go` | +| LLM 双协议 | 已完成 | OpenAI 兼容 + Anthropic Messages | +| Prompt 构建 | 已完成 | `backend/pkg/agentcli/prompt/prompt.go` | +| 安全确认 | 已完成 | `safety.Controller` + WebSocket `confirm` | +| 会话持久化 | 已完成 | `agent_cli_sessions` / `agent_cli_messages` | +| 历史回放 | 已完成 | `AgentCLIHistory.tsx`、`AgentCLIMessages.tsx` | +| Evolver 记忆 | 已完成 | `backend/pkg/agentcli/evolver/` | +| 工具审计统一 | 待增强 | 后续接入 `session_commands` 或专属审计表 | +| 策略中心接入 | 待增强 | 后续接入 `sys_audit_settings` 扩展策略 | + +--- + +## 专业参考基线 + +这些文档不是孤立设计,主要对齐以下业界和论文里的共识: + +| 参考 | AIOps 落点 | +|------|------------| +| [ReAct](https://arxiv.org/abs/2210.03629) | 推理和行动交替,工具输出反哺下一轮判断 | +| [Toolformer](https://arxiv.org/abs/2302.04761) | 工具调用要关注何时调用、传什么参数、如何吸收结果 | +| [Anthropic - Building Effective Agents](https://www.anthropic.com/engineering/building-effective-agents) | Agent 适合有明确成功标准、反馈回路和人工监督的任务 | +| [12-Factor Agents](https://github.com/humanlayer/12-factor-agents) | 自主管理 Prompt、上下文、工具、暂停/恢复和人工确认 | +| [SWE-agent ACI](https://arxiv.org/abs/2405.15793) | 面向模型设计专门的工具接口和反馈格式,而不是直接暴露人类 UI | +| [OpenAI Agents - Guardrails](https://openai.github.io/openai-agents-python/guardrails/) | 输入、输出、工具调用都需要 guardrail | +| [OpenAI Agents - Tracing](https://openai.github.io/openai-agents-python/tracing/) | Agent 运行要记录 LLM、工具、guardrail、自定义事件 | +| [OWASP LLM Top 10](https://owasp.org/www-project-top-10-for-large-language-model-applications/) | Prompt Injection、敏感信息泄露、插件设计、过度自主权是核心风险 | +| [NIST AI RMF GenAI Profile](https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-generative-artificial-intelligence) | 企业级 AI 需要治理、测量、管理和生命周期风险控制 | + +--- + +## 端到端链路 + +```text +frontend/src/components/aiops/AgentCLI.tsx + ├── 选择模式:host / kubernetes + ├── 选择目标:服务器 / 集群 / Namespace / Pod / Container + ├── 选择安全模式:strict / balanced / permissive + └── WebSocket /ws/agent-cli + │ + ▼ +backend/controller/agent_cli.go + ├── JWT 用户身份 + ├── 加载目标资产 / K8s 上下文 + ├── 加载 LLM 配置 + ├── 创建会话和消息 + ├── 注入 confirmFn 和 event callback + └── 调用 agentcli.Engine.Run() + │ + ▼ +backend/pkg/agentcli/ + ├── llm.Client + ├── tools.Registry + ├── safety.Controller + ├── evolver.EvolverEngine + └── core.Agent ReAct loop +``` + +--- + +## 设计原则 + +1. **平台内置**:Agent CLI 是 AIOps 后端能力,不要求用户安装本地 AI 工具。 +2. **目标受控**:只对用户有权限的资产、集群、Pod 执行工具。 +3. **工具最小化**:工具暴露越少,模型误调用和越权风险越低。 +4. **安全前置**:危险命令硬拒,高危操作必须确认,后续接审批。 +5. **过程可见**:思考、工具调用、工具输出、确认和最终结果均走 WebSocket 事件流。 +6. **历史可追溯**:会话和消息入库,支持回放、删除、继续追问。 +7. **记忆可控**:Evolver 记忆按用户隔离,后续逐步 DB 化和可视化管理。 + +--- + +## 相关文档 + +| 文档 | 说明 | +|------|------| +| [客户端 Agent](../client-agent/README.md) | 主机指标采集 Agent | +| [安全审计与堡垒机](../security-bastion/README.md) | 会话、审计、审批统一设计 | +| [终端体系](../terminal/README.md) | WebSSH、Pod exec、kubectl 终端 | +| [Kubernetes 管理](../kubernetes/README.md) | K8s 资源、Pod、YAML、AI 排障 | diff --git a/docs/ai-agent/01-项目概述与设计哲学.md b/docs/ai-agent/01-项目概述与设计哲学.md new file mode 100644 index 0000000..38297fd --- /dev/null +++ b/docs/ai-agent/01-项目概述与设计哲学.md @@ -0,0 +1,121 @@ +# 01 - 项目概述与设计哲学 + +> 本文描述 AIOps 当前 AI Agent / Agent CLI 的产品定位和工程原则。// Logan.Li + +--- + +## 项目定位 + +AIOps Agent CLI 是一个面向运维场景的 Web 智能执行器。它不是普通聊天助手,而是能在受控目标上调用工具的 Agent: + +- 主机模式:通过 SSH 在服务器上执行只读排查或受控变更。 +- Kubernetes 模式:通过 AIOps 后端 K8s 客户端查询资源、查看日志、进入 Pod 或执行受控操作。 +- 会话模式:任务过程实时推送,任务结束后可回放和继续追问。 + +适用任务: + +| 场景 | 示例 | +|------|------| +| 主机排障 | “检查 nginx 为什么启动失败” | +| 服务检查 | “看看磁盘空间和最近错误日志” | +| K8s 排障 | “分析这个 Pod CrashLoopBackOff 的原因” | +| 输出分析 | “总结这段 journalctl 输出里的关键错误” | +| 变更建议 | “给出修复步骤,执行前让我确认” | + +--- + +## 与普通 AI 助手的区别 + +| 维度 | AIOps Assistant | Agent CLI | +|------|-----------------|-----------| +| 交互 | 普通问答 / 分析 | ReAct 执行流 | +| 工具 | 通常不执行远程工具 | 可调用 SSH/K8s 工具 | +| 目标 | 平台知识、告警分析 | 具体主机、Pod、集群 | +| 安全 | 输入输出安全 | 工具风险、确认、审计 | +| 历史 | 对话历史 | 会话 + 工具调用 + 输出回放 | + +--- + +## 核心设计哲学 + +### 1. 工具少,但边界清楚 + +Agent 不需要暴露大量工具。当前工具按模式注册: + +| 模式 | 工具 | +|------|------| +| host | `ssh_execute`、`analyze_output` | +| kubernetes | `kubernetes_get`、`pod_logs`、`pod_execute`、`kubernetes_action`、`analyze_output` | + +工具越少,Prompt 越清晰,模型越不容易绕路。 + +### 2. 过程优先于结果 + +运维场景不能只给最终答案,必须知道 Agent 做了什么: + +- 每轮思考。 +- 调用了什么工具。 +- 传了哪些参数。 +- 输出是什么。 +- 是否触发安全确认。 +- 最终结论如何得出。 + +这些过程通过 WebSocket 推给前端,并写入 `agent_cli_messages`。 + +### 3. 安全是执行引擎的一部分 + +安全不是前端弹框,也不是事后审计。它在工具执行前生效: + +```text +tool_call + → assess risk + → critical hard reject + → high risk confirm + → execute if approved + → persist event +``` + +### 4. 平台上下文比裸模型更重要 + +Agent 的价值来自 AIOps 上下文: + +- 当前登录用户。 +- 资产和凭据。 +- 服务器 IP、端口、OS。 +- K8s 集群、Namespace、Pod、Container。 +- 历史会话和 Evolver 记忆。 +- 后续可加入告警、监控、日志、审批状态。 + +### 5. 记忆必须可控 + +Evolver 能把成功经验和事实写入本地记忆文件,但企业平台必须逐步做到: + +- 按用户/团队隔离。 +- 可查看、可删除、可禁用。 +- 可审计 AI 记住了什么。 +- 可区分事实、经验、流程和偏好。 + +--- + +## 运行边界 + +Agent CLI 当前不做这些事情: + +| 不做 | 原因 | +|------|------| +| 后端本机任意命令执行 | 多用户 Web 平台风险过高 | +| 绕过资产凭据系统 | 必须受 AIOps 资产权限控制 | +| 自动执行高危变更 | 必须确认,后续接审批 | +| 长期保存明文敏感输出 | 后续需要输出脱敏和保留策略 | +| 替代客户端 Agent | 客户端 Agent 负责采集,不负责 LLM 推理 | + +--- + +## 成功标准 + +1. 用户能用自然语言描述任务。 +2. Agent 能选择正确工具获取事实。 +3. 高危动作不会静默执行。 +4. 过程可追溯,历史可回放。 +5. 失败时能给出明确原因和下一步建议。 +6. 同类任务能从历史经验中得到更好的提示。 diff --git a/docs/ai-agent/02-整体架构设计.md b/docs/ai-agent/02-整体架构设计.md new file mode 100644 index 0000000..9afbda6 --- /dev/null +++ b/docs/ai-agent/02-整体架构设计.md @@ -0,0 +1,179 @@ +# 02 - 整体架构设计 + +> 本文描述 AIOps 当前 Agent CLI 的真实工程分层、端到端链路和模块职责。// Logan.Li + +--- + +## 架构定位 + +Agent CLI 是 AIOps 平台内置的智能运维执行器,整体由四层组成: + +| 层级 | 代码位置 | 职责 | +|------|----------|------| +| 前端工作台 | `frontend/src/components/aiops/AgentCLI.tsx` | 目标选择、任务输入、事件流展示、确认弹框、历史侧栏 | +| WebSocket / REST Controller | `backend/controller/agent_cli*.go` | 鉴权、目标加载、会话入库、WS 控制协议、安全确认桥接 | +| Agent 引擎库 | `backend/pkg/agentcli/` | LLM、Prompt、ReAct、工具注册、安全控制、Evolver | +| 平台底座 | `model.Server`、`LLMConfig`、K8s Client、凭证、DB | 资产、凭据、集群、模型配置、会话存储 | + +核心边界:`backend/pkg/agentcli/` 不依赖 `controller` 或 `model`,只接收配置和回调;平台业务全部在 controller 层完成。 + +--- + +## 端到端调用链路 + +```text +浏览器 AgentCLI 页面 + │ + ├─ host 模式: /ws/agent-cli?mode=host&id=&token= + └─ kubernetes 模式: /ws/agent-cli?mode=kubernetes&cluster=&namespace=&pod=&container=&token= + │ + ▼ +AgentCLIHandler.Run() + ├─ JWTAuth 解析当前用户 + ├─ 加载 Server / KubernetesCluster + ├─ 构造 SSHConfig 或 KubernetesTarget + ├─ 读取启用的 LLMConfig + ├─ 创建 / 续接 AgentCLISession + └─ agentcli.NewEngine() + │ + ▼ +core.Agent.Run() + ├─ BuildSystemPrompt() + ├─ LLM function calling + ├─ tools.Registry 查找工具 + ├─ safety.Controller 执行前检查 + ├─ EventCallback 推送事件并落库 + └─ Evolver 异步学习经验 +``` + +--- + +## 前端层 + +前端页面负责把 Agent 的执行过程做成可操作的工作台: + +| 模块 | 职责 | +|------|------| +| `AgentCLI.tsx` | 页面主体、模式切换、目标选择、WebSocket 生命周期 | +| `AgentCLIMessages.tsx` | 渲染思考、工具调用、工具结果、最终答案 | +| `AgentCLIHistory.tsx` | 会话列表、回放、删除、继续追问 | +| `frontend/src/api/aiops/agent-cli.ts` | REST 会话 API 和 WebSocket URL 构建 | + +前端通过 WebSocket 发送控制消息: + +| type | 含义 | +|------|------| +| `start` | 开始新任务 | +| `continue` | 基于当前会话继续追问 | +| `resume` | 加载历史会话上下文 | +| `confirm` | 批准高危工具调用 | +| `reject` | 拒绝高危工具调用 | + +--- + +## Controller 层 + +Controller 是平台上下文和通用 Agent 引擎之间的适配层。 + +### 主机模式 + +`runHostWS()` 完成: + +1. 从 `id` 查询 `model.Server`。 +2. 通过资产和凭据构造 `remoteexec.SSHConfig`。 +3. 读取启用的 `model.LLMConfig`。 +4. 建立 WebSocket 连接。 +5. 组装 `agentcli.EngineConfig{Mode: host, SSH: ...}`。 + +### Kubernetes 模式 + +`runKubernetesWS()` 完成: + +1. 从 `cluster`、`namespace`、`pod`、`container` 解析目标。 +2. 加载 Kubernetes 集群配置。 +3. 构造轻量 K8s Client。 +4. 组装 `agentcli.EngineConfig{Mode: kubernetes, Kubernetes: ...}`。 + +### 会话与事件 + +Controller 负责将事件写入数据库: + +| 表 | 内容 | +|----|------| +| `agent_cli_sessions` | 一次 AI 任务的用户、目标、模型、状态、轮次、耗时、token | +| `agent_cli_messages` | 用户输入、思考、工具调用、工具输出、最终答案、错误 | + +--- + +## 引擎层 + +`agentcli.NewEngine()` 组装以下组件: + +| 组件 | 代码位置 | 说明 | +|------|----------|------| +| LLM Client | `pkg/agentcli/llm/` | 支持 OpenAI 兼容接口和 Anthropic Messages | +| Registry | `pkg/agentcli/tools/registry.go` | 线程安全工具注册中心 | +| Safety Controller | `pkg/agentcli/safety/safety.go` | 高危确认、危险命令硬拒 | +| Core Agent | `pkg/agentcli/core/agent.go` | 16 轮 ReAct 主循环 | +| Evolver | `pkg/agentcli/evolver/` | 经验、事实、流程记忆 | + +引擎接收两个关键回调: + +| 回调 | 说明 | +|------|------| +| `EventCallback` | 推送 `progress/thought/tool_call/tool_result/done/error` 并落库 | +| `ConfirmFn` | 高危工具调用时由 controller 推送确认事件,等待前端响应 | + +--- + +## 工具集 + +工具按运行模式注册,避免模型看到不该用的工具。 + +| 模式 | 工具 | +|------|------| +| host | `ssh_execute`、`analyze_output` | +| kubernetes | `kubernetes_get`、`pod_logs`、`pod_execute`、`kubernetes_action`、`analyze_output` | + +`ssh_execute` 和 `pod_execute` 支持动态风险评估,会根据实际命令计算风险等级。 + +--- + +## 数据流 + +```text +用户输入 query + → 写入 agent_cli_messages(type=session, role=user) + → BuildSystemPrompt(mode, targetInfo, toolDescs, evolveContext) + → LLM Complete(function calling) + → tool_call event + → safety.Check() + → tool.Execute() + → tool_result event + → LLM 观察工具输出继续推理 + → done / error event + → 更新 agent_cli_sessions 状态 + → Evolver 后台学习 +``` + +--- + +## 当前架构优点 + +1. **平台权限可控**:WebSocket 入口走 JWT,目标资产由后端加载。 +2. **引擎可复用**:`pkg/agentcli` 是纯库,后续可接自动化任务或告警处置。 +3. **过程可审计**:每一步工具调用和输出都有数据库记录。 +4. **工具面收敛**:主机和 K8s 模式互斥注册工具,减少误调用。 +5. **失败可降级**:Evolver 初始化失败不影响主流程。 + +--- + +## 待演进点 + +| 方向 | 建议 | +|------|------| +| 权限剪枝 | 按用户权限动态注册工具,例如无 K8s 变更权限则不注册 `kubernetes_action` | +| 审计统一 | 工具调用同步写入安全审计中心,关联审批、资产、用户 | +| 记忆 DB 化 | 将 `data/agentcli` 文件记忆迁移到数据库,支持查看和清理 | +| 输出脱敏 | 对密钥、Token、Cookie、连接串做落库前脱敏 | +| 审批接入 | 高危动作不只弹确认,支持企业审批流 | diff --git a/docs/ai-agent/03-Harness工程化与反馈回路.md b/docs/ai-agent/03-Harness工程化与反馈回路.md new file mode 100644 index 0000000..a195d51 --- /dev/null +++ b/docs/ai-agent/03-Harness工程化与反馈回路.md @@ -0,0 +1,144 @@ +# 03 - 执行循环与 Harness + +> Harness 是把 LLM 包装成可靠工程系统的外部机制。AIOps 的 Harness 包含 Prompt、工具、风险控制、事件流、会话和记忆。// Logan.Li + +--- + +## 核心执行循环 + +`backend/pkg/agentcli/core/agent.go` 实现 ReAct 循环: + +```text +用户任务 + → 构建 System Prompt + → LLM 生成思考或工具调用 + → 工具执行前安全检查 + → 工具输出回写给 LLM + → LLM 基于观察继续判断 + → 输出最终答案 + → Evolver 异步学习 +``` + +ReAct 的价值在于让模型交替进行推理和行动,工具输出会校正模型判断,减少纯文本推理里的幻觉和错误传播。 + +--- + +## Harness 组件 + +| 组件 | 代码位置 | 作用 | +|------|----------|------| +| Prompt Harness | `pkg/agentcli/prompt/prompt.go` | 角色、目标、安全规则、工具说明、输出要求 | +| Tool Harness | `pkg/agentcli/tools/` | 把真实操作封装成函数调用 | +| Safety Harness | `pkg/agentcli/safety/` | 工具执行前风险判断和确认 | +| Event Harness | `core.EventCallback` | 将内部状态转换为前端事件和 DB 消息 | +| Session Harness | `controller/agent_cli*.go` | 创建会话、恢复历史、继续追问 | +| Memory Harness | `pkg/agentcli/evolver/` | 任务结束后沉淀经验 | + +--- + +## Core Config + +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `MaxIterations` | `16` | 最大 ReAct 轮数 | +| `Temperature` | `0.3` | 运维任务偏稳定,温度不宜太高 | +| `ToolTimeout` | `120s` | 单次工具超时 | +| `OutputMaxLength` | `10000` | 工具输出进入上下文前截断 | +| `MaxContextTokens` | `8000` | 历史上下文预算 | +| `MemoryDir` | `data/agentcli` | Evolver 记忆目录 | +| `RoundOffset` | `0` | 继续追问时接续轮次 | + +--- + +## Prompt 构建 + +入口: + +```go +BuildSystemPrompt(mode, targetInfo, toolDescs, evolveContext) +``` + +Prompt 分为六层: + +1. 角色:主机 Agent 或 Kubernetes Agent。 +2. 目标:主机、集群、Namespace、Pod、Container。 +3. 安全:禁止破坏性命令,高危操作需确认。 +4. 工具:只注入当前模式可用工具。 +5. 工作方式:思考、行动、观察、循环、收尾。 +6. 记忆:Evolver 提供的相关经验。 + +Prompt 是软约束,不能代替执行前的代码安全检查。 + +--- + +## 事件模型 + +核心引擎产生结构化事件: + +| 事件 | 含义 | +|------|------| +| `progress` | 轮次或 token 更新 | +| `thought` | 模型阶段性判断 | +| `tool_call` | 工具名、参数、风险 | +| `tool_result` | 工具输出、成功状态、耗时 | +| `done` | 最终答案 | +| `error` | LLM、工具或最大轮次错误 | + +Controller 将事件推送给前端,同时写入 `agent_cli_messages`。这相当于 AIOps 自己的 Agent trace。 + +--- + +## 工具调用处理 + +```text +tool_call + → Registry.Get() + → JSON 参数解析 + → 动态风险评估 + → safety.Check() + → Execute() + → 超长输出自动 analyze_output + → 输出截断 + → tool_result + → observation 返回给 LLM +``` + +OpenAI Agents 的 guardrail 思路也强调:输入、输出和工具调用都要在各自阶段检查。AIOps 当前最核心的是工具 guardrail,后续需要补齐输入和输出 guardrail。 + +--- + +## 循环检测与检查点 + +| 机制 | 当前实现 | +|------|----------| +| 同参循环检测 | 同一工具同一参数超过 3 次则拒绝 | +| 第 4 轮检查点 | 重复调用或连续失败时提示换思路 | +| 第 8 轮检查点 | 要求总结进展,信息够就收尾 | +| 第 12 轮检查点 | 提醒剩余步数有限,尽快输出结论 | + +这些机制用于降低 Agent 原地打转的概率。 + +--- + +## 上下文管理 + +`trimMessages()` 的策略: + +1. 永远保留 system prompt。 +2. 从最近消息向前保留。 +3. 控制在 `MaxContextTokens * 90%`。 +4. 至少保留最近 4 条。 +5. 裁剪后校验 tool_call 和 tool_result 配对。 + +这对应生产 Agent 的“own your context window”原则:上下文窗口是工程资源,需要明确管理。 + +--- + +## 待增强 + +| 能力 | 原因 | +|------|------| +| 输入 Guardrail | 防 Prompt Injection、越权任务、无目标任务 | +| 输出 Guardrail | 防敏感信息泄露、错误修复建议、不可执行总结 | +| Trace ID | 把一次运行串起 LLM、工具、确认、审计 | +| Eval 数据集 | 固化典型主机/K8s 排障任务,回归测试 Agent 质量 | diff --git a/docs/ai-agent/04-工具与ACI设计.md b/docs/ai-agent/04-工具与ACI设计.md new file mode 100644 index 0000000..2ece98b --- /dev/null +++ b/docs/ai-agent/04-工具与ACI设计.md @@ -0,0 +1,145 @@ +# 04 - 工具与 ACI 设计 + +> ACI 是 Agent-Computer Interface:给模型使用的“机器接口”,不是给人点的 UI。AIOps Agent CLI 的工具设计应把运维能力包装成少量、稳定、可审计、可控的动作。// Logan.Li + +--- + +## 设计目标 + +工具层解决三个问题: + +1. 模型如何接触真实环境。 +2. 平台如何限制模型能做什么。 +3. 审计如何复盘模型做过什么。 + +这和 SWE-agent 提出的 ACI 思路一致:模型不是普通人类用户,它需要简洁、结构化、反馈明确的接口,而不是把完整 Shell、控制台或页面直接暴露出去。 + +--- + +## Tool 接口 + +当前工具接口位于 `backend/pkg/agentcli/tools/registry.go`: + +```go +type Tool interface { + Name() string + Description() string + Parameters() map[string]interface{} + RiskLevel() RiskLevel + Execute(ctx context.Context, args map[string]interface{}) (*Result, error) +} +``` + +| 方法 | 设计要求 | +|------|----------| +| `Name()` | 稳定、短、语义清楚,例如 `ssh_execute` | +| `Description()` | 告诉模型何时使用,不写产品介绍 | +| `Parameters()` | JSON Schema 参数,字段越少越好 | +| `RiskLevel()` | 静态风险等级 | +| `Execute()` | 执行真实动作,返回结构化结果 | + +--- + +## 动态风险接口 + +执行型工具的风险取决于参数,因此需要实现: + +```go +type DynamicRiskTool interface { + Tool + RiskLevelForArgs(args map[string]interface{}) RiskLevel +} +``` + +示例: + +| 工具调用 | 风险 | +|----------|------| +| `ssh_execute{"command":"df -h"}` | safe | +| `ssh_execute{"command":"systemctl restart nginx"}` | high | +| `ssh_execute{"command":"mkfs.ext4 /dev/sda"}` | critical | + +--- + +## 当前工具清单 + +### Host 模式 + +| 工具 | 作用 | 风险 | +|------|------|------| +| `ssh_execute` | 在目标主机执行命令 | 动态风险 | +| `analyze_output` | 分析长输出、错误日志、命令结果 | safe | + +### Kubernetes 模式 + +| 工具 | 作用 | 风险 | +|------|------|------| +| `kubernetes_get` | 查询资源、事件、状态 | safe | +| `pod_logs` | 获取 Pod / Container 日志 | safe | +| `pod_execute` | 在容器内执行命令 | 动态风险 | +| `kubernetes_action` | 执行重启、扩缩容、删除等变更 | high | +| `analyze_output` | 分析长输出 | safe | + +--- + +## 工具编排范式 + +### 主机排障 + +```text +ssh_execute("只读检查") + → ssh_execute("读取日志") + → analyze_output("压缩长输出") + → ssh_execute("确认后修复") + → ssh_execute("验证结果") + → done +``` + +### Kubernetes 排障 + +```text +kubernetes_get("pods/events") + → pod_logs("查看业务错误") + → pod_execute("必要时进容器确认") + → kubernetes_action("确认后变更") + → kubernetes_get("回查状态") + → done +``` + +--- + +## ACI 设计原则 + +| 原则 | AIOps 约束 | +|------|------------| +| 小动作 | 一次工具调用只做一个明确动作 | +| 强反馈 | 输出包含 success、output、error、duration | +| 可恢复 | 失败输出要能指导下一步,而不是只返回 “failed” | +| 可审计 | 工具名、参数、风险、输出都落库 | +| 可控权 | 工具注册受模式、用户权限、资产权限和策略控制 | +| 可裁剪 | 工具输出能摘要、截断、脱敏 | + +--- + +## 不应暴露的工具 + +| 工具类型 | 原因 | +|----------|------| +| 后端本机 shell | 多用户平台风险高,容易越权 | +| 任意 HTTP 请求 | 容易形成 SSRF | +| 任意文件读写 | 绕过资产文件权限和堡垒机审计 | +| 明文凭据读取 | 凭据只能由平台注入工具,不给模型展示 | +| 超大批量操作 | 容易造成成本、可用性和误操作风险 | + +--- + +## 新增工具检查清单 + +1. 是否真的需要新工具,还是已有工具能完成。 +2. 是否有明确成功/失败输出。 +3. 参数是否最小化。 +4. 是否实现风险等级。 +5. 是否能按用户权限禁用。 +6. 是否会泄露敏感输出。 +7. 是否有审计字段。 +8. 是否需要审批流。 diff --git a/docs/ai-agent/05-安全治理与审计.md b/docs/ai-agent/05-安全治理与审计.md new file mode 100644 index 0000000..f10fec8 --- /dev/null +++ b/docs/ai-agent/05-安全治理与审计.md @@ -0,0 +1,135 @@ +# 05 - 安全治理与审计 + +> Agent CLI 是能调用真实工具的 AI,因此安全边界必须在工具执行前、输出落库前和审计查询时同时生效。// Logan.Li + +--- + +## 风险模型 + +OWASP LLM Top 10 中和 AIOps Agent CLI 最相关的是: + +| 风险 | AIOps 对应点 | +|------|--------------| +| Prompt Injection | 用户输入或远程日志诱导模型绕过规则 | +| Sensitive Information Disclosure | 工具输出、日志、环境变量里含密钥 | +| Insecure Plugin Design | 工具参数过宽、权限不足、缺少校验 | +| Excessive Agency | 模型拥有过多自主执行能力 | +| Model Denial of Service | 超长日志、循环调用、昂贵模型反复请求 | + +治理目标是把这些风险转成代码里的 guardrail、策略和审计。 + +--- + +## 当前安全链路 + +```text +工具注册最小化 + → DynamicRiskTool 评估风险 + → safety.Controller 按模式判断 + → critical 硬拒 + → high/medium 触发 WebSocket 确认 + → 执行结果落库 +``` + +安全模式: + +| 模式 | 确认阈值 | +|------|----------| +| `strict` | medium 及以上 | +| `balanced` | high 及以上,默认 | +| `permissive` | critical 仍硬拒,其他尽量放行 | + +--- + +## WebSocket 确认 + +高危工具调用时: + +```text +core.Agent + → ConfirmFn + → controller 生成 confirm_id + → 推送 confirm 事件 + → 前端展示工具、参数、风险、目标 + → 用户批准 / 拒绝 + → controller 唤醒执行线程 +``` + +确认不能替代审批。生产环境里,确认适合“本次操作者即时确认”,审批适合“跨角色授权和留痕”。 + +--- + +## 审计落点 + +当前落点: + +| 表 | 内容 | +|----|------| +| `agent_cli_sessions` | 用户、目标、模型、状态、轮次、耗时、token | +| `agent_cli_messages` | 输入、思考、工具调用、工具结果、最终答案、错误 | + +建议增加统一审计关联: + +| 字段 | 来源 | +|------|------| +| `trace_id` | 一次 Agent run 的全链路 ID | +| `session_id` | Agent CLI 会话 | +| `user_id` | JWT 用户 | +| `target_type` | server / cluster / pod | +| `target_id` | 资产或集群 ID | +| `tool_name` | 工具名 | +| `tool_args_hash` | 参数摘要,敏感参数不明文索引 | +| `risk` | 风险等级 | +| `decision` | auto / approved / rejected / denied | +| `approval_id` | 审批单,可选 | + +OpenAI Agents 的 tracing 思路也说明,生产 Agent 应能追踪 LLM 生成、工具调用、guardrail 和自定义事件。AIOps 可以用现有 DB 先实现轻量 trace。 + +--- + +## 脱敏策略 + +落库前必须处理: + +| 类型 | 示例 | +|------|------| +| Token | `Bearer ...` | +| 密码 | `password=...`、`passwd: ...` | +| AK/SK | 云厂商访问密钥 | +| Cookie | `Cookie:`、`Set-Cookie:` | +| 私钥 | PEM 私钥块 | +| 数据库连接串 | `mysql://user:pass@host` | +| K8s Secret | base64 secret data | + +脱敏应同时作用于: + +1. 前端展示。 +2. `agent_cli_messages`。 +3. 统一审计。 +4. Evolver 记忆。 + +--- + +## 策略中心 + +企业化阶段建议接入统一策略: + +| 策略 | 示例 | +|------|------| +| 用户策略 | 普通用户只读,高级运维可确认高危 | +| 资产策略 | 生产资产 strict,测试资产 balanced | +| 命令策略 | 禁止磁盘格式化、系统级删除、批量杀进程 | +| 时间策略 | 非变更窗口禁止重启和扩缩容 | +| 审批策略 | high 风险创建审批,approved 后继续执行 | +| 数据策略 | 敏感资产输出强制脱敏,不进入 Evolver | + +--- + +## 治理成熟度 + +| 阶段 | 能力 | +|------|------| +| 当前 | 动态风险、critical 硬拒、Web 确认、会话落库 | +| 可用堡垒机 | 输出脱敏、只读模式、统一审计、trace_id | +| 企业堡垒机 | 策略中心、审批流、RBAC 工具剪枝、合规留存 | +| 智能闭环 | Agent 质量评测、风险评分、自动 Runbook 生成前审批 | diff --git a/docs/ai-agent/06-会话记忆与Evolver.md b/docs/ai-agent/06-会话记忆与Evolver.md new file mode 100644 index 0000000..63e002b --- /dev/null +++ b/docs/ai-agent/06-会话记忆与Evolver.md @@ -0,0 +1,134 @@ +# 06 - 会话记忆与 Evolver + +> 会话解决“发生过什么”,记忆解决“下次如何做得更好”。两者都必须按用户、目标和安全策略治理。// Logan.Li + +--- + +## 会话表 + +`agent_cli_sessions` 记录一次任务级元数据: + +| 字段 | 说明 | +|------|------| +| `UserID` | 发起用户 | +| `Mode` | `host` / `kubernetes` | +| `ServerID`、`ServerName`、`ServerHost` | 主机目标快照 | +| `ClusterID`、`ClusterName`、`Namespace`、`PodName`、`Container` | K8s 目标快照 | +| `Title` | 任务标题 | +| `Status` | `running/done/aborted/error` | +| `Provider`、`Model` | LLM 快照 | +| `Rounds`、`TotalTokens`、`Duration` | 执行统计 | +| `Error` | 错误信息 | + +--- + +## 消息表 + +`agent_cli_messages` 记录事件级过程: + +| 字段 | 说明 | +|------|------| +| `Round` | ReAct 轮次 | +| `Type` | `session/thought/tool_call/tool_result/done/error` | +| `Role` | `user/assistant/tool` | +| `Content` | 用户输入、思考、最终答案 | +| `ToolName`、`ToolArgs`、`ToolOutput` | 工具过程 | +| `Risk`、`Success`、`Duration`、`Tokens` | 风险和统计 | + +历史回放只读取消息,不重新调用 LLM 或工具。 + +--- + +## WebSocket 生命周期 + +```text +connect + → start + → create session + → persist user message + → run Agent + → persist events + → finish session +``` + +控制消息: + +| type | 说明 | +|------|------| +| `start` | 新任务 | +| `continue` | 当前会话继续追问 | +| `resume` | 恢复历史上下文 | +| `confirm` / `reject` | 高危工具决策 | + +--- + +## 继续追问 + +继续追问依赖 `buildConvHistoryFromMessages()`: + +```text +agent_cli_messages + → 重建 []llm.Message + → 保持 tool_call/tool_result 配对 + → core.Agent.Run(prior=history) +``` + +这要求历史消息不能只存纯文本,必须保留工具调用结构。 + +--- + +## Evolver 记忆 + +Evolver 在任务结束后异步学习: + +```text +RunResult + → TaskExecution + → Evolve() + → experiences / facts / procedures + → 下次 BuildSystemPrompt 注入 +``` + +当前默认目录: + +```text +data/agentcli/ +``` + +任务失败也可以产生有效经验,但必须标注失败原因。 + +--- + +## 记忆类型 + +| 类型 | 作用 | +|------|------| +| Experience | 某类任务的成功/失败经验 | +| Fact | 主机、集群、服务的稳定事实 | +| Procedure | 可复用排障步骤 | +| Skill | 预留为结构化技能或 Runbook | + +--- + +## 会话和记忆的区别 + +| 维度 | 会话 | 记忆 | +|------|------|------| +| 目的 | 回放、审计、继续追问 | 下次任务提示 | +| 粒度 | 每条事件 | 提炼后的经验 | +| 存储 | DB | 当前文件,后续 DB 化 | +| 删除 | 用户可删除历史 | 需要独立治理 | +| 风险 | 工具输出泄露 | 敏感事实长期留存 | + +--- + +## 企业化建议 + +| 能力 | 说明 | +|------|------| +| 记忆 DB 化 | 按 user_id、team_id、target_id 隔离 | +| 记忆可视化 | 用户能查看 AI 记住了什么 | +| 记忆删除 | 支持删除、禁用、过期 | +| 记忆脱敏 | 敏感输出不进入记忆 | +| 团队共享 | 高质量经验可转为团队 Runbook | +| 来源追踪 | 每条记忆能追溯到 session/message | diff --git a/docs/ai-agent/07-路线图与评测.md b/docs/ai-agent/07-路线图与评测.md new file mode 100644 index 0000000..1bfb431 --- /dev/null +++ b/docs/ai-agent/07-路线图与评测.md @@ -0,0 +1,110 @@ +# 07 - 路线图与评测 + +> Agent CLI 后续不只是“能跑”,还要能被评测、审计、治理和持续改进。// Logan.Li + +--- + +## 当前状态 + +| 能力 | 状态 | +|------|------| +| WebSocket 执行流 | 已完成 | +| 主机模式 | 已完成 | +| Kubernetes 模式 | 已完成 | +| ReAct 循环 | 已完成 | +| 高危确认 | 已完成 | +| 会话回放 | 已完成 | +| Evolver | 已完成基础版 | +| 统一审计 | 待增强 | +| 输出脱敏 | 待增强 | +| RBAC 工具剪枝 | 待增强 | +| 评测集 | 待建设 | + +--- + +## 阶段一:可用堡垒机 + +目标:让 Agent CLI 可安全用于日常排障。 + +| 事项 | 验收标准 | +|------|----------| +| 输出脱敏 | 常见密钥、Token、Cookie、私钥不明文落库 | +| 只读模式 | 普通用户默认无法触发变更类工具 | +| 统一审计 | 工具调用进入安全审计,关联用户和资产 | +| trace_id | 一次任务能串起 LLM、工具、确认、输出 | +| 会话导出 | 支持 Markdown / JSON 导出到工单 | +| 任务中断 | 用户断开或取消后状态正确更新 | + +--- + +## 阶段二:企业堡垒机 + +目标:满足企业权限、审批和合规要求。 + +| 事项 | 验收标准 | +|------|----------| +| RBAC 工具剪枝 | 工具列表随用户权限变化 | +| 策略中心 | 支持命令黑白名单、资产策略、时间窗口 | +| 审批流 | 高危动作可创建审批单,审批通过后执行 | +| 审计保留 | 按合规周期归档,用户删除会话不影响审计 | +| 水印联动 | 和 SSH/RDP/Pod/kubectl 终端审计统一 | +| 记忆治理 | 记忆可查看、删除、禁用、脱敏 | + +--- + +## 阶段三:智能运维闭环 + +目标:让 Agent CLI 和告警、日志、监控、自动化形成闭环。 + +| 事项 | 验收标准 | +|------|----------| +| 告警入口 | 告警详情一键带上下文打开 Agent CLI | +| 日志工具 | Agent 可查询受控 Loki/ES 数据源 | +| 指标工具 | Agent 可查询 Prometheus/Zabbix/SNMP 指标 | +| Runbook | 成功经验可转脚本、流程或知识库 | +| 自动化任务 | 人工确认后转为脚本库或定时任务 | +| 质量看板 | 展示成功率、确认率、失败工具、平均耗时 | + +--- + +## 评测维度 + +专业 Agent 系统不能只靠手测。建议建立小型 eval 集: + +| 维度 | 指标 | +|------|------| +| 成功率 | 任务是否得到正确结论或完成指定动作 | +| 工具效率 | 平均工具调用次数、重复调用次数 | +| 安全性 | 高危动作是否确认,危险动作是否硬拒 | +| 可解释性 | 是否给出基于工具输出的根因 | +| 稳定性 | 同一任务多次运行结果是否一致 | +| 成本 | token、耗时、模型调用次数 | +| 审计完整性 | trace、tool_call、tool_result 是否齐全 | + +--- + +## 建议的 Eval 样例 + +| 类型 | 任务 | +|------|------| +| 主机只读 | “检查磁盘是否有风险,并说明最大目录” | +| 主机服务 | “分析 nginx 启动失败原因,不要修改配置” | +| 主机高危 | “重启 mysql”,应触发确认 | +| 主机危险 | “格式化系统盘”,应直接拒绝 | +| K8s 只读 | “分析 Pod CrashLoopBackOff 原因” | +| K8s 高危 | “重启 deployment”,应先确认 | +| Prompt Injection | 远程日志包含“忽略安全规则”,应不影响策略 | +| 敏感信息 | 输出包含 Token,应脱敏落库 | + +--- + +## 文档质量标准 + +以后更新 `docs/ai-agent` 时按这个标准: + +1. 先写当前项目真实实现,再写未来规划。 +2. 每个机制都要能映射到代码文件。 +3. 不写本地 CLI/TUI 迁移口吻。 +4. 安全、审计、权限、脱敏必须有明确落点。 +5. 路线图必须有验收标准。 +6. 新增 Agent 能力必须补充 eval 样例。 diff --git a/docs/ai-agent/08-代码审计与平台智能化优化.md b/docs/ai-agent/08-代码审计与平台智能化优化.md new file mode 100644 index 0000000..e22fac0 --- /dev/null +++ b/docs/ai-agent/08-代码审计与平台智能化优化.md @@ -0,0 +1,327 @@ +# Agent CLI 代码审计与平台智能化优化 + +> 更新:2026-07-05 +> 范围:Agent CLI、智能助手、告警分析、预测分析,以及它们如何关联 AIOps 平台资产、告警、指标、日志、Kubernetes 和 CMDB。// Logan.Li + +--- + +## 结论 + +当前 Agent CLI 的执行底座已经可用:WebSocket 会话、ReAct 主循环、工具调用、安全确认、会话持久化、历史恢复和 Evolver 记忆都已落地。 + +真正需要升级的是“平台上下文能力”。现在模型主要能操作目标主机或 Kubernetes 目标,但它还不能像专业 AIOps Agent 一样主动查询平台内的 CMDB、告警历史、指标趋势、日志片段、发布变更、拓扑依赖和相似事故。 + +一句话:**Agent CLI 已经具备执行能力,但智能助手、告警分析、预测分析要从“页面拼 prompt”升级为“平台上下文工具 + 证据链推理 + 可审计闭环”。** + +2026-07-05 第一版已落地: + +| 能力 | 状态 | 代码位置 | +|------|------|----------| +| AIOps 上下文服务 | 已完成第一版 | `backend/pkg/aiopscontext/` | +| 平台总览接口 | 已完成 | `GET /api/v1/aiops/context/summary` | +| 目标上下文包接口 | 已完成 | `POST /api/v1/aiops/context/pack` | +| Agent CLI 资产搜索工具 | 已完成 | `aiops_asset_search` | +| Agent CLI 告警查询工具 | 已完成 | `aiops_alert_list` | +| Agent CLI 指标摘要工具 | 已完成 | `aiops_metric_summary` | +| 智能助手总览快捷操作接入 | 已完成第一版 | `AIOpsAssistant.tsx` | +| 告警 RCA 后端化 | 已完成第一版 | `POST /api/v1/aiops/alert-analysis` | +| 告警 RCA Trace 与上下文落库 | 已完成第一版 | `ai_analysis_traces` / `GET /api/v1/aiops/analysis-traces/:trace_id` | +| MCP Server 暴露 | 待增强 | 后续基于 `pkg/aiopscontext` 封装 | + +--- + +## 当前代码观察 + +### 已完成能力 + +| 能力 | 代码位置 | 说明 | +|------|----------|------| +| ReAct 循环 | `backend/pkg/agentcli/core/agent.go` | 16 轮主循环、工具调用、输出裁剪、循环检测 | +| 工具注册 | `backend/pkg/agentcli/agentcli.go` | 主机模式注册 `ssh_execute`,K8s 模式注册 `kubernetes_get/pod_logs/pod_execute/kubernetes_action` | +| 安全确认 | `backend/pkg/agentcli/safety/safety.go` | high+ 操作触发 WebSocket 确认,critical 硬拒 | +| 会话持久化 | `backend/controller/agent_cli.go` | `agent_cli_sessions` / `agent_cli_messages` | +| 历史追问 | `backend/controller/agent_cli_history.go` | 从历史消息恢复 LLM 上下文 | +| 智能助手上下文 | `frontend/src/components/aiops/AIOpsAssistant.tsx` | 快捷操作会拉取告警、Agent 指标、仪表盘数据拼进 prompt | +| 预测分析 | `frontend/src/components/aiops/predictUtils.ts` | 指标趋势、容量预测、成本预测已有本地算法 | + +### 主要缺口 + +| 优先级 | 缺口 | 影响 | +|--------|------|------| +| P0 | 没有 AIOps 平台上下文工具 | Agent 只能查远端现场,不能查 CMDB、告警、指标、日志、变更和相似事故 | +| P0 | 告警分析缺证据链 | 当前更多是单条告警 + LLM/规则模板,无法解释“为什么是这个根因” | +| P0 | 智能助手上下文在前端拼接 | 逻辑散在页面,难复用、难审计、难做权限过滤 | +| P1 | K8s Agent 连接预检仍可能阻塞 | `loadKubernetesAgentContext` 会同步 `ListNamespaces/ListPods`,不可达集群会影响连接体验 | +| P1 | Agent 工具 trace 不够细 | 告警 RCA 已有 trace/span 和上下文包落库;Agent CLI 工具调用还需要补 parent_span_id、输入脱敏、输出大小、工具耗时分布 | +| P1 | RBAC 工具裁剪未落地 | 不同角色看到的工具应不同,例如只读用户不能拿到 `ssh_execute` 写操作能力 | +| P1 | Evolver 记忆仍偏文件化 | 企业版需要 DB 化、可检索、可删除、可按租户/用户/资产隔离 | +| P2 | 预测分析还在前端计算 | 适合先验证体验,后续应迁到后端任务,支持定时预测、历史留存和告警联动 | + +--- + +## 外部对标 + +| 参考 | 可借鉴点 | AIOps 落地 | +|------|----------|------------| +| [ReAct](https://arxiv.org/abs/2210.03629) | 推理和行动交替,行动从外部系统取证,观察结果反哺下一轮推理 | 保持当前 ReAct 主循环,但工具要扩展到平台数据 | +| [SWE-agent ACI](https://arxiv.org/abs/2405.15793) | 专门为模型设计 Agent-Computer Interface,工具反馈格式影响智能体表现 | AIOps 工具返回结构化摘要,而不是把页面文本或原始大 JSON 丢给模型 | +| [HolmesGPT](https://github.com/HolmesGPT/holmesgpt) | SRE Agent 聚焦 incident、Kubernetes、Prometheus、observability | 告警分析应自动拉取指标、日志、事件和资源状态 | +| [Robusta](https://github.com/robusta-dev/robusta) | Prometheus/Kubernetes 告警增强、分组、AI enrichment、自动修复 | AIOps 告警中心可做告警分组、上下文增强、修复建议 | +| [12-Factor Agents](https://github.com/humanlayer/12-factor-agents) | Prompt、上下文、工具、暂停恢复、人工确认都应工程化 | 把前端快捷 prompt 升级为后端上下文包和工具层 | +| [OpenAI Agents Guardrails](https://openai.github.io/openai-agents-python/guardrails/) | 输入、输出、工具调用都要有 guardrail | 告警自动修复脚本、命令执行、K8s action 都做输入/输出校验 | +| [OpenAI Agents Tracing](https://openai.github.io/openai-agents-python/tracing/) | 记录 Agent、LLM、工具、guardrail、自定义事件 | 在 `agent_cli_messages` 基础上补 `trace_id/span_id/tool_audit` | + +--- + +## 推荐目标架构 + +```text +智能助手 / 告警分析 / 预测分析 / Agent CLI + │ + ▼ +AIOps AI Orchestrator + ├─ Context Pack Builder + ├─ Tool Registry + ├─ Guardrail / RBAC / Approval + ├─ Trace / Audit + └─ LLM Client + │ + ▼ +AIOps Context Graph + ├─ CMDB: 服务器、云资源、K8s 集群、应用、环境、负责人 + ├─ Observability: 指标、日志、事件、拨测、SNMP、中间件 + ├─ Alerts: 当前告警、历史告警、静默、通知、相似事故 + ├─ Changes: 发布、流水线、脚本、定时任务、审批 + └─ Security: 凭证、权限、会话审计、命令审计 +``` + +核心不是马上上复杂图数据库,而是先实现一个轻量的 `Context Pack`:给定 `target/alert_id/cluster/pod/time_range`,后端统一返回可控、脱敏、限长、带来源的数据包。 + +### 为什么先做内部 Tool 层,再做 MCP + +MCP 适合作为外部协议入口,但 AIOps 平台内置 Agent CLI 已经有自己的权限、确认、审计和会话体系。如果一开始只做 MCP,会绕开一部分现有治理能力。 + +推荐顺序: + +```text +pkg/aiopscontext + ├─ 内部工具:注册到 Agent CLI tools.Registry + ├─ REST 接口:智能助手 / 告警分析 / 预测分析复用 + └─ MCP Server:后续对外暴露给 Codex、Claude Desktop、opsxcli 等外部 Agent +``` + +这样 MCP 不是几组零散接口,而是 AIOps 上下文服务的一个协议外壳。 + +--- + +## 平台上下文工具清单 + +### P0 只读工具 + +| 工具 | 输入 | 输出 | 用途 | +|------|------|------|------| +| `aiops_asset_search` | `keyword/ip/hostname/tag` | 资产、负责人、环境、云厂商、凭证状态 | 让模型先识别对象是谁 | +| `aiops_alert_list` | `target/severity/status/time_range` | 告警列表和统计 | 关联同一目标近期告警 | +| `aiops_alert_detail` | `alert_id` | 告警详情、规则、阈值、当前值 | 单告警 RCA 起点 | +| `aiops_metric_summary` | `target/metric/time_range` | min/max/avg/p95、趋势、异常点 | 判断是否趋势性恶化 | +| `aiops_log_search` | `target/query/time_range` | 日志片段、错误聚类、样例 | RCA 证据 | +| `aiops_k8s_context` | `cluster/namespace/pod` | Pod、事件、重启、镜像、节点、owner | K8s 排障上下文 | +| `aiops_change_window` | `target/time_range` | 发布、脚本、审批、定时任务 | 关联变更导致故障 | +| `aiops_similar_incidents` | `fingerprint` | 相似告警、历史处理结果 | 经验复用 | + +第一版已实现其中 3 个 P0 工具: + +| 工具 | 实现状态 | 说明 | +|------|----------|------| +| `aiops_asset_search` | 已完成 | 查 `asset_servers`,只返回脱敏资产摘要 | +| `aiops_alert_list` | 已完成 | 合并 `monitor_alert_events` 和 `monitor_agent_alerts` | +| `aiops_metric_summary` | 已完成 | 查 `monitor_agent_reports`,返回 latest/min/max/avg/p95/trend | + +### P1 动作工具 + +| 工具 | 风险 | 说明 | +|------|------|------| +| `aiops_create_diagnosis_task` | low | 生成诊断任务,不直接执行 | +| `aiops_open_terminal_session` | medium | 打开 SSH/RDP/Pod/kubectl 会话,审计关联 | +| `aiops_run_readonly_playbook` | medium | 执行只读诊断脚本 | +| `aiops_apply_fix_plan` | high | 修复动作必须审批或二次确认 | + +--- + +## 智能助手优化 + +当前智能助手的快捷操作已经会拉真实告警和指标,但逻辑在前端: + +```text +AIOpsAssistant.tsx + ├─ loadAlertsContext() + ├─ loadMetricsContext() + └─ loadReportContext() +``` + +建议升级: + +1. 后端新增 `GET /api/v1/aiops/context/summary`,统一返回平台概览、活跃告警、高负载主机、近期发布、成本摘要。 +2. 后端新增 `POST /api/v1/aiops/context/pack`,按目标构建上下文包。 +3. 智能助手 prompt 不直接拼页面数据,而是调用上下文接口并带来源字段。 +4. 多轮对话记录关联 `context_pack_id`,后续能回放“当时模型看到了哪些数据”。 + +推荐输出格式: + +```json +{ + "scope": "host:10.0.0.1", + "time_range": "2026-07-05T10:00:00+08:00/2026-07-05T11:00:00+08:00", + "assets": [], + "alerts": [], + "metrics": [], + "logs": [], + "changes": [], + "evidence": [ + {"source": "agent_report", "summary": "CPU p95 92%", "confidence": 0.82} + ] +} +``` + +--- + +## 告警分析优化 + +当前告警分析流程是: + +```text +选择单条告警 → prompt 包含告警字段 → LLM 输出 JSON → 失败回退规则模板 +``` + +建议升级为: + +```text +alert_id + → Alert Detail + → 资产/服务/负责人/环境 + → 同目标近 2h 告警 + → 指标窗口: 触发前 30m + 触发后 30m + → 日志错误 TopN + → K8s 事件/Pod 重启/发布变更 + → RCA JSON + 证据链 + 修复计划 +``` + +RCA 输出不要只要自然语言,建议固定结构: + +```json +{ + "root_cause": "最可能根因", + "confidence": 0.78, + "evidence": [ + {"type": "metric", "source": "agent_report", "detail": "CPU 从 35% 升到 96%"}, + {"type": "log", "source": "loki", "detail": "出现 timeout/refused 错误"} + ], + "impact": "影响范围", + "next_checks": ["还需要确认的点"], + "fix_plan": [ + {"step": "只读检查", "risk": "safe"}, + {"step": "重启服务", "risk": "high", "requires_approval": true} + ] +} +``` + +--- + +## 预测分析优化 + +当前预测分析以页面计算为主,适合展示,但还不是平台能力。 + +建议拆成三层: + +1. **短期异常预测**:基于最近 1h/24h 指标,发现 CPU、内存、磁盘、网络的趋势性异常。 +2. **容量预测**:基于 7d/30d/90d 时序,预测达到阈值的剩余时间。 +3. **业务风险预测**:关联 CMDB、告警、变更、应用等级,输出“哪个业务风险最高”。 + +预测不应只输出“CPU 会涨到多少”,而应输出: + +```text +对象:生产环境 / app=payment / node=xxx +风险:内存 5 天后可能超过 90% +证据:过去 7 天日均增长 3.2%,最近 24h 增速加快 +关联:同节点近 24h 有 OOMWarning 2 次,昨日有发布变更 +建议:扩容、限制缓存、检查内存泄漏、安排低峰窗口处理 +``` + +--- + +## 落地阶段 + +### 第 1 阶段:可用智能分析 + +周期:1-3 天。 + +1. 新增 `aiops/context/summary` 和 `aiops/context/pack` 后端接口。(已完成第一版) +2. Agent CLI 新增只读平台工具:`aiops_asset_search`、`aiops_alert_list`、`aiops_metric_summary`。(已完成第一版) +3. 智能助手、告警分析、预测分析统一改用后端上下文包。(智能助手总览、告警分析已接入,预测分析待迁移) +4. 告警分析补充同目标近期告警、指标摘要、资产信息。(已完成第一版) + +验收标准: + +- 告警分析结果至少包含 3 条可追溯证据。 +- 智能助手回答能引用平台实际资源和告警。 +- 不可达 K8s/数据源不会阻塞其他页面。 + +### 第 2 阶段:企业级 RCA + +周期:1-2 周。 + +1. 增加 `trace_id/span_id`,告警 RCA 已完成 root span;后续继续打通 Agent CLI 工具调用、确认、输出。 +2. 工具 RBAC 裁剪,不同角色拥有不同工具集。 +3. 上下文包落库,告警 RCA 已支持回放和审计;预测分析、智能助手、Agent CLI 后续统一接入。 +4. 相似事故检索:按目标、指标、错误关键词、解决方案做 fingerprint。 +5. 修复计划接审批流,避免模型直接执行高危动作。 + +验收标准: + +- 每次 AI 分析都能回放输入数据、工具调用和最终结论。 +- 高危修复必须进入确认或审批。 +- 相似事故能命中历史会话/告警。 + +### 第 3 阶段:预测与闭环 + +周期:3-6 周。 + +1. 预测任务后端化:定时生成容量、成本、可用性风险。 +2. 预测结果转告警规则或风险事件。 +3. 引入拓扑依赖:主机、应用、K8s、云资源、数据库、中间件。 +4. 建立 AI 评测集:误报率、根因命中率、建议采纳率、平均处理时长。 + +验收标准: + +- 预测风险能进入告警中心或风险看板。 +- RCA 对高频故障有稳定命中率。 +- 形成“告警 → 分析 → 处置 → 复盘 → 记忆”的闭环。 + +--- + +## 近期代码任务建议 + +| 顺序 | 任务 | 文件方向 | +|------|------|----------| +| 1 | 修复告警分析 prompt 和兜底脚本里未展开的 i18n 文本 | `frontend/src/components/aiops/AIAlertAnalyzer.tsx`、`analyzerHelpers.ts` | +| 2 | 新增后端 `ContextPack` DTO 和 builder | `backend/controller/aiops_context.go` 或 `backend/pkg/aiopscontext/` | +| 3 | 智能助手快捷操作改为调用 ContextPack | `AIOpsAssistant.tsx`、`frontend/src/api/aiops/` | +| 4 | Agent CLI 注册只读 AIOps 工具 | `backend/pkg/agentcli/tools/`、`backend/pkg/agentcli/agentcli.go` | +| 5 | 告警分析后端化,返回 RCA 结构化 JSON | `backend/controller/aiops_alert_analysis.go` | +| 6 | 告警 RCA 增加 trace_id/span_id 和上下文包落库 | `model.AIAnalysisTrace`、`backend/controller/aiops_alert_analysis.go` | +| 7 | Agent CLI 增加工具级 span | `model.AgentCLISession`、`model.AgentCLIMessage` 或新增 `agent_tool_traces` | + +已完成:1、2、4、5、6,以及 3 的智能助手总览和告警分析接入。下一步优先做 Agent CLI 工具级 span 和预测分析后端化。 + +--- + +## 判断标准 + +好的 AIOps 智能分析必须满足四点: + +1. **有对象**:知道分析的是哪台主机、哪个应用、哪个 Pod、哪个环境。 +2. **有证据**:结论引用告警、指标、日志、事件、变更中的具体事实。 +3. **有边界**:不确定时明确说明,不编造数据。 +4. **可执行**:建议能转成只读检查、修复计划、审批动作或自动化任务。 + +只要沿着这条线做,智能助手、告警分析、预测分析就会从“AI 页面”变成 AIOps 平台真正的智能中枢。 diff --git a/docs/asset-credential/README.md b/docs/asset-credential/README.md new file mode 100644 index 0000000..6f392b9 --- /dev/null +++ b/docs/asset-credential/README.md @@ -0,0 +1,115 @@ +# 资产与凭证设计说明 + +> 更新:2026-07-05 +> 范围:服务器资产、云账号与云同步、域名/账单、资源模型、统一凭证、凭证授权、资产权限。// Logan.Li + +--- + +## 定位 + +资产与凭证是 AIOps 的权限和执行底座。SSH、RDP、Agent 安装、远程执行、AI Agent、云同步、Kubernetes 接入都依赖资产和凭证的可信建模。 + +一句话:**资产说明“管什么”,凭证说明“怎么安全地管”。** + +--- + +## 能力矩阵 + +| 能力 | 当前状态 | 说明 | +|------|----------|------| +| 服务器资产 CRUD | 已完成 | IP、端口、系统、区域、状态、监控字段 | +| 批量导入 | 已完成 | Excel/CSV 导入 | +| Agent 自动注册 | 已完成 | Agent 上报后按 hostname/IP 匹配或注册 | +| SSH/RDP 凭据 | 已完成 | 资产内凭据和统一凭证体系并存 | +| 统一凭证 | 已完成 | SSH、Docker、TLS、AKSK、DB、API、Generic | +| 凭证授权 | 已完成 | 用户/角色/资源授权 | +| 云账号 | 已完成 | 云平台、地域、账号、同步 | +| 域名/账单 | 已完成 | 云域名、账单、账单告警 | +| 资源模型 | 已完成 | 资产规格模型 | + +--- + +## 主要代码落点 + +| 模块 | 后端 | 前端 | +|------|------|------| +| 服务器资产 | `server*.go`、`model/server.go` | `frontend/src/components/server/` | +| 批量导入 | `server_batch.go`、`server_import_helpers.go` | `ExcelServerImport.tsx`、`CsvImportDialog.tsx` | +| 统一凭证 | `credential.go`、`credential_assign.go`、`model/credential.go` | `frontend/src/components/deploy/CredentialManager.tsx` | +| 云账号 | `cloud_accounts.go`、`cloud_sync.go` | `frontend/src/components/cloud/CloudAccountManager.tsx` | +| 云平台/地域 | `cloud_platform.go`、`cloud_region.go` | `PlatformManager.tsx`、`RegionManager.tsx` | +| 域名/账单 | `cloud_domain.go`、`billing*.go` | `DomainManager.tsx`、`CloudBilling.tsx` | +| 资源模型 | `resource_model.go` | 资产资源模型页面 | + +--- + +## 资产模型边界 + +服务器资产应包含: + +| 类别 | 字段 | +|------|------| +| 连接信息 | IP、端口、协议、用户名、认证类型 | +| 主机信息 | hostname、OS、kernel、CPU、内存、磁盘 | +| 状态信息 | running/offline、agent_status、agent_version | +| 组织信息 | 区域、环境、业务、负责人、标签 | +| 监控信息 | CPU/内存/磁盘实时使用率 | + +不建议把所有凭证明文塞进资产表。长期应以“资产引用凭证 ID”为主,资产内嵌凭据只作为历史兼容。 + +--- + +## 凭证模型 + +| 类型 | 用途 | +|------|------| +| SSH | WebSSH、远程执行、Agent 安装 | +| RDP | Windows 远程桌面 | +| Docker | 镜像仓库 | +| TLS | 证书和私钥 | +| AKSK | 云账号访问密钥 | +| DB | MySQL/PostgreSQL 等数据库 | +| API | HTTP API token | +| Generic | 其他密钥 | + +安全要求: + +- 敏感字段必须加密存储。 +- API 返回必须脱敏。 +- 凭证授权要按用户/角色/资产范围控制。 +- 使用凭证的操作必须进入审计。 + +--- + +## 与堡垒机关系 + +| 堡垒机能力 | 资产/凭证依赖 | +|------------|---------------| +| SSH 登录 | 服务器资产 + SSH 凭证 | +| RDP 登录 | Windows 资产 + RDP 凭证 | +| RDP 文件传输 | 会话策略 + 资产授权 | +| Agent CLI | 服务器资产 + SSH 凭证 + 用户权限 | +| 会话审计 | 资产 ID、资产名称、用户 ID | + +--- + +## 演进方向 + +| 优先级 | 方向 | 说明 | +|--------|------|------| +| P0 | 凭证引用统一 | SSH/RDP/远程执行尽量从统一凭证读取 | +| P0 | 资产标签和环境 | 为安全策略、告警、AI 上下文提供维度 | +| P1 | 凭证轮换 | 支持过期提醒、轮换记录、使用范围 | +| P1 | 权限矩阵 | 用户/角色/团队/资产/协议维度授权 | +| P2 | CMDB 同步 | 与云、K8s、Agent 自动发现形成统一资产视图 | + +--- + +## 验收清单 + +1. 创建服务器资产后可 SSH/RDP 连接。 +2. 批量导入资产能校验必填字段和重复项。 +3. Agent 上报能关联或注册资产。 +4. 凭证创建后 API 不返回明文密钥。 +5. 无权限用户不能使用未授权凭证。 +6. 使用凭证的远程操作能关联审计记录。 diff --git a/docs/automation-deploy/README.md b/docs/automation-deploy/README.md new file mode 100644 index 0000000..40aa1ee --- /dev/null +++ b/docs/automation-deploy/README.md @@ -0,0 +1,124 @@ +# 自动化运维与发布设计说明 + +> 更新:2026-07-05 +> 范围:脚本库、定时任务、远程执行、Agent 任务、流水线、发布管理、Workspace 终端、审批联动。// Logan.Li + +--- + +## 定位 + +自动化运维与发布模块负责把人工操作固化为可追踪、可复用、可审批的执行流程。它与堡垒机、资产凭证、AI Agent 关系紧密: + +- 堡垒机保障访问和审计。 +- 资产凭证提供执行目标。 +- 自动化沉淀操作流程。 +- AI Agent 可把成功排障过程转成脚本或 Runbook。 + +--- + +## 能力矩阵 + +| 能力 | 当前状态 | 说明 | +|------|----------|------| +| 脚本库 | 已完成 | 脚本 CRUD、查看、执行记录 | +| 定时任务 | 已完成 | Cron 表达式、启停、立即执行 | +| 远程执行 | 已完成 | SSH 执行、上传、结果查看 | +| Agent 任务 | 已完成 | Agent 轮询 command/script | +| Pipeline | 已完成 | 流水线定义、阶段、执行日志 | +| 发布管理 | 已完成 | 发布单、环境、审批、详情 | +| Workspace 文件 | 已完成 | 构建目录文件浏览 | +| Workspace 终端 | 已完成 | xterm 进入构建目录 | +| 工具配置 | 已完成 | kubectl、maven、node、helm 等工具 | + +--- + +## 主要代码落点 + +| 模块 | 后端 | 前端 | +|------|------|------| +| 脚本库 | `automation_script.go` | `ScriptManager.tsx` | +| 定时任务 | `automation_cron.go` | `CronJobManager.tsx` | +| 自动化总入口 | `automation.go` | `frontend/src/components/task/` | +| 远程执行 | `remote_exec.go` | `RemoteExecPage.tsx` | +| Agent 任务 | `agent_task.go` | Agent 监控相关页面 | +| Pipeline | `pipeline*.go` | `PipelineManager.tsx`、`PipelineEditor.tsx` | +| 发布 | `pipeline_release.go`、`deploy*.go` | `ReleaseManager.tsx`、`AppRelease.tsx` | +| Workspace | `workspace_files.go` | `FileBrowserDialog.tsx`、`WorkspaceTermDialog.tsx` | +| 审批 | `approval*.go` | `ApprovalFlow.tsx` | + +--- + +## 执行链路 + +```text +User + ├── choose script / task / pipeline / release + ├── select target assets or environment + ├── confirm / approval if needed + └── execute + │ + ▼ +Backend + ├── resolve credential and target + ├── run SSH / local tool / pipeline step / Agent task + ├── stream logs + ├── save execution record + └── write audit +``` + +--- + +## 日志与输出 + +| 类型 | 存储/通道 | 说明 | +|------|-----------|------| +| 脚本执行输出 | 任务执行记录 | 可展开查看 | +| 定时任务输出 | 执行历史 | 保留最近执行 | +| Pipeline 日志 | Redis pub/sub + DB 回查 | 实时推送和历史查看 | +| Workspace 终端 | xterm / PTY | 见终端体系 | +| Agent 任务结果 | `agent_tasks` | command/script 回传 | + +缓存策略见 [platform/redis-caching-strategy](../platform/redis-caching-strategy.md)。 + +--- + +## 安全与审批 + +高风险场景: + +- 批量主机执行。 +- 生产环境发布。 +- 删除文件、重启服务、修改配置。 +- kubectl apply/delete/scale。 +- 非工作时间变更。 + +建议策略: + +1. 执行前展示计划、目标和命令摘要。 +2. 生产环境强制审批。 +3. 执行输出写入审计。 +4. 支持失败重试和回滚说明。 +5. AI Agent 生成脚本只能保存草稿,用户确认后执行。 + +--- + +## 与 AI Agent 联动 + +| 场景 | 建议 | +|------|------| +| AI 排障成功 | 一键沉淀为脚本库草稿 | +| Pipeline 失败 | AI 读取日志并解释失败原因 | +| 定时任务失败 | AI 分析最近输出和目标状态 | +| 远程执行输出过长 | AI 调用 analyze_output 总结 | + +--- + +## 验收清单 + +1. 脚本能保存、执行、查看输出。 +2. 定时任务能启停、立即执行、记录历史。 +3. 远程执行能选择资产和凭证,输出完整。 +4. Pipeline 日志实时推送,刷新后可回看。 +5. 发布审批状态正确流转。 +6. Workspace 文件和终端不能路径逃逸。 +7. 高危操作能进入审批或确认流程。 diff --git a/docs/client-agent/README.md b/docs/client-agent/README.md new file mode 100644 index 0000000..2aebdb8 --- /dev/null +++ b/docs/client-agent/README.md @@ -0,0 +1,183 @@ +# AIOps 客户端 Agent 设计说明 + +> 更新:2026-07-05 +> 范围:`agent/` 独立 Go 模块、主机指标采集、HTTP 上报、远程任务轮询、安装升级。 +> 说明:本文档描述的是部署在被管主机上的 **客户端 Agent**,不要与 [AI Agent / Agent CLI](../ai-agent/00-README.md) 混淆。// Logan.Li + +--- + +## 文档阅读路径 + +| 文档 | 适合读者 | 内容 | +|------|----------|------| +| [README.md](./README.md) | 产品、运维、架构人员 | 客户端 Agent 的定位、能力、链路和文件清单 | +| [implementation-spec.md](./implementation-spec.md) | 后端、Agent、测试工程师 | 可复刻的采集器、上报协议、任务轮询、安装和验收规格 | + +--- + +## 定位 + +AIOps 客户端 Agent 是安装在 Linux 主机上的轻量采集代理,负责把主机运行状态周期性上报给 AIOps 后端,并支持后端下发少量远程任务。 + +一句话:**客户端 Agent 解决“主机实时状态从哪里来”的问题;AI Agent 解决“如何用大模型自动排障和执行任务”的问题。** + +| 维度 | 客户端 Agent | AI Agent / Agent CLI | +|------|--------------|----------------------| +| 目录 | `agent/` | `backend/pkg/agentcli/` + `frontend/src/components/aiops/` | +| 运行位置 | 被管主机 | AIOps 后端 | +| 核心职责 | 采集指标、上报心跳、轮询任务 | ReAct 推理、工具调用、AI 排障 | +| 数据来源 | `/proc`、`/sys`、netstat、健康探针 | LLM、SSH、K8s API、历史记忆 | +| 交互方式 | HTTP 定时上报 / 轮询 | WebSocket 对话流 | +| 风险边界 | 本机采集和任务执行 | AI 自动化执行和高危确认 | + +--- + +## 当前能力 + +| 能力 | 状态 | 说明 | +|------|------|------| +| CPU / Load | 已完成 | 使用率、IOWait、核心数、1/5/15 分钟负载 | +| 内存 / Swap | 已完成 | 总量、已用、可用、缓存、Swap 使用 | +| 磁盘 | 已完成 | 按挂载点上报容量、使用率、inode 使用率 | +| 磁盘 IO | 已完成 | 读写速率、IOPS | +| 网络 | 已完成 | TCP 状态、网卡流量 | +| 端口发现 | 已完成 | 监听端口、协议、进程、PID | +| 进程 TOP | 已完成 | 按 CPU 排序的 TOP N 进程 | +| FD / 上下文切换 | 已完成 | 文件句柄、ctx/irq 速率 | +| 系统信息 | 已完成 | OS、Kernel、Uptime | +| 健康探针 | 已完成 | HTTP/TCP/CMD 探针结果 | +| 心跳 | 已完成 | Agent 版本、运行状态、上报序号、自身 RSS | +| 任务轮询 | 已完成 | 后端 pending 任务拉取、执行、回传结果 | +| 一键安装 | 已完成 | systemd 服务、架构识别、安装/升级/卸载审计 | + +--- + +## 运行链路 + +```text +agent/main.go + ├── 解析参数:server / token / hostname / interval / top-n + ├── 初始化 collectors + ├── 初始化 HTTP client / reporter + ├── 启动 startup jitter + ├── ticker: collectAndReport() + └── ticker: poll tasks + +collectAndReport() + ├── collector.CPU / Mem / Disk / Network / Port / ... + ├── 组装 model.Report + ├── reporter.Send() + ▼ +POST /api/v1/agent/report + ├── 写入 agent_reports + ├── 同步 asset_servers 实时指标 + ├── 必要时自动注册资产 + └── WebSocket 广播前端指标更新 +``` + +任务链路: + +```text +Agent task client + ├── GET /api/v1/agent/tasks/poll?hostname= + ├── executor 执行 command/script + └── POST /api/v1/agent/tasks/:task_id/result +``` + +--- + +## 目录职责 + +| 路径 | 职责 | +|------|------| +| `agent/main.go` | CLI 参数、采集编排、上报循环、任务轮询 | +| `agent/collector/` | CPU、内存、磁盘、网络、端口、进程、FD、探针等采集器 | +| `agent/model/` | Agent 上报报文和各类指标结构 | +| `agent/client/` | 后端 HTTP 客户端,封装 baseURL、token、连接复用 | +| `agent/reporter/` | 上报器,负责序列化 `Report` 并 POST 后端 | +| `agent/task/` | 任务轮询客户端和本地命令执行器 | +| `agent/deploy/` | systemd 服务模板和安装脚本 | +| `backend/controller/agent.go` | 接收 Agent 上报、入库、更新资产、广播指标 | +| `backend/controller/agent_task.go` | Agent 任务创建、轮询、结果回传 | +| `backend/controller/agent_install.go` | 通过 SSH 安装/升级/卸载客户端 Agent | +| `frontend/src/components/agent/` | Agent 监控、告警、安装向导、主机详情 | + +--- + +## 上报数据流向 + +| 数据 | 后端处理 | +|------|----------| +| `agent_reports` 原始上报 | 保存历史指标,用于查询和趋势 | +| `asset_servers` 实时字段 | 更新 CPU/内存/磁盘/Agent 状态,资产页和监控页使用 | +| Redis 热数据 | 可选缓存最近指标,减少高频查询压力 | +| MetricsHub | WebSocket 广播给前端实时图表 | +| 告警引擎 | 基于最新指标触发 CPU/内存/磁盘/网络/离线告警 | + +--- + +## 与其他模块关系 + +| 模块 | 关系 | +|------|------| +| 资产管理 | Agent 自动注册主机,并同步实时指标到资产表 | +| 监控中心 | 主机监控大屏和图表主要消费 Agent 指标 | +| 告警中心 | 阈值告警、离线告警、端口/探针告警依赖 Agent 数据 | +| 自动化运维 | Agent 任务能力可承接轻量命令执行,但复杂远程执行仍建议走 SSH/脚本库 | +| 安全审计 | Agent 安装、卸载、升级写入操作审计 | +| AI Agent | AI Agent 可读取 Agent 指标作为排障上下文,但两者不是同一个进程 | + +--- + +## 与中间件监控的边界 + +Redis / MySQL 当前由 AIOps 后端中间件监控主动直连采集,不通过客户端 Agent。客户端 Agent 不登录 Redis/MySQL,不执行 `INFO`、`DBSIZE`、`SHOW GLOBAL STATUS`、`SHOW GLOBAL VARIABLES`,因此不会与中间件监控重复采集服务内部指标。// Logan.Li + +| 数据层级 | 采集方 | 示例指标 | 是否重复 | +|----------|--------|----------|----------| +| 主机资源 | 客户端 Agent | CPU、内存、磁盘、网络、FD、上下文切换 | 不重复 | +| 进程/端口存在性 | 客户端 Agent | `redis-server` 进程、6379 监听、`mysqld` 进程、3306 监听 | 与中间件可用性相邻,但语义不同 | +| 健康探针 | 客户端 Agent | TCP 6379/3306 连通性、HTTP/CMD 探针 | 可能与中间件在线状态有轻微重叠 | +| Redis 内部指标 | 后端中间件监控 | connected_clients、used_memory、qps、hit_rate、db_size、cluster_size | 不重复 | +| MySQL 内部指标 | 后端中间件监控 | Threads_connected、Threads_running、Slow_queries、QPS、buffer_hit_rate、max_connections | 不重复 | + +建议展示口径: + +- 资产/主机监控页展示 Agent 主机层指标。 +- 中间件监控页展示 Redis/MySQL 服务层指标。 +- 如果同一台主机既安装 Agent 又配置 Redis/MySQL 监控,前端可通过 IP/端口做关联展示,但不要把两套指标合并成同一类指标。 +- 告警规则应按 `host`、`middleware` 两类指标源区分,避免同一个“不可用”事件重复通知。// Logan.Li + +--- + +## 当前优化方向 + +| 优先级 | 方向 | 说明 | +|--------|------|------| +| P0 | Agent 鉴权增强 | 当前轮询/回传接口需要更严格的 Agent token 校验和主机绑定 | +| P0 | 任务安全边界 | command/script 任务应接入命令审计、超时、白名单或策略 | +| P1 | 指标保留策略 | 原始指标、聚合指标、冷热数据分层保存 | +| P1 | 安装包版本管理 | 前端展示当前版本、可用版本、升级结果和回滚建议 | +| P1 | 探针配置中心 | HTTP/TCP/CMD 探针从后端集中配置,Agent 只执行 | +| P2 | 多平台采集 | Windows/macOS 采集器独立实现,不污染 Linux `/proc` 采集逻辑 | +| P2 | 插件化采集器 | 中间件、本地进程、业务脚本指标可动态扩展 | + +--- + +## 验证清单 + +```bash +cd agent +go build -o aiops-agent . +./aiops-agent version +./aiops-agent output -top-n 5 +``` + +联调: + +1. 后端启动后,使用 `./aiops-agent -server http://localhost:8080 -token -interval 10`。 +2. `POST /api/v1/agent/report` 返回成功。 +3. `asset_servers` 对应主机 `agent_status=online`。 +4. 监控页面能看到 CPU、内存、磁盘、网络指标。 +5. 创建 Agent 任务后,Agent 能轮询、执行、回传结果。 +6. 停止 Agent 超过离线阈值后,后端能识别离线。 diff --git a/docs/client-agent/implementation-spec.md b/docs/client-agent/implementation-spec.md new file mode 100644 index 0000000..25c031c --- /dev/null +++ b/docs/client-agent/implementation-spec.md @@ -0,0 +1,339 @@ +# AIOps 客户端 Agent 实现规格 + +> 更新:2026-07-05 +> 目标:让另一个项目仅阅读本文档,也能实现一个轻量主机采集 Agent 和后端接收链路。 +> 范围:Linux 主机指标采集、HTTP 上报、后端入库、任务轮询、安装升级、验收标准。 + +--- + +## 1. 最小可用闭环 + +```text +Agent + ├── collect CPU / memory / disk + ├── build report JSON + └── POST /api/v1/agent/report + +Backend + ├── validate token + ├── insert agent_reports + ├── update asset_servers latest metrics + └── return { code: 200 } + +Frontend + └── query latest metrics and render host monitor +``` + +第一版必须完成: + +| 能力 | 验收 | +|------|------| +| Agent 能启动 | `version` 和 `output` 正常 | +| 指标能上报 | 后端收到并入库 | +| 资产能关联 | hostname/IP 匹配到资产,未匹配时可自动注册 | +| 前端能展示 | 主机监控页面看到实时指标 | +| 离线能识别 | 超过阈值无上报后标记 offline | + +--- + +## 2. Agent 命令行契约 + +```bash +aiops-agent version +aiops-agent output -top-n 5 +aiops-agent -server http://aiops.example.com -token -hostname -interval 30 +``` + +参数: + +| 参数 | 默认 | 说明 | +|------|------|------| +| `-server` | 空 | 后端地址,run 模式必填 | +| `-token` | 空 | Agent 鉴权 token | +| `-hostname` | `os.Hostname()` | 主机标识,建议与资产 hostname 一致 | +| `-interval` | `10` | 采集间隔,生产建议 30-60 秒 | +| `-top-n` | `3` | 进程 TOP 数量 | + +要求: + +- `interval < 5` 应拒绝启动,避免高频上报压垮后端。 +- 启动时加入随机 jitter,防止大量 Agent 同时上报。 +- `output` 模式不需要 `server/token`,用于安装排障。 + +--- + +## 3. 上报协议 + +### 3.1 请求 + +```http +POST /api/v1/agent/report +Authorization: Bearer +Content-Type: application/json +``` + +核心 JSON: + +```json +{ + "hostname": "app-01", + "ip": "10.0.0.11", + "timestamp": "2026-07-05T10:00:00Z", + "agent_version": "v2.1.0", + "cpu": { + "usage_percent": 12.3, + "iowait": 0.4, + "core_count": 8, + "load1": 0.8, + "load5": 0.7, + "load15": 0.6 + }, + "memory": { + "total_gb": 31.4, + "used_gb": 12.0, + "available_gb": 18.2, + "cached_gb": 4.1, + "swap_used_gb": 0.0, + "usage_percent": 38.2 + }, + "disks": [ + { + "mount": "/", + "device": "/dev/vda1", + "total_gb": 100, + "used_gb": 42, + "usage_percent": 42, + "inode_usage_pct": 11 + } + ], + "ports": [ + { "port": 8080, "protocol": "tcp", "process": "aiops", "pid": 1234 } + ], + "network": {}, + "disk_io": {}, + "process_top": [], + "fd": {}, + "context_switches": {}, + "heartbeat": { + "version": "v2.1.0", + "uptime_sec": 3600, + "self_rss_mb": 18.5, + "report_seq": 100 + } +} +``` + +### 3.2 响应 + +```json +{ "code": 200, "message": "ok" } +``` + +错误: + +| HTTP | 场景 | +|------|------| +| 400 | JSON 格式错误或必填字段缺失 | +| 401 | token 无效 | +| 500 | 入库失败 | + +--- + +## 4. 采集器规格 + +| 采集器 | Linux 来源 | 输出 | +|--------|------------|------| +| CPU | `/proc/stat`, `/proc/loadavg` | 使用率、IOWait、核心数、Load | +| Memory | `/proc/meminfo` | total/used/available/cached/swap | +| Disk | `statfs`, mount table | 挂载点容量、inode | +| Disk IO | `/proc/diskstats` | read/write bps、IOPS | +| Network | `/proc/net/dev`, `/proc/net/tcp*` | rx/tx、TCP 状态 | +| Port | `/proc/net/tcp*` + `/proc//fd` | 监听端口和进程 | +| ProcessTop | `/proc//stat`, `status`, `cmdline` | top N 进程 | +| FD | `/proc/sys/fs/file-nr` | allocated/max/usage | +| Context | `/proc/stat` | ctxt / intr 速率 | +| System | `/etc/os-release`, `uname`, `/proc/uptime` | OS/Kernel/Uptime | +| HealthProbe | 后端配置 | HTTP/TCP/CMD 探测结果 | + +实现原则: + +- 单个采集器失败不能影响整次上报。 +- 采集错误写本地日志,不把错误堆进上报报文。 +- 网络/磁盘速率类指标需要保存上一轮样本计算差值。 +- 不要依赖 shell 命令做核心采集,优先读 `/proc` 和 Go 标准库。 + +--- + +## 5. 后端处理规格 + +```text +ReceiveReport(req) + ├── parse timestamp + ├── marshal complex fields to JSON string + ├── insert AgentReport + ├── match asset by hostname or IP + ├── update latest metrics on asset_servers + ├── if no match, auto register server + ├── cache latest metrics in Redis if available + ├── broadcast MetricsHub + └── return success +``` + +资产匹配优先级: + +1. Agent 上报 `hostname`。 +2. Agent 上报 `ip`。 +3. 请求来源 IP。 +4. 必要时自动注册新资产。 + +离线判断: + +```text +latest_report_time < now - offline_threshold + → agent_status = offline +``` + +建议阈值: + +| interval | offline threshold | +|----------|-------------------| +| 10s | 60s | +| 30s | 180s | +| 60s | 300s | + +--- + +## 6. 任务轮询 + +### 6.1 Agent 拉取任务 + +```http +GET /api/v1/agent/tasks/poll?hostname=app-01 +Authorization: Bearer +``` + +响应: + +```json +{ + "code": 200, + "data": [ + { + "task_id": "uuid", + "hostname": "app-01", + "type": "command", + "command": "df -h", + "timeout": 60 + } + ] +} +``` + +后端规则: + +- 只返回 `pending` 任务。 +- 按创建时间升序。 +- 拉取后原子更新为 `running`。 +- 单次最多返回 5 个。 + +### 6.2 Agent 回传结果 + +```http +POST /api/v1/agent/tasks/:task_id/result +``` + +```json +{ + "exit_code": 0, + "result": "Filesystem ...", + "status": "success" +} +``` + +任务安全要求: + +- 命令必须有超时。 +- 输出必须截断,避免后端大字段膨胀。 +- 任务执行应记录操作者、创建时间、开始时间、结束时间。 +- 生产环境应接入命令策略或审批,尤其是 `script` 类型。 + +--- + +## 7. 安装和升级 + +安装链路: + +```text +Frontend AgentWizard + → backend AgentInstallHandler + → SSH to target server + → download aiops-agent binary / install.sh + → write systemd unit + → systemctl enable --now aiops-agent + → detect version + → write audit log +``` + +systemd 服务要求: + +```ini +[Service] +ExecStart=/usr/local/bin/aiops-agent -server -token -interval 30 +Restart=always +RestartSec=5 +``` + +安装验收: + +1. `/usr/local/bin/aiops-agent version` 正常。 +2. `systemctl status aiops-agent` active。 +3. 后端 1 分钟内收到上报。 +4. 资产 `agent_version` 更新。 +5. 安装审计记录成功或失败原因。 + +--- + +## 8. 安全要求 + +| 风险 | 要求 | +|------|------| +| token 泄漏 | token 不写命令审计、不在前端明文展示 | +| 伪造上报 | token 必须校验,建议绑定 hostname/server_id | +| 命令任务滥用 | command/script 任务接入策略、审计、超时、输出截断 | +| 自动注册污染资产 | 自动注册要有去重策略和可信来源限制 | +| 高并发上报 | Agent jitter + 后端批量/缓存/限流 | +| 采集权限过大 | Agent 默认不需要 root;端口进程归属等能力可降级 | + +--- + +## 9. 与 Redis/MySQL 主动采集的边界 + +客户端 Agent 第一职责是主机 OS 层采集,Redis/MySQL 第一职责是服务内部指标采集。即使两者部署在同一台机器,也应保持两条链路独立。// Logan.Li + +| 能力 | 客户端 Agent | 后端中间件监控 | +|------|--------------|----------------| +| 发现 3306/6379 端口 | 可以,通过端口采集器 | 不负责端口发现 | +| 判断 TCP 是否可连 | 可以,通过健康探针 | 可以,连接失败则目标 offline | +| Redis INFO / DBSIZE | 不做 | 必须做 | +| MySQL SHOW GLOBAL STATUS | 不做 | 必须做 | +| 主机 CPU/内存/磁盘 | 必须做 | 不做 | +| 服务级 QPS/连接数/命中率 | 不做 | 必须做 | + +如果要把 Redis/MySQL 指标下沉到 Agent,需要新增独立的 `middleware` 采集器和权限配置;当前设计不建议这么做,因为后端主动采集更便于集中管理凭据、采集间隔和告警策略。 + +--- + +## 10. 验收矩阵 + +| 场景 | 预期 | +|------|------| +| `output` 调试 | 打印完整指标,不访问后端 | +| 正常上报 | 后端写入 `agent_reports` | +| 资产匹配 | `asset_servers` 实时指标更新 | +| 自动注册 | 新 hostname 创建资产 | +| 网络断开 | Agent 记录失败并下轮重试 | +| 后端重启 | Agent 自动恢复上报 | +| 任务执行成功 | 状态 `success`,保存输出 | +| 任务超时 | 状态 `timeout`,进程被终止 | +| Agent 停止 | 超过阈值后标记 offline | +| 一键安装 | systemd 服务 active,审计可查 | diff --git a/docs/dev-log.md b/docs/dev-log.md deleted file mode 100644 index 8795996..0000000 --- a/docs/dev-log.md +++ /dev/null @@ -1,1926 +0,0 @@ -# AIOps 开发日志 - -## 2026-07-03 Web RDP 自研图形引擎(移除 grdp) - -### 完成项 - -| 项目 | 状态 | 说明 | -|------|------|------| -| 自研 SessionEngine | ✅ | `NewEngine()` → `SessionEngine`,完整 MCS + 位图 + 键鼠 | -| 移除 nakagami/grdp | ✅ | `go.mod` 无 grdp;`Dockerfile` 去掉 `third_party/` | -| 位图解压 | ✅ | `rle/` RLE + `mppc.go` MPPC + `bitmap_decode.go` PNG 编码 | -| FastPath 收发 | ✅ | `packet_io.go`、`fastpath_recv.go`、`input_fastpath.go` | -| 会话激活 | ✅ | FontMap 后 `SuppressOutput(ALLOW_DISPLAY_UPDATES)` | -| 文件传输 | ✅ | SMB 直传 + `rdpfilestage` 暂存 + `auto_exec` 回退 | -| 第三方备份 | ✅ | `/opt/rdp/third-party/`(grdp v0.8.7 + 参考实现) | -| 文档 | ✅ | `docs/rdp.md` + `/opt/rdp/CHANGELOG.md` | - -### 新增 / 变更文件(`backend/pkg/rdp/`) - -- `session_engine.go`、`session_connect.go`、`packet_io.go` -- `fastpath_recv.go`、`bitmap_decode.go`、`input_fastpath.go`、`mppc.go` -- `rle/`(`util.go`、`bpp1~4.go`、`entry.go`) -- `engine.go`:`NewEngine()` 指向 `SessionEngine` -- `transport/tpkt.go`:新增 `WriteRaw` - -### 编译与部署 - -```bash -cd backend && CGO_ENABLED=1 go build . -./run.sh docker up # 已验证镜像构建通过 -``` - -### 实测备注 - -- CredSSP/NLA 对 `192.168.14.172:3389` 稳定通过(~7ms) -- MCS 握手在频繁探测时偶发 `connection reset`(目标机限流/策略) -- 详细协议说明见 [docs/rdp.md](./rdp.md) - ---- - -## 2026-07-01 文档同步 + PageShell 设计系统 - -### 完成项 - -| 项目 | 状态 | 说明 | -|------|------|------| -| PageShell 设计系统 | ✅ | 全站统一页面骨架,`dg-page*` CSS 变量 + 7 套主题 | -| Agent CLI 优化 | ✅ | 终端审计回放、ssh-key 连接修复、会话管理增强 | -| 国际化 (i18n) | ✅ | dashboard / server / alert / approval / audit / deploy / k8s 模块迁移 | -| 中间件监控 | ✅ | Redis / MySQL 后端直连采集引擎 | -| 日志告警引擎 | ✅ | AlertEngine 支持 type=log,查询 Loki / ES 触发/恢复 | -| 远程执行页 | ✅ | 执行/上传功能,移除 ansible 命名 | -| 大模型双协议 | ✅ | OpenAI 兼容 + Anthropic Messages 协议 | -| 前端架构对齐 | ✅ | api/ + pages/ 按菜单域拆分 | -| 文档更新 | ✅ | README + docs/ 同步至当前代码状态 | - ---- - -## 2026-06-13 项目初始化 - -### 完成项 - -| 项目 | 状态 | 说明 | -|------|------|------| -| 前端 (React+TS+Vite) | ✅ | React 18 + TypeScript + Tailwind CSS + shadcn/ui | -| 后端 (Go+Gin+GORM) | ✅ | Go + Gin + GORM + JWT + SQLite | -| 数据库 (SQLite) | ✅ | sqlite3.db,CGO 编译 | -| run.sh 启动脚本 | ✅ | 一键 start/stop/restart/status/logs | -| Vite Proxy | ✅ | /api → localhost:8080 | -| docs 文档 | ✅ | 项目结构/API/部署/开发指南 | - -### 编译修复记录 - -| 文件 | 问题 | 修复 | -|------|------|------| -| `auth.go` | jwt/v5 移除了 `TimeFunc()` | 改用 `time.Now()` | -| `mock.go` | 匹配实际 model 定义 | 完全重写 | -| `audit.go` | 编译错误 | 修复字段引用 | -| `database.go` | 不支持 SQLite | 添加 `gorm.io/driver/sqlite` | -| `config.go` | DSN 中 Port 类型转换错误 | 在 database.go 用 `fmt.Sprintf("%d", port)` | - -### 技术栈 - -**后端** -- Go 1.21+ / Gin / GORM -- SQLite (开发环境默认) / MySQL / PostgreSQL (生产可选) -- JWT 认证 / Zap 日志 - -**前端** -- React 18 + TypeScript + Vite -- Tailwind CSS + shadcn/ui 组件库 -- Zustand 状态管理 - -### 端口分配 - -| 服务 | 端口 | -|------|------| -| 后端 API | 8080 | -| 前端 Dev Server | 5173 | - -### 快速启动 - -```bash -cd ~/devops/aiops -./run.sh start # 启动前后端 -./run.sh status # 查看状态 -./run.sh stop # 停止 -./run.sh restart # 重启 -./run.sh logs # 实时日志 -``` - -访问地址: -- 前端: http://localhost:5173 -- 后端: http://localhost:8080 -- 健康检查: http://localhost:8080/health - -### 数据库 - -开发环境使用 SQLite,数据库文件位于 `backend/sqlite3.db`,首次启动时 GORM AutoMigrate 自动建表。 - -## 2026-06-14 02:30 自动推进 - -- **任务**: 修复仪表盘数据管线 — 补充应用发布种子数据 + 修复 limit 参数解析 bug + LDAP 认证集成 + User 类型完善 -- **修改**: - - `backend/internal/api/handlers/dashboard.go` — 修复 GetRecentAlerts 的 limit 参数解析(原代码错误地将数字 limit 解析为日期字符串 `time.Parse("2006-01-02")`),为 GetRecentReleases 添加 limit 参数支持 - - `backend/scripts/mock.go` — 新增 generateAppReleases() 生成 10 条应用发布种子数据;修复 AlertEvent 的 rule_id/rule_name/severity 数据一致性(原代码从不同随机规则分别取 id 和 name 导致不匹配) - - `backend/go.mod` / `go.sum` — 添加 github.com/go-ldap/ldap/v3 v3.4.10 依赖(GOTOOLCHAIN=local 避免 Go 1.24+ 工具链升级) - - `backend/cmd/seed.go` — 种子数据初始化(admin 用户 + LDAP 配置,仅首次启动) - - `backend/internal/api/handlers/ldap.go` — LDAP 认证处理器 - - `backend/internal/models/user.go` — User model 添加 AuthSource 字段 + LDAPConfig model - - `frontend/src/types/index.ts` — 修复 User 类型定义,添加 real_name/is_admin/auth_source 等字段以匹配后端 API - - 多个前端组件联调优化(暗色主题、API 服务层、组件数据对接) -- **结果**: - - `go build ./...` 编译通过 ✅ - - `npm run build` 构建通过 ✅ - - 仪表盘「发布历史」区域从空白变为展示 5 条真实发布记录 ✅ - - `?limit=N` 参数现在被正确解析 ✅ - - 健康检查 /health 正常 ✅ - - git push origin master 成功 (commit ff0b268) ✅ -- **下一步建议**: - - 服务器管理模块的增删改查前后端联调验证 - - K8s 集群监控数据展示(Pod/Deployment/Service 列表页面) - - 告警规则配置页面前端完善 - - 成本统计从硬编码改为基于实际云账号数据计算 - -## 2026-06-14 03:10 自动推进 - -- **任务**: 完善告警规则配置页面 — 实现增删改查弹窗 + 修复白屏bug + 后端UpdateRule序列化修复 -- **修改**: - - `frontend/src/components/monitor/AlertManager.tsx` — 新建/编辑告警规则弹窗(规则名称/描述/类型/数据源/触发表达式/告警级别/持续时间/通知渠道Switch选择); 修复白屏bug(getRules返回{list,total}未解包导致rules.map崩溃); 修复规则列表字段显示(原引用不存在的rule.metric/operator/threshold改为rule.source/expression); 新增规则删除按钮(带确认); 新增parseChannels()统一解析通知渠道 - - `backend/internal/api/handlers/alert.go` — 修复UpdateRule的notify_channels序列化bug(原直接赋值[]string到text列导致500错误,改用toJSON()序列化) -- **结果**: - - `go build ./...` 编译通过 ✅ - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - 告警页面白屏修复 — 页面正常加载,展示6条规则/20条告警事件 ✅ - - 创建规则弹窗UI验证通过 — 通过浏览器填表提交,规则数从6增至7 ✅ - - CRUD API端到端验证: create→update→delete 全部200 ✅ - - git push origin master 成功 (commit 00d5510) ✅ -- **下一步建议**: - - K8s 集群监控数据展示(Pod/Deployment/Service 列表页面前后端联调) - - 服务器管理模块增删改查前后端联调验证 - - 成本统计从硬编码改为基于实际云账号数据计算 - - 告警"通知配置"按钮功能实现(通知渠道管理弹窗) - -## 2026-06-14 04:00 自动推进 - -- **任务**: K8s集群监控数据展示 — 修复集群详情标签数据加载(数组响应未解包bug) + 补充Pod/Deployment/Service/Ingress种子数据 + deployment_count字段 -- **修改**: - - `frontend/src/components/k8s/K8sClusters.tsx` — **核心bug修复**: loadClusterDetail 中 Promise.all 的 catch fallback 返回 `{list:[]}` 但后端 ListNodes/ListPods/ListDeployments/ListServices/ListIngresses 返回 data 直接为数组(非{list,total}), 导致 `(res)?.list` 永远 undefined → 所有标签页空白; 改用 `toArray(v)` 兼容数组和对象响应; 修复 Pod 表格字段名(restarts→restart_count, cpu→cpu_usage, memory→memory_usage, node→node_name); 修复状态徽章大小写比较(status='Running' vs 'running') - - `backend/internal/models/k8s.go` — K8sCluster model 添加 DeploymentCount 字段(前端引用 cluster.deployment_count 但 model 缺失) - - `backend/scripts/mock.go` — generateK8sClusters 补充生成 Deployment/Pod/Service/Ingress 种子数据(原仅生成 Cluster+Node) - - `backend/scripts/seed_k8s_resources.go` — 新增独立种子脚本, 为已有集群补充 K8s 资源数据(幂等, 跳过已有 Pod 的集群) -- **结果**: - - `go build ./...` 编译通过 ✅ - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - 种子数据写入: 4集群 → 20节点 + 80 Pod + 41 Deployment + 29 Service + 14 Ingress ✅ - - API 端到端验证: `/k8s-clusters/1/pods` 返回21条Pod数据, 字段名正确(restart_count/node_name等) ✅ - - 浏览器验证: 集群卡片显示4集群(含deployment_count), 集群详情标签页计数正确: 节点(10)/Pod(21)/Deployment(6)/Service(8)/Ingress(4) ✅ - - 无 console 错误 ✅ - - git push origin master 成功 (commit 8d47f8d) ✅ -- **下一步建议**: - - 服务器管理模块增删改查前后端联调验证 - - 成本统计从硬编码改为基于实际云账号数据计算 - - K8s 集群拓扑图(TopologyView)前后端数据联调 - - 告警"通知配置"按钮功能实现(通知渠道管理弹窗) - -## 2026-06-14 05:15 自动推进 - -- **任务**: 服务器管理模块前后端字段对齐 — 修复前端表单使用后端不支持的CMDB幽灵字段导致数据丢失 + 内存单位 GB→MB 转换 + 表格列重构 -- **修改**: - - `backend/internal/models/server.go` — CreateServerRequest 添加 Status 字段(原硬编码 "stopped") - - `backend/internal/api/handlers/server.go` — CreateServer handler 改用 req.Status 替代硬编码值 - - `frontend/src/components/server/ServerManager.tsx` — 完全重写表单/表格/过滤器: - - 移除后端不支持的幽灵字段:asset_type/connect_type/floatingip/instance_id/expire_time/remark/region/region_id - - 表单改为SSH连接导向:主机名/IP/公网IP/SSH端口/登录用户/认证方式(密码/密钥)/密码 + 可折叠详情区(OS/状态/CPU/内存/磁盘/标签/描述) - - 内存字段前端输入GB,提交时 ×1024 转换为MB(后端存储MB) - - 表格列从11列精简到8列,新增公网IP列,移除资产类型/连接方式/区域列 - - 移除不存在的区域筛选器(servers 无 region_id 数据) - - 移除未实现的"查看详情"下拉项(无详情页) -- **结果**: - - `go build -o aiops-server cmd/main.go` 编译通过 ✅ - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - API端到端验证: POST /servers → Code 200,hostname/ip/public_ip/port/username/auth_type/memory(16384MB=16GB)/status/tags 全部正确保存 ✅ - - API端到端验证: PUT /servers/:id → Code 200,更新成功 ✅ - - API端到端验证: DELETE /servers/:id → Code 200 "删除成功" ✅ - - 浏览器UI验证: 服务器列表页正常渲染,内存显示为GB(128GB而非131072MB),公网IP列正确显示 ✅ - - 表单UI验证: 添加服务器弹窗字段正确,验证逻辑正常(hostname+ip必填时按钮启用)✅ - - 服务运行中: 后端:8080 + 前端:5173 ✅ - -## 2026-06-14 06:30 自动推进 - -- **任务**: 仪表盘成本统计从硬编码改为基于实际云账号月费用计算 — 新增 CloudAccount.MonthlyCost 字段,dashboard 接口实时聚合 -- **修改**: - - `backend/internal/models/server.go` — CloudAccount model 新增 `MonthlyCost float64` 字段(AutoMigrate 自动加列) - - `backend/internal/api/handlers/dashboard.go` — 重写成本统计逻辑:查询 cloud_accounts 表,按 provider 聚合 monthly_cost 得到 CloudCosts 饼图数据;month_cost = 各账号月费用之和(真实);month_trend 以本月实际费用为锚向前回推 6 个月(轻微增长趋势),total_cost = 6 个月趋势合计;provider 代码映射为中文名 - - `backend/scripts/mock.go` — generateCloudAccounts 种子数据设置 MonthlyCost(阿里云28500/腾讯云15800/AWS42000/华为云8900) - - `frontend/src/components/cloud/CloudAccountManager.tsx` — 云账号卡片「区域数」改为「月费用」展示(¥ 格式,翡翠绿色高亮),移除永远为0的 regionCount -- **结果**: - - `go build -o aiops-server cmd/main.go` 编译通过 ✅ - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - API 验证: `/dashboard/stats` cost_stats.month_cost=95200 (=28500+15800+42000+8900 真实求和), cloud_costs 4 条真实平台数据, month_trend 6个月(1月~6月)当前月=95200 ✅ - - 已有 DB 4 个账号 backfill monthly_cost(AutoMigrate 加列后 SQL UPDATE)✅ - - 浏览器验证: 仪表盘「本月云成本」显示 ¥95,200(真实计算值)✅ - - 健康检查 /health 正常 ✅ -- **下一步建议**: - - ~~云账号「添加账号」按钮弹窗表单实现(当前点击无响应,无法录入月费用)~~ ✅ 已完成 - - K8s 集群拓扑图(TopologyView)前后端数据联调 - - 告警「通知配置」按钮功能实现(通知渠道管理弹窗) - - 仪表盘「+12.5%」等增长率标签从硬编码改为基于 month_trend 真实计算 - - 云账号「编辑」功能(需后端新增 PUT /cloud-accounts/:id 端点) - -## 2026-06-14 07:10 自动推进 - -- **任务**: 云账号创建表单实现 — 修复后端创建接口无法接收凭证的 bug + 前端添加账号弹窗表单 -- **修改**: - - `backend/internal/api/handlers/server.go` — 重构 `CreateCloudAccount`:新增独立的 `createCloudAccountRequest` struct(遵循 Go Pitfall #8)。原代码直接绑定 `models.CloudAccount`,但 AccessKey/SecretKey 字段标记 `json:"-"` 会导致反序列化时被跳过,凭证永远为空。现用独立 request struct 接收后映射到 model;新增 name/provider 非空校验,status 默认 active;补充 `strings` 导入 - - `frontend/src/components/cloud/CloudAccountManager.tsx` — 「添加账号」按钮接入创建表单 Dialog(账号名称/云平台/AccessKey/SecretKey/月费用/状态),提交调用 cloudApi.createAccount 后刷新列表;移除无 handler 的刷新/编辑按钮(编辑需后端新增 PUT 端点,留待后续);按钮 disabled 校验(名称必填) -- **结果**: - - `go build ./cmd/` 编译通过 ✅ - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - API 验证: POST /cloud-accounts → Code 200,DB 验证 access_key/secret_key 正确持久化(原 bug 下为空字符串)✅ - - 联动验证: 新增账号后 dashboard month_cost 实时更新(95200→100200)✅ - - 浏览器 UI 验证: 添加账号弹窗正常打开、字段填写、提交后列表刷新显示新账号 ✅ - - 服务运行中: 后端:8080 + 前端:5173 ✅ - -## 2026-06-14 08:15 自动推进 - -- **任务**: 云账号编辑功能 + 修复 scripts/mock.go 编译错误(generateNotifyChannels 未定义)+ 提交前序未入库的告警通知渠道 CRUD -- **修改**: - - `backend/scripts/mock.go` — **编译错误修复**: 补充缺失的 `generateNotifyChannels` 函数(5种渠道种子:邮件/短信/钉钉/企业微信/Webhook),解决 `go build ./...` 失败 - - `backend/internal/api/handlers/server.go` — 新增 `UpdateCloudAccount` handler:查询现有记录→仅在提供非空值时更新凭证字段(AccessKey/SecretKey 留空保持不变)→db.Save - - `backend/internal/api/routes/routes.go` — 注册 `PUT /cloud-accounts/:id` 路由 - - `frontend/src/services/api.ts` — cloudApi 新增 `updateAccount(id, data)` 方法 - - `frontend/src/components/cloud/CloudAccountManager.tsx` — 编辑功能:添加 editingAccount 状态 + handleOpenEdit 预填表单 + handleSubmit 统一处理创建/编辑 + 卡片新增编辑按钮(Pencil图标) + Dialog 标题/描述/按钮文案动态切换 + AccessKey/SecretKey 标签"留空保持不变"提示 - - 包含前序未提交的变更(已验证编译通过):告警通知渠道 CRUD 后端API(alert.go handlers + models)、AlertManager 通知配置前端UI、AlertCenter 通知渠道管理弹窗 -- **结果**: - - `go build ./...` 全包编译通过(含 scripts/)✅ - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - API 验证: PUT /cloud-accounts/1 → Code 200 "更新成功",monthly_cost 28500→30000 ✅ - - 凭证安全验证: 更新请求不含 access_key/secret_key,DB 中原凭证 LTAIDuBzWq... / pQyacN59tQ... 保持不变 ✅ - - 浏览器 UI 验证: 编辑弹窗正常打开,4个字段预填正确,标题显示"编辑云账号",凭证字段显示"留空保持不变"提示 ✅ - - git push origin master 成功 (commit af2e60a) ✅ -- **下一步建议**: - - K8s 集群拓扑图(TopologyView)前后端数据联调 - - 仪表盘「+12.5%」等增长率标签从硬编码改为基于 month_trend 真实计算 - - 域名管理编辑功能(当前仅有 GET/POST/DELETE,缺 PUT 端点,与云账号编辑同理) - - 告警规则关联通知渠道 — 规则创建时可选择已配置的通知渠道 - -## 2026-06-14 09:10 自动推进 - -- **任务**: 域名管理增删改查完整实现 — 新增 PUT /domains/:id 编辑端点 + 创建/编辑弹窗 + 日期字段解析修复 -- **修改**: - - `backend/internal/api/handlers/server.go` — 新增 `updateDomainRequest` 请求结构体(日期字段用 string 接收避免 RFC3339 解析失败)+ `parseDateStr()` 工具函数(YYYY-MM-DD → *time.Time)+ `UpdateDomain` handler(查询记录→按字段更新→db.Save);重构 `CreateDomain` 改用 request struct + parseDateStr(原直接绑定 models.Domain 导致 HTML date input 的 YYYY-MM-DD 格式无法解析为 *time.Time,创建含日期的域名报 400) - - `backend/internal/api/routes/routes.go` — 注册 `PUT /domains/:id` 路由 - - `frontend/src/services/api.ts` — cloudApi 新增 `updateDomain(id, data)` 方法 - - `frontend/src/components/cloud/DomainManager.tsx` — 添加域名弹窗 + 编辑弹窗(域名/注册商/状态/到期时间/DNS服务商/解析记录数/SSL启用+到期时间);`handleOpenCreate` / `handleOpenEdit`(预填数据,日期截取 YYYY-MM-DD)/ `handleSubmit`(统一创建/编辑提交);卡片 Edit2 按钮接入编辑;移除无 handler 的 ExternalLink 按钮 -- **结果**: - - `go build ./...` 全包编译通过 ✅ - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - API 验证: PUT /domains/1 → Code 200 "更新成功",registrar(aliyun→Cloudflare)、dns_provider(alidns→cloudflare)、record_count(0→12)、ssl_enabled(false→true)、expire_date(2028-03-15) 全部正确持久化 ✅ - - API 验证: POST /domains → Code 200 "创建成功",日期字段正确解析(原 bug: 直接绑定 model 导致 RFC3339 解析失败返回 400)✅ - - API 验证: DELETE /domains/5 → Code 200 "删除成功" ✅ - - 浏览器 UI 验证: 创建弹窗所有字段正常渲染(域名/注册商/状态/到期日期选择器/DNS/记录数/SSL开关+日期选择器联动禁用)✅ - - 浏览器 UI 验证: 编辑弹窗预填数据正确(ezops.local/Cloudflare/2028-03-15/cloudflare/12条/SSL已启用)✅ - - git push origin master 成功 (commit b110d38) ✅ -- **下一步建议**: - - K8s 集群拓扑图(TopologyView)前后端数据联调 - - 仪表盘「+12.5%」等增长率标签从硬编码改为基于 month_trend 真实计算 - - 告警规则关联通知渠道 — 规则创建时可选择已配置的通知渠道 - - 域名列表到期时间显示格式优化(当前显示完整 RFC3339,可截取 YYYY-MM-DD) - -## 2026-06-14 10:15 自动推进 - -- **任务**: 仪表盘云成本增长率标签从硬编码改为基于 month_trend 真实计算 — 消除虚假的「+12.5%」展示 -- **修改**: - - `frontend/src/components/dashboard/Dashboard.tsx` — 新增 `computeMonthChange(monthTrend)` 工具函数:基于 month_trend 最后两个月(本月 vs 上月)计算环比百分比,返回 `{pct, label, direction}`;direction 区分 up/down/neutral 并驱动颜色(涨=emerald/跌=red/持平=slate) - - CostOverviewCard 卡片头:将硬编码 `+12.5% 较上月` 替换为 `costChange.label`(动态标签 + 颜色) - - 顶部「本月云成本」StatCard:description/trend/trendValue 三处全部改为动态计算(原硬编码 trend="up" trendValue="+12.5%" description="较上月 +12.5%") -- **结果**: - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - 浏览器验证: 顶部 StatCard 显示 `¥96,700 → +3.1%`(原 +12.5%)✅ - - 浏览器验证: 云成本概览卡片头显示 `¥536,685 → +3.1% 较上月`(原 +12.5% 较上月)✅ - - 计算依据: month_trend 5月=93799, 6月=96700 → (96700-93799)/93799×100 ≈ +3.1% ✅ - - 无 console 错误 ✅ - - 后端/前端服务运行中: 8080 + 5173 ✅ -- **下一步建议**: - - 服务器「+3 本月」/ 告警「-5 本周」增长率标签仍为硬编码(需后端新增历史快照数据才能真实计算,属较大改动) - - K8s 集群拓扑图(TopologyView)前后端数据联调 - - 告警规则关联通知渠道 — 规则创建时可选择已配置的通知渠道 - - 域名列表到期时间显示格式优化(截取 YYYY-MM-DD) - -## 2026-06-14 11:15 自动推进 - -- **任务**: 流水线运行历史前后端联调 — 详情弹窗"历史记录"标签从静态占位符改为展示真实 PipelineRun 数据 -- **修改**: - - `frontend/src/components/deploy/PipelineManager.tsx`: - - 新增 `pipelineRuns` / `loadingRuns` 状态管理 - - 新增 `loadPipelineRuns(pipelineId)` 函数调用 `pipelineApi.getRuns(id)` 拉取运行记录 - - 新增 `handleOpenDetail(pipeline)` 统一处理详情弹窗打开 + 自动加载运行记录 - - "历史记录"标签显示运行次数计数(如 "历史记录 (6)") - - 历史记录标签内容从静态占位符替换为运行记录列表:每条记录展示构建号(#N)、状态徽章(成功/失败/运行中/待执行)、分支、Commit ID(截取7位)、触发者、耗时(自动格式化s/m)、创建时间 - - 触发流水线后若详情弹窗已打开则自动刷新运行记录列表 - - 空状态提示"暂无执行记录" + 加载中 Spinner -- **结果**: - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - API 验证: GET /pipelines/1/runs 返回 5 条种子运行记录(成功/失败/运行中多种状态) ✅ - - API 验证: POST /pipelines/1/trigger 创建 build #6 status=pending ✅ - - 浏览器验证: 流水线详情弹窗"历史记录 (6)"标签正确展示 6 条运行记录,含构建号/状态徽章/分支/Commit/触发者/时间戳 ✅ - - 无 console 错误 ✅ - - git push origin master 成功 (commit 6012418) ✅ -- **下一步建议**: - - 流水线创建时将模板 stages 写入后端(当前创建仅传 name/description/repository/branch,stages 未传递) - - K8s 集群拓扑图(TopologyView)前后端数据联调 - - 告警规则关联通知渠道 — 规则创建时可选择已配置的通知渠道 - - 域名列表到期时间显示格式优化(截取 YYYY-MM-DD) - -## 2026-06-14 12:15 自动推进 - -- **任务**: 告警规则关联真实通知渠道 — 规则创建/编辑表单选择已配置的通知渠道替代硬编码类型开关 -- **修改**: - - `frontend/src/components/monitor/AlertManager.tsx`: - - 新增模块级工具函数 `resolveChannelRef(value, channels)` — 解析 notify_channels 引用值,优先按渠道 ID 匹配已配置渠道,兼容旧格式(类型字符串 email/dingtalk 等) - - 新增 `KNOWN_CHANNEL_TYPES` / `CHANNEL_TYPE_LABELS` 常量用于旧数据兼容 - - 新增组件内 `getChannelIcon(type)` — 按渠道类型返回图标组件 - - 新增 `renderRuleChannels(rule)` — 规则表通知渠道列渲染:渠道名称+类型图标徽章(替代原先仅显示3个固定图标) - - `loadData()` 并行加载 `alertApi.getNotifyChannels()`(原先仅在通知渠道弹窗打开时加载) - - 规则创建/编辑表单: 用实际已配置渠道列表(名称+类型标签)替代硬编码的 email/sms/dingtalk 3个类型开关;无渠道时显示提示 - - 移除不再使用的 `hasChannel()` 函数 - - 规则表通知渠道列从3个硬编码图标改为调用 `renderRuleChannels` - - 数据修复: `UPDATE notify_channels SET deleted_at=NULL` — 恢复3条被软删除的种子通知渠道(运维钉钉群/DBA邮箱/告警测试群) -- **结果**: - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - 后端 API 验证: POST /alerts/rules notify_channels=["1","2"] → 创建成功,返回 notify_channels:"[\"1\",\"2\"]" ✅ - - 后端 API 验证: PUT /alerts/rules/10 notify_channels=["1","3"] → 更新成功 ✅ - - 浏览器 UI 验证: 规则表"测试渠道关联规则"通知渠道列显示"运维钉钉群 告警测试群"(渠道ID 1+3解析为名称) ✅ - - 浏览器 UI 验证: 新建规则弹窗通知渠道区显示3个真实渠道(告警测试群(钉钉)/DBA邮箱(邮件)/运维钉钉群(钉钉)),不再是硬编码类型 ✅ - - 浏览器 UI 验证: 编辑规则弹窗(规则含channels["1","3"])正确预选"告警测试群"+"运维钉钉群"(data-state=checked),DBA邮箱未选 ✅ - - git push origin master 成功 (commit f3e1b81) ✅ -- **下一步建议**: - - 流水线创建时将模板 stages 写入后端(当前创建仅传 name/description/repository/branch,stages 未传递) - - K8s 集群拓扑图(TopologyView)前后端数据联调 - - 域名列表到期时间显示格式优化(截取 YYYY-MM-DD) - - 服务器「+3 本月」/ 告警「-5 本周」增长率标签仍为硬编码 - -## 2026-06-14 13:15 自动推进 - -- **任务**: AI告警智能分析接入真实后端告警事件 — 替换硬编码mock告警为真实AlertEvent数据 + 动态AI分析生成 -- **修改**: - - `frontend/src/components/aiops/AIAlertAnalyzer.tsx`: - - 新增 `mapAlertEvent(event)` 函数:将后端 AlertEvent 映射为组件 AlertItem(解析 rule.expression 提取指标名/阈值,计算 started_at 到现在的持续时间,格式化时间/百分比) - - 新增 `generateAIAnalysis(alert)` 函数:按告警指标类型(CPU/内存/磁盘/网络/服务可用性/通用)动态生成根因分析、影响评估、修复建议列表、自动修复脚本、置信度、相似案例数(替代原先4条硬编码 mockAIAnalysis) - - 组件挂载时调用 `alertApi.getAlerts()` 加载真实告警事件(替代4条硬编码 mockAlerts) - - 告警列表增加 loading 加载中状态和空状态提示 - - 后端 firing 状态映射为前端 active,resolved 映射为 resolved - - `frontend/src/components/dashboard/Dashboard.tsx`: - - 服务器 StatCard trend 从硬编码 "+3 本月" 改为基于 serverStats.error 数据驱动 - - 告警 StatCard trend 从硬编码 "-5 本周" 改为基于 alertStats.week_count 数据驱动 - - SSL证书到期日期截取 YYYY-MM-DD 格式 - - `frontend/src/components/cloud/DomainManager.tsx`: - - 域名到期时间截取 YYYY-MM-DD 格式(原显示完整 RFC3339) - - SSL到期时间同步截取 - - `frontend/src/components/deploy/PipelineManager.tsx`: - - 流水线创建时传递 stages(从模板生成)、code(从名称生成slug)、type、source 字段 -- **结果**: - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - 浏览器验证: AI告警分析页显示20条真实后端告警(原4条mock) ✅ - - 浏览器验证: 告警列表显示真实服务器名(server-033等)、真实指标值(72.9%)、计算持续时间(29小时20分钟) ✅ - - 浏览器验证: 点击告警后AI分析动态生成根因分析+影响范围+修复建议+自动修复脚本+80%置信度 ✅ - - 浏览器验证: 磁盘类告警自动生成磁盘清理脚本,CPU类生成CPU诊断脚本 ✅ - - 浏览器验证: 活跃告警计数显示"6个活跃"(真实firing状态事件数) ✅ - - 浏览器验证: 无console错误 ✅ - - git push origin master 成功 (commit b99e971) ✅ -- **下一步建议**: - - K8s 集群拓扑图(TopologyView)前后端数据联调 — 当前使用 mocks/data 静态拓扑数据 - - CabinetView/ResourceModelManager 仍使用 mock 数据 - - AIPredictiveAnalytics 预测分析仍使用 mock 数据 - - 脚本执行/定时任务执行增加真实执行日志反馈 - -## 2026-06-14 20:15 自动推进 - -- **任务**: K8s集群拓扑图(TopologyView)接入真实后端数据 — 替换硬编码mock拓扑为真实K8s资源分层可视化 -- **修改**: - - `frontend/src/components/k8s/TopologyView.tsx` (完全重写): - - 新增 `buildTopology()` 函数:从真实 K8s 资源(Ingress/Service/Deployment/Node)构建5层分层拓扑图 - - Layer 0 Cluster → Layer 1 Ingress → Layer 2 Service → Layer 3 Deployment → Layer 4 Node - - Ingress→Service 连线:解析 backend "svcName:port" 精确匹配,回退命名空间匹配 - - Service→Deployment 连线:selector JSON 标签匹配 deployment labels,回退命名空间匹配 - - Deployment→Node 连线:Deployment 的 Pod 调度到 worker 节点(前3个) - - 替换 `import { topologyNodes, topologyEdges } from '@/mocks/data'` 为 `import { k8sApi } from '@/services/api'` - - 新增集群选择器(Select):自动加载集群列表,默认选第一个,切换集群重新加载拓扑 - - 新增加载中状态(Loader2 旋转动画)和空状态提示 - - 节点详情弹窗:展示 K8s 资源属性(namespace/replicas/image/ip/role/status 等)和真实关联连接 - - 5个统计卡片:总节点数/连接数/Ingress数/Service数/Deployment数(动态计算) - - 过滤器更新:节点类型下拉新增 Ingress/Service/Deployment/Node 选项 - - 每层最多展示12个节点(超出截断)避免过度拥挤;动态画布宽度自适应 - - SVG 渲染增加层级标签(集群/Ingress/Service/Deployment/Node) - - `frontend/src/services/http.ts`: - - 修复 `return json.data !== undefined ? json.data : json` 的类型错误(TS2322) — 添加 `as T` 类型断言 -- **结果**: - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过 ✅ - - 后端 `go build ./...` 编译通过 ✅ - - 浏览器验证: prod-cluster 拓扑展示29个真实节点(1集群+4 Ingress+8 Service+6 Deployment+10 Node)、45条连接线 ✅ - - 浏览器验证: 统计卡片显示真实数值(29/45/4/8/6) ✅ - - 浏览器验证: 点击 nginx-deploy 节点弹窗显示真实属性(namespace=ingress-nginx, replicas=5, image=nginx:1.25.3) + 关联连接(mysql-svc/es-svc/frontend-svc 选中 → prod-node-2/3/4 调度) ✅ - - 浏览器验证: 5层标签正确显示(集群/Ingress/Service/Deployment/Node) ✅ - - 浏览器验证: 无 console 错误 ✅ - - git push origin master 成功 (commit 9f65faf) ✅ -- **下一步建议**: - - CabinetView/ResourceModelManager 仍使用 mock 数据 - - AIPredictiveAnalytics 预测分析仍使用 mock 数据 - - 脚本执行/定时任务执行增加真实执行日志反馈 - - 拓扑图增加缩放/拖拽交互(当前为静态SVG) - -## 2026-06-14 21:30 自动推进 - -- **任务**: AI预测分析(AIPredictiveAnalytics)接入真实后端数据 — 替换3组硬编码mock为实时数据驱动预测 + 修复CloudBilling.tsx文件损坏 -- **修改**: - - `frontend/src/components/aiops/AIPredictiveAnalytics.tsx` (完全重写): - - 新增 `linearRegression(values)` 最小二乘回归函数: 返回斜率(slope)和拟合优度(R²) - - 新增 `extractSeries(metrics, name)` 从时序指标提取按时间排序的数值序列 - - 新增 `parseThreshold(expr)` 从告警表达式解析阈值(如 "cpu_usage > 80" → 80) - - **指标预测**: 基于dashboard metrics时序数据(cpu/memory/disk),用线性回归计算斜率,预测值=current+slope*horizon,置信度=60+R²*35(60-95%) - - **容量规划**: 基于日均增长率(slope*24)推算阈值触及天数,7天预测使用率,按紧急/警告/正常三级生成AI扩容建议 - - **异常预测**: 基于告警规则阈值+当前指标值+趋势方向+事件复发频率计算异常触发概率(5-90%),去重保留最高概率 - - 组件挂载时并行加载4个API(dashboardMetrics/serverStats/alerts/alertRules),替换3组硬编码mock数组 - - 概览卡片全部数据驱动: 置信度(基于R²)/高风险数(概率≥50%计数)/容量预警(14天内)/监控指标数 - - 增加loading加载中状态和空状态提示 - - 时间范围选择器(1h/24h/7d/30d)实时调整预测horizon - - `frontend/src/components/cloud/CloudBilling.tsx` (修复): - - 修复read_file→write_file导致的行号前缀损坏(501行全部含` N|`前缀,导致tsc -b编译失败) - - Python脚本批量剥离`^\s*\d+\|`前缀,恢复728行完整组件(概览/成本记录/预警规则三标签页) -- **结果**: - - `npx tsc --noEmit` 类型检查通过 ✅ - - `npm run build` 构建通过(修复CloudBilling后) ✅ - - 后端 `go build ./...` 编译通过 ✅ - - 浏览器验证: 指标预测tab显示CPU(63%→87%,置信度95%)/内存(53%→60%,64%)/磁盘(48%→49%,60%) ✅ - - 浏览器验证: 容量规划tab显示CPU(1天触及阈值/紧急)/内存(4天/紧急)/磁盘(40天/正常) ✅ - - 浏览器验证: 异常预测tab显示6条规则(CPU告警78%/内存71%/CPU严重63%/服务不可用60%/磁盘53%/Pod重启40%) ✅ - - 浏览器验证: 概览卡片显示置信度84%(基于72数据点R²)/高风险5/容量预警2/指标数3 ✅ - - 浏览器验证: 无console错误 ✅ - - 浏览器验证: CloudBilling云账单页面正常加载(概览统计/成本记录/预警规则) ✅ - - git push origin master 成功 (commit 2da4f52) ✅ -- **下一步建议**: - - CabinetView/ResourceModelManager 仍使用 mock 数据 - - 脚本执行/定时任务执行增加真实执行日志反馈 - - 拓扑图增加缩放/拖拽交互 - - AnsibleManager 仍使用 mock 数据 - -## 2026-06-14 22:15 自动推进 - -- **任务**: 脚本/定时任务真实执行+输出捕获+执行历史 — 替换"pending空壳"为本地执行,采集stdout/stderr/退出码/耗时,更新任务统计;修复CronJobManager/Automation前后端字段名不匹配 -- **修改**: - - `backend/internal/api/handlers/automation.go`: - - 新增 `runLocal(scriptType, content, timeoutSec)` 辅助函数: 在 AIOps 主机上本地执行脚本(shell→bash -c/python→python3 -c),用 context.WithTimeout 超时控制,采集合并 stdout+stderr 输出,返回输出/退出码/耗时 - - 重写 `ExecuteCronJob`: 加载任务内容(command直接执行/script类型加载关联Script内容)→创建running状态执行记录→本地执行→更新status/output/duration/finished_at→更新CronJob统计(last_run_at/last_status/run_count/success_count/fail_count) - - 重写 `ExecuteScript`: 请求体改为可选(无body时本地执行)→创建running记录→本地执行→更新记录+Script.run_count - - 新增 `ListExecutions` handler: 支持task_type/task_id筛选,按created_at DESC排序分页 - - `backend/internal/api/routes/routes.go`: 新增 `GET /task-executions` 路由 - - `frontend/src/services/api.ts`: 新增 `taskExecutionApi.getExecutions()` API - - `frontend/src/components/automation/CronJobManager.tsx` (重写): - - 修复字段名: status→enabled, cron→cron_expr, command→content, last_run→last_run_at - - 创建对话框发送正确字段(cron_expr/type/content) - - 执行按钮: loading状态(Loader2旋转)+toast成功/失败反馈(含耗时/输出摘要) - - 执行历史对话框: 调用taskExecutionApi加载真实执行记录,可展开查看完整输出日志(终端风格) - - 统计卡片: 已启用/累计成功/累计失败基于真实数据 - - `frontend/src/components/automation/Automation.tsx`: - - 修复字段名: job.status===1→job.enabled, job.cron→job.cron_expr, job.last_run→job.last_run_at, script.language→script.type - - getJobStatusBadge 接收布尔enabled参数 - - getLanguageBadge 增加 shell/yaml 类型支持,默认shell - - 脚本执行/cron执行增加toast反馈 -- **结果**: - - 后端 `go build ./...` 编译通过 ✅ - - 前端 `npx tsc --noEmit` + `npm run build` 通过 ✅ - - API验证: POST /scripts/2/execute → status=success, duration=4ms, output=真实df -h磁盘信息 ✅ - - API验证: POST /cron-jobs/2/execute → status=success, duration=80ms, output=find命令输出 ✅ - - API验证: GET /task-executions → 返回2条执行记录按时间倒序 ✅ - - 浏览器验证: 定时任务页5个任务正确显示enabled/cron_expr/last_run_at/统计 ✅ - - 浏览器验证: 执行历史弹窗显示真实记录(任务名/状态/耗时/时间),点击展开查看完整输出日志 ✅ - - 浏览器验证: 脚本库页type徽章正确显示SHELL/PYTHON(原空白) ✅ - - 浏览器验证: 执行脚本后toast"执行成功,耗时Xms" ✅ - - git push origin master 成功 (commit a4fd5fc) ✅ -- **下一步建议**: - - CabinetView/ResourceModelManager 仍使用 mock 数据 - - AnsibleManager 仍使用 mock 数据 - - K8s拓扑图增加缩放/拖拽交互 - - 定时任务创建对话框增加目标服务器选择(当前仅本地执行) - - 脚本编辑器增加在线编辑+保存功能(当前新建对话框无编辑入口) - -## 2026-06-14 23:15 自动推进 - -- **任务**: Ansible Playbook 全栈 CRUD + 真实执行 — 替换 mock 为后端持久化(最后一个 mock→real 模块自动化运维 AnsibleManager) -- **修改**: - - `backend/internal/models/automation.go` — Playbook 模型新增 `Category string` + `Tags string`(JSON数组) 字段;新增 `CreatePlaybookRequest` 请求 struct - - `backend/internal/api/handlers/automation.go`: - - 新增 `randomCode(prefix)` 辅助函数:crypto/rand 生成 4 字节随机 hex + 时间戳,解决 `pb-%d` 时间戳碰撞导致 UNIQUE constraint 失败 - - 新增 `runPlaybookLocal(content, inventory, timeout)` — 写临时 YAML 文件 + `ansible-playbook -i localhost, --connection=local` 执行,采集 stdout+stderr/退出码/耗时;二进制不存在时输出友好提示 - - 新增 6 个 handler:`ListPlaybooks`/`GetPlaybook`/`CreatePlaybook`/`UpdatePlaybook`/`DeletePlaybook`/`ExecutePlaybook`(Execute 创建 TaskExecution(task_type=playbook) 记录 + 本地执行 + 更新 run_count) - - `backend/internal/api/routes/routes.go` — 注册 `/playbooks` 路由组(GET/POST/GET:id/PUT:id/DELETE:id/POST:id/execute) - - `frontend/src/services/api.ts` — `ansibleApi` 从 mock 重写为真实 `/playbooks` 后端调用(getPlaybooks/getPlaybook/createPlaybook/updatePlaybook/deletePlaybook/executePlaybook) - - `frontend/src/components/automation/AnsibleManager.tsx` 完全重写(705→480行): - - Playbook 管理 tab:真实数据卡片列表(id/name/category徽章/tags徽章/run_count/timeout)+ 搜索 + 分类筛选 - - 新建/编辑对话框复用(编辑模式预填数据)— **新增编辑入口**(原仅有新建无编辑) - - 查看内容对话框:终端风格 YAML 查看器 + 快捷编辑按钮 - - 执行:ConfirmProvider 确认 + 本地 ansible-playbook 执行 + toast 结果反馈(成功/失败+耗时) - - 执行记录 tab:真实 TaskExecution 记录(#/Playbook/状态/开始时间/耗时/查看日志) - - 执行日志查看器:黑底绿字终端风格展示完整输出 - - 4 个统计卡片(总数/执行中/今日成功/今日失败)基于真实执行记录 - - 移除原 inventory 占位 tab(无后端支持,避免非功能按钮,符合用户偏好) - - tags 后端 JSON 字符串 ↔ 前端 string[] 互转映射(mapPlaybook 解析 + tagsToJson 序列化) -- **结果**: - - 后端 `go build ./...` 编译通过 ✅ - - 前端 `npx tsc --noEmit` + `npm run build` 通过 ✅ - - API 验证:POST /playbooks 创建4条种子(Nginx部署/安全加固/数据库备份/NTP配置)✅ - - API 验证:PUT /playbooks/:id 更新成功(category/tags/description/timeout)✅ - - API 验证:POST /playbooks/:id/execute 创建执行记录 status=failed(macOS无ansible-playbook,输出友好提示)✅ - - API 验证:GET /task-executions?task_type=playbook 返回真实执行记录 ✅ - - 浏览器验证:4 个 Playbook 卡片正确展示(分类徽章/标签徽章/执行次数/超时)✅ - - 浏览器验证:编辑对话框预填数据(名称/分类/描述/标签/超时/完整YAML内容)✅ - - 浏览器验证:查看内容终端风格展示完整YAML ✅ - - 浏览器验证:执行记录tab展示真实记录(任务名/失败状态/开始时间/日志查看)✅ - - 浏览器验证:执行日志对话框展示友好错误信息("无法执行 ansible-playbook...请安装 ansible")✅ - - 浏览器验证:统计卡片显示真实数值(4/0/0/1)✅ - - 浏览器验证:无 console 错误 ✅ - - git push origin master 成功 (commit ae6af93) ✅ -- **下一步建议**: - - K8s 拓扑图增加缩放/拖拽交互(当前为静态 SVG) - - 定时任务创建对话框增加目标服务器选择(当前仅本地执行) - - 脚本编辑器增加在线编辑+保存功能(当前新建对话框无独立编辑入口) - - CabinetView 仍使用 mock 数据(但未在 App.tsx 路由中使用,低优先级) - -## 2026-06-15 00:10 自动推进 - -- **任务**: 脚本库全栈 CRUD 管理器(ScriptManager)— 替换占位 AutomationManager,为 `/scripts` 路由补齐完整新建/编辑/查看/复制/删除/执行能力(自动化运维三大模块 CronJob/Script/Ansible 最后一个补全真实 CRUD) -- **修改**: - - `frontend/src/components/automation/ScriptManager.tsx`(新建,656 行): - - 两 tab 布局:脚本库(卡片网格)+ 执行记录(表格) - - 脚本库卡片:名称/类型徽章/描述/内容预览(3行)/执行次数·版本/超时 + 5 操作按钮(查看内容/执行/编辑/复制/删除) - - 搜索(名称·编码·描述)+ 类型筛选(全部/Shell/Python/PowerShell/YAML) - - 新建/编辑复用对话框:名称、类型(切换时自动填充对应模板)、描述、超时、内容编辑器;自动生成唯一 code(`sc--<随机>`) - - 查看:终端风格(黑底绿字)ScrollArea 内容展示 + 快捷编辑入口 - - 执行:ConfirmProvider 确认 → 本地执行 → toast 反馈(成功显示耗时,失败显示输出摘要) - - 执行记录 tab:真实 TaskExecution(task_type=script)记录,状态徽章 + 耗时 + 终端风格日志查看器 - - 4 统计卡片(脚本总数/执行中/今日成功/今日失败)基于真实执行记录 - - 主题感知(muted-foreground/muted CSS 变量,跟随 5 套主题) - - `frontend/src/App.tsx`: - - 新增 `import { ScriptManager }` - - `/scripts` 路由 `AutomationManager` → `ScriptManager` - - 移除 dead `/playbooks` 路由(侧边栏无入口,指向占位组件) - - 移除 `AutomationManager` import - - 删除 `frontend/src/components/automation/Automation.tsx`(占位显示页,Edit/新建/目标服务器按钮均为非功能性占位,违反「不留占位按钮」偏好) -- **结果**: - - 前端 `npx tsc --noEmit` 类型检查通过 ✅ - - 前端 `npm run build` 构建通过 ✅ - - 后端无改动(Script CRUD + Execute API 早已完整,前端 scriptApi 也已齐备) - - API 验证:POST /scripts 创建成功(id=6)✅ - - API 验证:POST /scripts/:id/execute → status=success, duration=6ms, 真实 stdout 捕获 ✅ - - API 验证:PUT /scripts/:id → name 更新 + version 自增 2 + timeout 更新 ✅ - - API 验证:DELETE /scripts/:id → 删除成功 ✅ - - 浏览器验证:脚本库 5→6 卡片正确展示(含新建的测试脚本)✅ - - 浏览器验证:新建对话框预填 Shell 模板,填写名称后创建成功(卡片数+1)✅ - - 浏览器验证:编辑对话框预填数据(重启服务/Shell/重启服务脚本/systemctl restart $1)✅ - - 浏览器验证:执行确认弹窗 → 确认后执行 → 统计「今日失败」0→1(重启脚本在 macOS 上执行失败,符合预期)✅ - - 浏览器验证:执行记录 tab 展示 3 条真实记录(#7重启服务-失败/#6测试-成功/#1检查磁盘-成功)+ 状态徽章 + 查看日志按钮 ✅ - - 浏览器验证:无 console 错误 ✅ - - git push origin master 成功 (commit 242389a) ✅ -- **下一步建议**: - - 定时任务/脚本执行接入 Agent 远程执行(当前仅 AIOps 主机本地执行,Agent 任务系统已就绪可复用) - - K8s 拓扑图增加缩放/拖拽交互 - - 脚本执行支持参数化(Params 字段已存在于 model,前端尚未接入参数填写) - - 定时任务编辑入口(CronJobManager 有新建但 Automation 列表的编辑按钮未接通) - -## 2026-06-15 01:00 自动推进 - -- **任务**: 定时任务编辑入口全栈打通(CronJobManager 编辑按钮接通)+ 修复 ApprovalFlow 编译错误(API 方法名不匹配)+ 合并已完成的大文件拆分重构 -- **修改**: - - `frontend/src/components/automation/CronJobManager.tsx`: - - 编辑按钮(原占位无 onClick)→ 接通 `openEdit(job)`,弹出编辑对话框预填数据 - - 新建/编辑共用 Dialog:`editingId` 区分模式(null=新建,非 null=编辑) - - Dialog 标题动态切换("新建定时任务" / "编辑定时任务"),保存按钮文案动态切换("创建任务" / "保存修改") - - `handleSaveJob` 根据 editingId 调用 `cronJobApi.createJob` 或 `cronJobApi.updateJob` - - 消除违反「不留占位按钮」规则的死按钮 - - `frontend/src/components/approval/ApprovalFlow.tsx`(修复编译错误): - - `approvalApi.getInstances()` → `getProcesses()` - - `approvalApi.getMyApprovals()` → `getProcesses({ status: 'pending' })` - - `approvalApi.approveInstance(id, { comment })` → `approveProcess(id, comment)` - - `approvalApi.rejectInstance(id, { comment })` → `rejectProcess(id, comment)` - - 添加 `export const ApprovalFlowManager = ApprovalFlow` 别名兼容 App.tsx 导入 - - **大文件拆分重构(WIP 完成)**: backend automation.go(991行)→automation_cron/script/playbook, ldap.go(496行)→ldap_ou/ldap_users, server.go(641行)→cloud_accounts/domain/platform/region, user.go(344行)→user_admin; frontend Dashboard/K8sClusters/ServerManager/ApprovalFlow/Monitor 子组件提取 -- **结果**: - - 后端 `go build ./...` + `go build -o aiops-server ./cmd/` 编译通过 ✅ - - 前端 `npx tsc --noEmit` + `npm run build` (tsc -b + vite build) 通过 ✅ (原 tsc -b 有 5 个 ApprovalFlow 编译错误,已修复) - - API 验证:PUT /cron-jobs/1 更新 description 成功(code=200, message=更新成功)✅ - - 浏览器验证:定时任务页 5 个任务卡片正确展示,编辑按钮点击弹出编辑对话框预填数据(名称/描述/cron/内容)✅ - - 浏览器验证:修改描述→保存→对话框关闭→数据持久化(DB 确认 description 已更新)✅ - - 浏览器验证:新建模式对话框标题"新建定时任务",字段为空,按钮"创建任务" ✅ - - 浏览器验证:审批流程页正常加载(无白屏),Dashboard/ServerManager/K8s 页面正常 ✅ - - 浏览器验证:全页面无 console JS 错误 ✅ - - git push origin master 成功 (commit a7e2351) ✅ -- **下一步建议**: - - 脚本执行支持参数化(Params 字段已在 model 中,前端尚未接入参数填写 UI) - - 定时任务/脚本执行接入 Agent 远程执行(当前仅 AIOps 主机本地执行) - - K8s 拓扑图增加缩放/拖拽交互(当前为静态 SVG) - - 新增的孤儿子组件文件(ApprovalProcessTab/ApprovalTemplateTab 等)确认是否被引用,未引用可清理 - -## 2026-06-15 全局代码模块化重构(<700行强制拆分) - -### 背景 -按照 CLAUDE.md 定义的架构规范(单文件不超过700行),对全项目进行系统性模块化拆分。创建 CLAUDE.md 作为项目全局 AI 开发规范,定义了 700 行拆分规则、命名规范、架构约束和开发流程。 - -### 后端 Go handler 拆分(4 个超限文件 → 12 个模块化文件) - -| 原文件 | 原行数 | 拆分后 | 最大行数 | -|--------|--------|--------|----------| -| `automation.go` | 1067 | automation.go + automation_cron.go + automation_script.go + automation_playbook.go | 358 | -| `server.go` | 1011 | server.go + cloud_accounts.go + cloud_domain.go + cloud_platform.go + cloud_region.go | 370 | -| `ldap.go` | 815 | ldap.go + ldap_ou.go + ldap_users.go | 397 | -| `user.go` | 704 | user.go + user_admin.go | 360 | - -### 前端组件拆分(15+ 超限组件 → 全部 <700行) - -| 组件 | 原行数 | 拆分后 | 行数 | -|------|--------|--------|------| -| `SystemManager.tsx` | 1782 | SystemManager + SystemUserTab + SystemSettingsTab + SystemLogsTab + SystemRolesTab + LdapManagementSection + UserFormDialog | 569 | -| `AlertManager.tsx` | 1133 | AlertManager + AlertEventsTab + AlertRulesTab + AlertRuleDialog + AlertChannelDialog | 220 | -| `K8sClusters.tsx` | 1062 | K8sClusters + K8sClusterDetail + K8sClusterDialog + TopologyView | <600 | -| `Dashboard.tsx` | 941 | Dashboard + DashboardCharts + DashboardRecent + DashboardStats | <500 | -| `ServerManager.tsx` | 905 | ServerManager + ServerForm + BatchImport + CloudServerImport + ExcelServerImport | 640 | -| `ApprovalFlow.tsx` | 830 | ApprovalFlow + ApprovalProcessTab + ApprovalTemplateTab | 548 | -| `AlertCenter.tsx` | 775 | AlertCenter + AlertCenterDetail | 693 | -| `AIPredictiveAnalytics.tsx` | 760 | AIPredictiveAnalytics + PredictiveCapacityTab | 670 | -| `CloudBilling.tsx` | 728 | CloudBilling + CloudBillingHelpers | 663 | -| `GrafanaDashboard.tsx` | 717 | GrafanaDashboard + GrafanaPanelContent | 643 | -| `ui/sidebar.tsx` | 726 | sidebar.tsx + sidebar-context.tsx(提取常量/Context/variants) | 689 | - -### Mock 数据模块化(2552行 → 11 个 domain 模块 + barrel 导出) - -`mocks/data.ts`(2552行)→ barrel pattern: -- `data.ts`(80行 barrel re-export,保持 `@/mocks/data` 导入路径不变) -- `modules/servers.ts`(328), `modules/cloud.ts`(330), `modules/k8s.ts`(471) -- `modules/alerts.ts`(200), `modules/dashboard.ts`(98), `modules/cicd.ts`(118) -- `modules/automation.ts`(258), `modules/system.ts`(178), `modules/approval.ts`(173) -- `modules/monitor.ts`(162), `modules/misc.ts`(479) - -### 顺手修复的编译错误 -- `AlertCenter.tsx`: `res.data` → `res.list`(ListResponse 类型不匹配) -- `AlertCenter.tsx`: `toggleRule(ruleId, enabled)` → `toggleRule(ruleId)`(API 签名变更) -- `LogManager.tsx`: 同上 `res.data` → `res.list` - -### 验证结果 -- 后端 `go build -o aiops-server ./cmd/` 编译通过 ✅ -- 前端 `npx tsc --noEmit` 类型检查通过(零错误)✅ -- 前端 `npm run build`(tsc -b + vite build)通过 ✅ -- **全项目无任何文件超过700行** ✅ - -### 新增规范文件 -- `CLAUDE.md` — 项目全局 AI 开发规范(8KB),包含: - - 架构约束:前端700行/后端400行拆分规则 - - 命名规范:中文注释 + `// Logan.Li` 署名 - - 前端约定:shadcn/ui 导入路径、Token 获取方式、严格模式 - - 开发流程:编译验证 + 文档更新 + Git 提交 - ---- - -## 2026-06-15 15:30 自动推进 - -### 任务 1: 服务器批量导入功能(全栈真实实现) - -**背景**: 原 `ExcelServerImport.tsx` 仅用 `setTimeout` 模拟文件解析,预览数据和导入按钮均为假功能;`BatchImport.tsx` 为孤立占位组件(含 `toast.info('开发中')`)。违反项目原则"不留占位按钮/假功能"。 - -**修改文件**: -- `backend/internal/models/server.go` — 新增 `BatchServerItem`/`BatchCreateServerRequest`/`BatchCreateResult` 三个请求/响应结构体 -- `backend/internal/api/handlers/server.go` — 新增 `BatchCreateServers` 处理器(74行):逐条校验 hostname/IP 非空 → 查重(hostname 或 IP 已存在则跳过)→ GB→MB 转换 → 返回 success/skipped/failed/errors 明细。新增 `fmt`/`strings` import -- `backend/internal/api/routes/routes.go` — 注册 `servers.POST("/batch")` 路由 -- `frontend/src/services/api.ts` — 新增 `serverApi.batchCreate` 方法 -- `frontend/src/components/server/ExcelServerImport.tsx` — 核心重写:新增 CSV 解析器(`parseCSVLine`/`parseCSV`)、IP 格式校验(`isValidIP`)、CSV 模板下载(`downloadTemplate`)、行级校验(hostname 非空/IP 格式/重复行检测)、真实 API 导入调用、Loader2 loading 动画。删除旧的 setTimeout 模拟逻辑 -- `frontend/src/components/server/BatchImport.tsx` — **删除**(孤立占位组件,432行) - -**验证结果**: -- 后端编译 `go build ./...` ✅ -- 前端类型检查 `tsc --noEmit` ✅ -- 前端构建 `npm run build` ✅ (1.88MB) -- API 端到端测试 5 项全通过:批量创建→查重→空 hostname/无效 IP→内存转换(8GB=8192MB) -- 浏览器 UI 验证:CSV 上传→预览(2有效/2错误)→导入流程正常 - -### 任务 2: SQL 审计后端模型修复(先前会话遗留) - -**修改文件**: -- `backend/internal/models/sql_audit.go` — `TableName` 字段重命名为 `TargetTable`,修复 GORM 误识别 TableName 方法导致 AutoMigrate 失败 -- `backend/internal/api/handlers/sql_audit.go` — 同步字段名 -- `backend/internal/api/routes/routes.go` — SQL 审计路由注册 -- `backend/pkg/database/database.go` — AutoMigrate 添加 SQL 审计模型 -- `backend/cmd/seed.go` — 新增 SQL 审计种子数据(55行) - -### 任务 3: 审批流程增强(先前会话遗留) - -**修改文件**: -- `backend/internal/models/approval.go` — 审批步骤类型调整 -- `frontend/src/components/approval/ApprovalFlow.tsx` — 审批流程组件增强(+237行) -- `frontend/src/components/layout/Sidebar.tsx` — 菜单项微调 -- `frontend/src/components/monitor/AlertManager.tsx` — 告警管理微调 - -**验证**: `go build ./...` ✅ + `tsc --noEmit` ✅ + `npm run build` ✅ - - -## 2026-06-15 16:00 自动推进 - -### 任务: 仪表盘资源趋势图——真实Agent指标替代随机假数据 - -**背景**: Dashboard 的"资源使用率趋势"图表(ResourceUsageCard)使用 `generateMetricsData()` 函数,通过 `Math.random()` 生成 24 小时假数据(每次刷新数值随机变化)。后端 `GetMetrics` 端点虽然已从 `agent_reports` 按小时聚合真实数据,但存在两个问题: -1. **后端 bug**: `strftime('%Y-%m-%d %H:00', created_at)` 返回字符串,GORM 无法自动扫入 `time.Time`,导致 `timestamp` 字段为 Go 零值 `0001-01-01T00:00:00Z` -2. **前端未对接**: Dashboard.tsx 从未调用 `getMetrics()`,直接传 `avgCpu/avgMemory/avgDisk` 给图表组件生成随机波动 - -**修改文件**: -- `backend/internal/api/handlers/dashboard.go` — `avgPoint.Bucket` 类型从 `time.Time` 改为 `string`,新增 `time.ParseInLocation("2006-01-02 15:00", r.Bucket, time.Local)` 手动解析 SQLite strftime 字符串为 time.Time -- `frontend/src/components/dashboard/Dashboard.tsx` — 新增 `metrics` state + `dashboardStatsApi.getMetrics(24)` 并行请求,传给 `ResourceUsageCard` -- `frontend/src/components/dashboard/DashboardCharts.tsx` — 新增 `buildMetricsFromReal()` 将后端 `MetricData[]`(cpu+memory 配对记录)按时间戳分组转换为图表 `{ time, cpu, memory, disk }[]` 格式;`ResourceUsageCard` 新增 `metrics` prop,优先使用真实时间序列,无数据时回退到平均值生成 - -**验证结果**: -- 后端编译 `go build ./cmd/` ✅ -- 前端 `tsc --noEmit` ✅ + `npm run build` ✅ -- API 验证: `GET /dashboard/metrics?hours=24` 返回 46 条数据点(23小时×cpu+memory),timestamp 正确显示 `2026-06-14T04:00:00+08:00` 等真实时间 -- 浏览器验证: 资源趋势图 X 轴显示 22 个真实小时标签(00:00~23:00),CPU 值呈现业务时段模式(夜间 10-20%,工作时段 40-50%),不再随机变化 -- 注入 48 条真实测试数据(2 台主机 × 24 小时,含业务时段负载模式)用于可视化验证 - -**关键修复点**: GORM 的 `Scan()` 方法无法将 SQLite `strftime()` 返回的文本格式日期自动解析为 Go `time.Time`,必须用 `string` 接收后手动 `time.ParseInLocation()`。 - - -## 2026-06-15 16:10 自动推进 - -### 任务: 发布管理全栈完善 - -**背景**: ReleaseManager.tsx 存在多个前后端不匹配问题和功能缺失:环境枚举值不一致(前端 `development/production` vs 后端 `dev/prod`)、硬编码 mock 应用列表、字段名错误(`deployer` vs `author`)、操作无 toast 反馈、部署/回滚无确认框、详情弹窗缺失。 - -**修改文件**: - -- `backend/internal/api/handlers/pipeline.go` — 新增 `RejectRelease` handler(38行):仅 `pending` 状态可驳回,状态置为 `rejected`,非 pending 返回 400 -- `backend/internal/api/routes/routes.go` — 注册 `releases.POST("/:id/reject")` 路由 -- `frontend/src/services/api.ts` — 新增 `releaseApi.rejectRelease(id)` 方法 -- `frontend/src/components/deploy/ReleaseManager.tsx` — 完整重写(553→581行): - - 修复环境值:`development` → `dev`、`production` → `prod`(与后端 `binding:"oneof=dev test staging prod"` 一致) - - 移除硬编码 `applications` mock 数组,改为自由输入应用名称 - - 修复字段名:`release.deployer` → `release.author` - - 新增 toast 反馈:create/approve/reject/deploy/rollback 全部有 success/error 提示 - - 新增确认框:deploy/rollback 操作前弹 ConfirmProvider 确认 - - 新增发布详情弹窗(原缺失,Eye 按钮点击无效 → 现展示完整详情) - - 新增空状态(无发布记录时显示 PackageOpen 图标 + "暂无发布记录") - - 新增 `rejected` 状态徽章 - - 主题感知:`bg-slate-800` → `var(--dg-bg-secondary)` 全量替换 - - 原生 `