diff --git a/.env.example b/.env.example index 9a44757..4cd737f 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,12 @@ # AIOps Docker Compose 环境变量 -# 复制为 .env 并修改密码 +# 复制为 .env 后填入随机生产密钥;.env 不可提交,权限应为 600。 // Logan.Li # MySQL 数据库密码(生产环境务必修改) -MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD -MYSQL_PASSWORD=CHANGE_ME_USER_PASSWORD +MYSQL_ROOT_PASSWORD=REPLACE_WITH_A_RANDOM_ROOT_PASSWORD +MYSQL_PASSWORD=REPLACE_WITH_A_RANDOM_APPLICATION_PASSWORD + +# JWT 至少 32 字节随机值,例如:openssl rand -hex 32。 // Logan.Li +JWT_SECRET=REPLACE_WITH_A_RANDOM_JWT_SECRET # MySQL 数据库名和用户名(通常无需修改) MYSQL_DATABASE=aiops diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ba84381 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,123 @@ +name: AIOps CI + +on: + push: + branches: [main, master, dev] + pull_request: + branches: [main, master, dev] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: aiops-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend (test, vet, build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + cache-dependency-path: backend/go.sum + - name: Download modules + run: go mod download + - name: Unit tests + run: go test ./... + - name: Vet + run: go vet ./... + - name: Build + run: go build -buildvcs=false -o "$RUNNER_TEMP/aiops" . + + agent: + name: Agent (test, vet, build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: agent + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + cache-dependency-path: agent/go.sum + - name: Download modules + run: go mod download + - name: Unit tests + run: go test ./... + - name: Vet + run: go vet ./... + - name: Build Linux binary + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "$RUNNER_TEMP/aiops-agent" . + + frontend: + name: Frontend (typecheck, lint, build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install dependencies + run: npm ci + - name: Type check + run: npm run typecheck + # ESLint 错误必须阻断 CI,禁止以 continue-on-error 掩盖质量问题。 # Logan.Li + - name: Lint + run: npm run lint + - name: Production build + run: npm run build + + api-integration: + name: API integration (SQLite + redka) + runs-on: ubuntu-latest + env: + AIOPS_TEST_USERNAME: admin + AIOPS_TEST_PASSWORD: "123456" + AIOPS_TEST_PORT: "18080" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + cache-dependency-path: backend/go.sum + - name: Build disposable test server + working-directory: backend + run: go build -buildvcs=false -o "$RUNNER_TEMP/aiops" . + - name: Start disposable local service + run: | + nohup "$RUNNER_TEMP/aiops" --mode local --port "$AIOPS_TEST_PORT" --db-name "$RUNNER_TEMP/aiops-ci.sqlite3" --jwt-secret "ci-only-jwt-secret" --log-level warn > "$RUNNER_TEMP/aiops.log" 2>&1 & + echo $! > "$RUNNER_TEMP/aiops.pid" + - name: Wait for health endpoint + run: | + for attempt in $(seq 1 30); do + if curl --fail --silent --show-error "http://127.0.0.1:${AIOPS_TEST_PORT}/health" > /dev/null; then + exit 0 + fi + sleep 1 + done + cat "$RUNNER_TEMP/aiops.log" + exit 1 + - name: Run critical API integration tests + run: python3 tests/integration_test.py --host 127.0.0.1 --port "$AIOPS_TEST_PORT" + - name: Show service log on failure + if: failure() + run: cat "$RUNNER_TEMP/aiops.log" + - name: Stop disposable local service + if: always() + run: | + if [ -f "$RUNNER_TEMP/aiops.pid" ]; then + kill "$(cat "$RUNNER_TEMP/aiops.pid")" || true + fi diff --git a/AGENTS.md b/AGENTS.md index 68b6b27..255e85a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,51 @@ --- +## 0. 规格驱动开发(SDD,强制) + +本项目采用 **Specification-Driven Development**。对于会改变行为、接口、数据或运维方式的工作,规格不是完成后的说明,而是实现前的约束和验收依据。 + +权威规范与模板: + +- 流程、分级、质量门禁:`docs/specs/README.md` +- 新规格模板:`docs/specs/_template.md` + +### 0.1 变更分级 + +| 级别 | 典型范围 | 规格要求 | +|------|----------|----------| +| S0 | 文案、注释、格式、无行为变化的机械修改 | 不创建规格文件,但必须验证受影响文件 | +| S1 | 局部缺陷修复、内部重构、单模块小改动 | 在任务计划中写清问题、约束、验收标准 | +| S2 | 新功能、跨模块流程、API/数据模型/配置变更 | 实现前必须创建或更新 `docs/specs//.md` | +| S3 | 认证授权、账单、安全、数据库迁移、数据清理、生产部署 | 按 S2 执行,并补充风险、兼容性、可观测性、发布与回滚方案 | + +级别取最高影响,不以代码行数判断。用户已给出明确需求时可以直接形成规格并继续;只有缺失选择会显著改变结果或扩大权限时才暂停询问。 + +### 0.2 强制工作流 + +1. **Discover**:先读现有代码、接口、模型、测试和工作区差异,以证据确认当前行为。 +2. **Specify**:写清目标、非目标、行为规则、接口/数据契约、验收标准和风险;S2/S3 必须落盘。 +3. **Design**:确定最小实现边界、复用点、迁移和回滚路径,禁止把未决问题留给编码阶段猜测。 +4. **Implement**:按规格逐项实现;发现规格错误时先更新规格,再改代码,禁止静默偏离。 +5. **Verify**:每条验收标准必须映射到自动化测试、构建检查或明确的人工验证证据。 +6. **Deliver**:交付说明必须列出实现结果、验证证据、未覆盖项和剩余风险;S2/S3 同步更新规格状态。 + +### 0.3 不可绕过的门禁 + +- API 改动必须先明确请求、响应、错误码、权限和兼容策略,再同步前后端。 +- 数据库改动必须使用版本化幂等迁移,说明数据回填、旧版本兼容、备份和回滚;禁止用启动期 `AutoMigrate` 代替生产迁移。 +- 认证、审批、远程执行、账单和密钥相关变更必须包含权限边界、审计行为和失败路径。 +- 监控、Agent 上报、日志等高频数据必须定义保留期、容量影响和清理可观测性。 +- 金额必须明确原币、报告币、汇率来源和精度;时间必须明确时区、统计窗口和边界日期。 +- 新增行为必须有测试。无法自动化的验收项要记录原因、人工步骤和结果,不能用“已检查”代替证据。 +- 实现不得夹带规格外重构;发现必要的额外工作,应先补入规格的范围或后续事项。 + +### 0.4 完成定义(Definition of Done) + +代码编译通过不等于完成。S1 及以上变更至少满足:规格/计划与实现一致、验收标准全部有证据、相关测试通过、错误与空状态可用、权限和兼容性已检查、文档已同步、发布与回滚可执行。任一项缺失时必须明确标记未完成,不得宣称任务已完成。 + +--- + ## 1. 项目概述 **AIOps** 是一个企业级云原生智能运维平台,聚焦三大核心能力: @@ -20,8 +65,8 @@ | 产物 | 技术 | 说明 | |------|------|------| | `frontend/` | React 19 + TypeScript + Vite | Web 控制台 | -| `backend/` | Go 1.24 + Gin + GORM | HTTP API + WebSocket + 前端静态资源服务 | -| `agent/` | Go 1.24(CGO_ENABLED=0) | 主机指标采集 + 远程任务执行 | +| `backend/` | Go 1.25 + Gin + GORM | HTTP API + WebSocket + 前端静态资源服务 | +| `agent/` | Go 1.22 模块(Go 1.25 工具链,CGO_ENABLED=0) | 主机指标采集 + 远程任务执行 | ### 1.2 默认账号(生产必须修改) @@ -41,7 +86,7 @@ | 文件 | 说明 | |------|------| -| `backend/go.mod` | 后端 Go 模块定义,模块名 `ezops`,Go 1.24.0 | +| `backend/go.mod` | 后端 Go 模块定义,模块名 `ezops`,Go 1.25.0 | | `agent/go.mod` | Agent 独立模块,模块名 `ezops/agent`,Go 1.22(与后端共用 1.24 工具链) | | `frontend/package.json` | 前端 npm 依赖与脚本 | | `frontend/vite.config.ts` | Vite 配置:端口 5173,代理 `/api`、`/ws`、`/health`、`/version`、`/agent` 到 `localhost:8080` | @@ -60,7 +105,7 @@ ### 2.2 后端 -- **语言 / 版本**:Go 1.24+ +- **语言 / 版本**:Go 1.25+ - **Web 框架**:Gin v1.9.1 - **ORM**:GORM v1.30.0 - **数据库**:SQLite(local 模式,开发本机)、MySQL 8.0(生产)、PostgreSQL(可选) @@ -91,7 +136,7 @@ - **数据请求**:TanStack Query 5+ - **路由**:React Router 7+ - **图表**:Recharts -- **图标**:Lucide React +- **图标**:Lucide React(通用 UI)+ Simple Icons(技术品牌按需导入) - **编辑器**:CodeMirror 6(`@uiw/react-codemirror` + `@codemirror/lang-yaml`) - **终端**:xterm.js - **国际化**:i18next + react-i18next(中/英双语) @@ -361,7 +406,7 @@ go build -o aiops . 3. **新增 handler 不直接绑 model**,使用独立 request struct。 4. **GORM 指针字段必须解引用**后再使用(`*field`)。 5. **Update handler 禁止复用 CreateRequest**,使用 `map[string]interface{}` 接收。 -6. **编译参数**:`unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=local CGO_ENABLED=1 go build -o aiops .` +6. **编译参数**:`unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=auto CGO_ENABLED=1 go build -o aiops .` 7. **GORM `First()` 连续查询会带上次 ID**,请用 `Take()` 代替。 8. 文件名使用 `snake_case.go`;类型/方法 `PascalCase`;变量 `camelCase`。 @@ -373,6 +418,8 @@ go build -o aiops . 4. **Token 双保险**:`useUserStore.getState().token || localStorage.getItem('token')`。 5. ESLint 禁止显式 `any`(`@typescript-eslint/no-explicit-any: error`)。 6. 组件文件 `PascalCase.tsx`;工具文件 `camelCase.ts`。 +7. 通用操作、状态和导航使用 `lucide-react`;云厂商、CI/CD、监控和中间件等产品实体使用 `TechnologyBrandIcon` 或 `CloudBrandIcon`。 +8. 技术品牌图标从 `simple-icons` 按需导入并随构建产物发布;禁止使用 CDN、远程图片 URL 或 `master` 分支资源。云厂商缺失图标使用仓库本地 SVG 或官方资源。 ### 6.5 UI 设计原则 @@ -482,7 +529,7 @@ WebSocket 无法设置自定义 header → 后端 `JWTAuth` 支持 `?token=` que | # | 陷阱 | 解决 | |---|------|------| -| Go1 | `go build` 超时 | `unset proxy + GOPROXY=https://goproxy.cn,direct + GOTOOLCHAIN=local` | +| Go1 | `go build` 超时 | `unset proxy + GOPROXY=https://goproxy.cn,direct + GOTOOLCHAIN=auto` | | Go2 | handler 不包裹 Response | 必须 `model.Response{Code:200, Data:...}` | | Go3 | GORM 指针字段 | `*field` 解引用后再用 | | Go4 | Update 复用 CreateRequest | 用 `map[string]interface{}` | diff --git a/CLAUDE.md b/CLAUDE.md index ccca56c..ca02bae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,27 @@ > 作者:Logan.Li > 语言:中文(代码注释和文档主要使用中文,注释署名 `// Logan.Li`) +## 规格驱动开发(SDD) + +本仓库强制使用 SDD 管理行为变更。开始编码前先按 `docs/specs/README.md` 判定变更级别并完成对应规格;S2/S3 变更使用 `docs/specs/_template.md` 创建规格文件。 + +执行顺序固定为:**Discover → Specify → Design → Implement → Verify → Deliver**。 + +- S0:纯文案、格式和无行为变化的机械修改,可不建规格文件。 +- S1:局部修复或单模块小改动,任务计划中必须包含问题、约束和可验证验收标准。 +- S2:新功能、跨模块流程、API、配置或数据模型变更,必须先落盘规格。 +- S3:认证、账单、安全、迁移、清理和生产部署,在 S2 基础上必须提供风险、兼容、可观测、发布和回滚方案。 + +硬性要求: + +1. 规格中的验收标准使用稳定编号 `AC-01`,每条必须映射到测试或人工验证证据。 +2. API 先定义请求、响应、错误码和权限;数据库先定义版本化迁移、兼容和回滚;前后端不得各自猜测契约。 +3. 实现与规格冲突时先更新规格再编码,禁止静默偏离或夹带范围外重构。 +4. 金额明确币种/汇率/精度,时间明确时区/窗口,敏感操作明确权限/审计/失败路径,高频数据明确保留与清理策略。 +5. 交付必须报告验证命令、结果、未覆盖项和剩余风险。验收未闭环时不得宣称完成。 + +`AGENTS.md` 保存完整项目约束;`docs/specs/README.md` 是 SDD 流程的唯一权威来源。若本文与权威规范冲突,以后者为准。 + ## 项目概述 **AIOps** 是一个企业级智能运维平台,聚焦三大核心能力: @@ -29,9 +50,9 @@ | 层 | 技术 | 端口 | |---|---|---| -| 后端 | Go 1.24 + Gin + GORM + MySQL 8.0 + Redis 7 + JWT + Zap | 8080 | +| 后端 | Go 1.25 + Gin + GORM + MySQL 8.0 + Redis 7 + JWT + Zap | 8080 | | 前端 | React 19 + TypeScript + Vite + Tailwind + shadcn/ui + Zustand + TanStack Query | 5173 | -| Agent | Go 1.24 (CGO_ENABLED=0 静态二进制, amd64+arm64) | — | +| Agent | Go 1.22 模块(Go 1.25 工具链,CGO_ENABLED=0 静态二进制, amd64+arm64) | — | ## 三种部署方式 @@ -117,7 +138,7 @@ cd backend # 编译 -unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=local CGO_ENABLED=1 go build -o aiops . +unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=auto CGO_ENABLED=1 go build -o aiops . # 直接运行 go run . @@ -262,7 +283,7 @@ WebSocket 无法设自定义 header → 后端 JWTAuth 支持 `?token=` query pa ### ⛔ Go 代码规范 1. **所有 handler 返回必须用 `models.Response{Code:200, Data:...}` 包裹** -2. **编译命令**: `unset proxy + GOPROXY=goproxy.cn + GOTOOLCHAIN=local + CGO_ENABLED=1` +2. **编译命令**: `unset proxy + GOPROXY=goproxy.cn + GOTOOLCHAIN=auto + CGO_ENABLED=1` 3. **新增 handler 不绑 model** — 用独立 request struct 4. **GORM 指针字段必须解引用** — `*field` 后再用 5. **Update handler 禁止复用 CreateRequest** — 用 `map[string]interface{}` 接收 @@ -276,6 +297,8 @@ WebSocket 无法设自定义 header → 后端 JWTAuth 支持 `?token=` query pa 2. **禁止 `window.confirm()`/`alert()`** — 用 `useConfirm()` + `toast` 3. **Radix Dialog 必须有 ``** 4. **Token 双保险**: `useUserStore.getState().token || localStorage.getItem('token')` +5. **图标分层**:操作、状态和导航使用 `lucide-react`;云厂商、CI/CD、监控和中间件等产品实体使用本地 `TechnologyBrandIcon` 或 `CloudBrandIcon`。 +6. **品牌图标本地化**:技术品牌图标统一从 `simple-icons` 按需导入,禁止使用 jsDelivr、CDN 或远程图片 URL;云厂商缺失图标使用仓库本地 SVG 或官方资源。 ### ⛔ UI 设计原则 @@ -316,7 +339,7 @@ WebSocket 无法设自定义 header → 后端 JWTAuth 支持 `?token=` query pa | # | 陷阱 | 解决 | |---|------|------| -| Go1 | go build 超时 | `unset proxy + GOPROXY=goproxy.cn + GOTOOLCHAIN=local` | +| Go1 | go build 超时 | `unset proxy + GOPROXY=goproxy.cn + GOTOOLCHAIN=auto` | | Go2 | handler 不包裹 Response | 必须 `models.Response{Code:200, Data:...}` | | Go3 | GORM 指针字段 | `*field` 解引用后再用 | | Go4 | Update 复用 CreateRequest | 用 `map[string]interface{}` | diff --git a/Dockerfile b/Dockerfile index 5b89fa1..998b5fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -112,13 +112,6 @@ RUN chmod +x /tmp/setup-alpine-mirrors.sh && /tmp/setup-alpine-mirrors.sh && \ /tmp/install-kubectl.sh /usr/local/bin/kubectl && \ rm -f /tmp/setup-alpine-mirrors.sh /tmp/install-kubectl.sh -# Maven 阿里云镜像加速(maven 懒加载后生效) -RUN mkdir -p /root/.m2 && \ - echo 'aliyuncentralAliyun Mavenhttps://maven.aliyun.com/repository/public' > /root/.m2/settings.xml - -# npm 淘宝镜像加速(node 懒加载后生效) -RUN npm config set registry https://registry.npmmirror.com 2>/dev/null || true - ENV TZ=Asia/Shanghai WORKDIR /app @@ -126,8 +119,13 @@ WORKDIR /app # 复制后端二进制(前端已嵌入) COPY --from=backend-builder /app/aiops . -# 创建运行时目录 -RUN mkdir -p /app/agent/bin /app/agent/deploy +# 创建非 root 运行用户及应用可写目录。工具懒加载目录由该用户写入。 // Logan.Li +RUN addgroup -S -g 10001 aiops && \ + adduser -S -D -H -u 10001 -G aiops aiops && \ + mkdir -p /app/agent/bin /app/agent/deploy /app/uploads /app/logs /app/tmp \ + /home/aiops && \ + chown -R aiops:aiops /app /home/aiops /usr/local && \ + chmod 1777 /tmp # 复制 agent 二进制。 // Logan.Li COPY --from=agent-builder /agent/bin/aiops-agent-linux-${TARGETARCH} /app/agent/bin/aiops-agent-linux-${TARGETARCH} @@ -138,6 +136,11 @@ 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/ +# 应用进程不得使用 root;部署清单还会限制 Linux capabilities。 // Logan.Li +USER 10001:10001 +ENV HOME=/home/aiops \ + TMPDIR=/app/tmp + EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index b0d6f09..61cf1fb 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -80,7 +80,6 @@ | 监控大屏(自定义仪表盘) | ✅ custom_dashboard.go CRUD | ✅ GrafanaDashboard 自定义 Tab + GrafanaPanelContent PromQL 渲染 | ✅ F4 done | | 链路追踪 | ✅ trace_proxy.go Jaeger/Tempo 代理 | ✅ TraceManager (587L) — traceApi 对接真实后端 + demo 数据兜底 | ✅ F5 done | | **中间件监控** | ✅ middleware_monitor.go 后端直连采集 Redis/MySQL | ✅ MiddlewareMonitor 完整管理页面 (CRUD + 测试 + 概览) | ✅ M15 done | -| **主机监控大屏** | ✅ serverApi 复用 | ✅ HostMonitorDashboard (Grafana 风格大屏 + CPU/内存 Top5 排行 + 统计卡片) | ✅ U13 done | ### 2.5 自动化运维 ✅ 完善 @@ -129,7 +128,7 @@ | ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done | | 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) | | ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done | -| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q38 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L→353L (-48%,提取 useLogQueryEngine + useLogExport 自定义 Hooks) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级) + TopologyView 596L→472L (-21%) + DeploymentEditAdvanced 590L→289L (-51%) + WorkloadViewPage 572L→348L (-39%) + CloudAccountManager 572L→308L + ExcelServerImport 560L→385L + PipelineCreateDialog 559L→498L + PodLogsViewer 553L→331L (-40%) + HostMonitor 547L→224L (-59%) + KubernetesClusterDetail 547L→130L (-76%) + **AlertCenter 542L→312L (-42%,提取 4 个受控组件)** + **CloudBilling 534L→295L (-45%,提取 cloud-billing/ 3 个文件)** + **PromDsFormDialog 528L→294L (-44%,提取 6 个文件: CollapsibleSection DRY)**,累计提取 119 个独立组件文件 + **predictUtils.ts 716L→49L barrel re-export (-93%)** + **k8s-yaml-utils.ts 504L→451L + k8s-helpers.ts 67L (barrel re-export 保持 8 消费者零修改)**。全项目无 ≥500 行业务组件 | +| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 | ### 3.2 🟡 中优先级 @@ -137,8 +136,8 @@ |------|------|:----------:| | 前端未使用变量/导入(ESLint no-unused-vars) | ~~218 处~~ → **0 处** | ~~P1~~ ✅ done | | 前端 console.log/console.error | ~~93 处~~ → **3 处** | ~~P1~~ ✅ done | -|| 前端 React Hooks 关键规则(exhaustive-deps/set-state-in-effect/purity/immutability/static-components 等 7 大规则) | ~~82 处回归~~ → **0 处**(U12 修复 i18n 全量改造引入的回归 + U20 修复 CredDetailDialog 内联组件 static-components 23→0) | ~~P1~~ ✅ done | -| 后端 `_ =` 忽略错误 | ~~13 处(3 处中风险)~~ → **controller/middleware 层 DB 写入零忽略**(Q4+Q5 全量治理) | ~~P2~~ ✅ done | +| 前端 React Hooks exhaustive-deps 缺失 | 38 处 | P1 | +| 后端 `_ =` 忽略错误 | 13 处(3 处中风险) | P2 | | 前端硬编码内网 IP(10.10.10.75 等) | 6+ 处 | P2 | | 后端告警引擎分布式安全 | ✅ Redis 分布式锁(SetNX + 60s TTL) | 后端 | ~~P2~~ done | @@ -146,9 +145,9 @@ | 问题 | 数量 | |------|------| -| ~~前端死代码(HostMonitorDashboard lazy 未挂载)~~ → **U13 done: 已挂载为 `/host-dashboard` 路由 + 监控菜单「主机大屏」** | ~~1 处~~ ✅ done | -| ~~前端 `set-state-in-effect`(潜在无限循环)~~ → **U12 done: 0 处** | ~~7 处~~ ✅ done | -| ~~前端 `react-hooks/purity`(render 调 Date.now)~~ → **U12 done: 0 处** | ~~6 处~~ ✅ done | +| 前端 `set-state-in-effect`(潜在无限循环) | 7 处 | +| 前端 `react-hooks/purity`(render 调 Date.now) | 6 处 | +| 前端死代码(HostMonitorDashboard lazy 未挂载) | 1 处 | | 后端 `exec.Command` + `bash -c`(命令注入面) | 18 处(需审计用户输入路径) | ### 3.4 Mock API / 未对接后端 diff --git a/README.md b/README.md index 1c7f02c..3d0851c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - CodeMirror 6 (YAML 编辑器) / xterm.js (WebSocket 终端) ### 后端 -- Go 1.24 / Gin Web Framework +- Go 1.25 / Gin Web Framework - GORM (ORM) / MySQL 8.0 / Redis 7 / SQLite (local 模式) - JWT 认证 / Zap 日志 / Viper 配置 - WebSocket (Gorilla) / PTY 终端 (creack/pty) @@ -23,7 +23,7 @@ - Agent CLI 引擎(`pkg/agentcli/`):ReAct + Evolver + 双协议 LLM(OpenAI / Anthropic) ### Agent -- Go 1.24 静态编译 (CGO_ENABLED=0) +- Go 1.22 模块(Go 1.25 工具链)静态编译 (CGO_ENABLED=0) - /proc 指标采集 + 远程任务双通道 - 支持 linux-amd64 / linux-arm64 / darwin-arm64 diff --git a/ROADMAP.md b/ROADMAP.md index 2e9aca7..e5ac329 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -473,658 +473,6 @@ services/api.ts (1324L) | F6 | 日志告警引擎 | P1 | **done** | | M15 | 中间件监控 (Redis/MySQL) | P1 | **done** | | U5 | Kubernetes 模块国际化 | P3 | **done** | -| U12 | 工程质量回归修复(ESLint errors + React Hooks 关键规则) | P3 | **done** | -| Q4+ | 后端错误处理治理扩展(Q5: RDP/API Token/AIOps 模块 `_ =` DB 写入) | P0 | **done** | -| U13 | 主机监控大屏挂载(HostMonitorDashboard 死代码复活) | P3 | **done** | -| U14 | ESLint 冗余 disable 指令清理(unused eslint-disable 6→0) | P3 | **done** | -| Q6 | 前端巨型组件拆分(ResourceVisualEditor 882L→282L,提取 5 个独立编辑器) | P0 | **done** | -| Q7 | 前端巨型组件拆分(AgentCLI 814L→521L,提取工具栏+常量+空状态+滚动按钮) | P0 | **done** | -| Q8 | 前端巨型组件拆分(SystemSettingsTab 754L→173L,提取通用/安全/LDAP 3 个子 Tab) | P0 | **done** | -| Q9 | 前端巨型组件拆分(NamespaceResourcePage 719L→300L,提取 RESOURCE_CONFIG + PodDetailDialog) | P0 | **done** | -| Q10 | 前端巨型组件拆分(ApprovalWorkflowPage 705L→167L,提取 types + UserSelectInline + WorkflowEditorModal + WorkflowCard) | P0 | **done** | -| Q11 | 前端巨型组件拆分(PipelineRunDetail 678L→273L,提取 types + StageProgressBar + RunsTable + LogConsole) | P0 | **done** | -| Q12 | 前端巨型组件拆分(CloudServerImport 699L→404L,提取 StepIndicator + 4 个向导步骤组件 + types,any[]→具体接口) | P0 | **done** | -| Q13 | 前端巨型组件拆分(UnifiedQueryPanel 682L→529L,提取 constants + entry-mappers + 5 个 UI 子组件) | P0 | **done** | -| Q14 | 前端巨型组件拆分(ServerManager 652L→442L,提取 ServerTableSection + server-export-utils) | P0 | **done** | -| Q15 | 前端巨型组件拆分(PipelineEditor 676L→406L,提取 types + TaskCard + TaskEditorPanel + PipelineLogDialog + RunsHistoryTab + PipelineMembersTab) | P0 | **done** | -| Q16 | 前端巨型组件拆分(RdpDialog 642L→442L,提取 types + rdpp-utils + RdpDialogHeader) | P0 | **done** | -| Q17 | 前端巨型组件拆分(DeploymentEditService 627L→394L,提取 types + ServiceCreateForm + IngressCreateForm + ServiceListItem + IngressListItem) | P0 | **done** | -| Q18 | 前端巨型组件拆分(TraceManager 605L→331L,提取 types + trace-helpers + SpanTreeNode + TraceDetailDialog + SpanDetailDialog,修复 SpanTreeNode 内联定义反模式) | P0 | **done** | -| Q19 | 前端巨型组件拆分(GrafanaDashboard 624L→78L,提取 types + datasource-toolbar + BatchDeployDialog + AgentTab + ExternalDatasourceTab,DRY 合并 Zabbix/Prometheus Tab,移除文件级 eslint-disable,类型安全升级 any→具体接口) | P0 | **done** | -| Q20 | 前端巨型组件拆分(TopologyView 596L→472L,提取 types + TopologyCanvas + NodeDetailDialog + EditNodeDialog,移除文件级 eslint-disable,any→具体接口类型) | P0 | **done** | -| Q21 | 前端巨型组件拆分(DeploymentEditAdvanced 590L→289L,提取 types(parser+builder 纯函数) + UpdateStrategyCard/TolerationsCard/PodMiscCard/PodSecurityCard/HostSettingsCard) | P0 | **done** | -| Q22 | 前端巨型组件拆分(WorkloadViewPage 572L→348L,提取 types(parseWorkloadStats 纯函数) + shared-components(StatCard/LabelBadges/ContainerInfo) + UpdateStrategySelectorCards + PodInstancesTable + ScaleDialog) | P0 | **done** | -| Q23 | 前端巨型组件拆分(CloudAccountManager 572L→308L,提取 cloud-account/types(AccountFormData/AccountStats + 6 个纯函数) + AccountCard(178L) + AccountFormDialog(162L)) | P0 | **done** | -| Q24 | 前端巨型组件拆分(ExcelServerImport 560L→385L,提取 excel-import/types(6 个 CSV 纯函数) + StepIndicator + PreviewTable) | P0 | **done** | -| Q25 | 前端巨型组件拆分(PipelineCreateDialog 559L→498L,提取 pipeline-create/types(PRESETS/DEFAULT_TARGET) + AdvancedSettingsPanel(172L 受控展开面板)) | P0 | **done** | -| Q26 | 前端巨型组件拆分(PodLogsViewer 553L→331L,提取 pod-logs-viewer/types(highlightKeyword 纯函数) + LogsHeader + ControlBar + LogContentArea + BottomStatusBar) | P0 | **done** | -| Q27 | 前端巨型组件拆分(HostMonitor 547L→224L (-59%),提取 host-monitor/tabs/ 8 个文件 (BasicTab + DiskTab + NetworkTab + PortMonitorTab + ProcessTable + FDUsageCard + ContextSwitchCard + ProcessTab),TABS 常量数组提取为模块级,PCT_REF_LINES 常量提升到 types.ts,端口/进程数组类型 JsonData→JsonData[] 类型安全升级) | P0 | **done** | -| Q28 | 前端巨型组件拆分(KubernetesClusterDetail 547L→130L (-76%),提取 cluster-detail/ 8 个文件 (types(computeClusterMetrics/buildTrendData 纯函数) + OverviewTab + NodesTab + PodsTab + DeploymentsTab + ServicesTab + IngressesTab + EventsTab)) | P0 | **done** | -| Q29 | 前端巨型组件拆分(AlertCenter 542L→312L (-42%),提取 4 个受控组件 (AlertsToolbar 89L 筛选工具栏 + AlertsTab 149L 告警列表表格+分页 + ContactsTab 87L 联系人卡片网格 + ContactGroupsTab 88L 联系组卡片网格),客户端搜索过滤逻辑移入 AlertsTab,删除确认逻辑下沉到子组件) | P0 | **done** | -| Q30 | 前端巨型组件拆分(CloudBilling 534L→295L (-45%),提取 cloud-billing/ 3 个文件 (OverviewTab 110L 概览统计:同步信息+成本告警横幅+趋势图+厂商分布 + RecordsTab 146L 成本记录表格+RecordsFilterToolbar筛选工具栏 + AlertRulesTab 91L 预警规则卡片网格)) | P0 | **done** | -| Q31 | 前端巨型组件拆分(PromDsFormDialog 528L→294L (-44%),提取 6 个文件 (CollapsibleSection 42L 通用折叠包装器 DRY 3 个重复折叠模式 + AuthSection 107L 认证折叠 + HeadersSection 64L 自定义Headers + AlertingSection 58L 告警折叠 + MiscSection 47L 杂项折叠 + TestResultBanner 32L 测试结果横幅),3 个折叠区块全部使用共享 CollapsibleSection 消除重复 JSX)) | P0 | **done** | -| Q32 | 前端巨型组件拆分(AuditOperationsTab 527L→275L (-48%),提取 audit-operations/ 3 个文件 (types 43L normalizeLog 纯函数+AuditStatistics 接口 + LogDetailDialog 85L 详情弹窗+DetailRow + StatisticsDialog 108L 统计弹窗+StatCard),清除死代码 useConfirm/_confirm/_handleShowStats/statsOpen/statistics/statsLoading,ESLint errors=0/any=0,tsc -b=0,npm build✓,go build=0,17/17 测试通过) | P0 | **done** | -| Q33 | 前端巨型组件拆分(DashboardRecent 518L→25L (-95%),提取 dashboard-recent/ 7 个文件 (types 48L + shared-components(StatusDot/SeverityBadge) 35L + AlertMessagesCard 72L + CertificateExpiryCard 99L + ReleaseHistoryCard 99L + OpsOverviewCard 77L + KubernetesClusterCard 97L),主文件改为 barrel re-export 保持向后兼容,tsc -b=0,npm build✓,ESLint=0/0/0,go build=0,17/17 测试通过) | P0 | **done** | -| Q34 | 前端巨型组件拆分(AIOpsAssistant 505L→288L (-43%),提取 aiops-assistant/ 5 个文件 (types 39L Message/QuickAction/defaultConfig/fmtTime + context-loaders.ts 69L loadAlerts/loadMetrics/loadReport 3 个平台数据加载纯函数 + quick-actions.tsx 17L + ChatMessage 47L 消息气泡(含 streaming/thinking/error 状态) + AssistantSidebar 76L 快捷操作+能力卡片),tsc -b=0,npm build✓,ESLint=0/0/0,go build=0,17/17 测试通过) | P0 | **done** | -| Q35 | 前端巨型组件拆分(AgentCLI 521L→399L (-23%),提取 agent-cli/ 2 个文件 (event-handler.ts 149L createWsEventHandler 工厂函数封装 8 种 WS 事件分发+高危命令确认 + ChatInputArea.tsx 44L 受控输入区组件),导出 ConfirmOptions 接口供跨模块引用,tsc -b=0,npm build✓,ESLint=0/66/0,go build=0,17/17 测试通过。最后一个 ≥500L 业务组件拆分完成,剩余最大业务组件 UnifiedQueryPanel 529L(已从 682L 拆分)) | P0 | **done** | -| Q36 | 前端巨型工具文件拆分(predictUtils.ts 716L→49L barrel re-export (-93%),提取 predict-types.ts 140L 类型+常量 + capacity-predict.ts 194L 容量预测函数(linearRegression+computePredictions+buildCapacityInsightPrompt) + cost-predict.ts 434L 云成本预测函数(computeCostPredictions+buildCostInsightPrompt),3 个消费者 import 路径无需修改,tsc -b=0,npm build✓ (17.26s),ESLint=0/66/0,go build=0,17/17 测试通过) | P0 | **done** | -| U15 | ApiAccessKeySection i18n 迁移(清理 56 个 apiaccesskey.s{hash} 乱码键为语义化 user.apiKey.* 键 + 组件全量接入 useTranslation,UI 中文 57→0,zh/en 7556=7556 对齐,零 hash 键残留) | P3 | **done** | -| Q37 | 前端巨型工具文件拆分(k8s-yaml-utils.ts 504L→451L + k8s-helpers.ts 67L 新文件,提取 7 个通用工具函数 parseScalar/STATUS_COLORS/statusColor/aggregateByImage/formatK8sTime/elapsedSeconds/formatDuration,barrel re-export 保持 8 个消费者零修改) | P0 | **done** | -| Q38 | 前端巨型组件拆分(UnifiedQueryPanel 529L→353L (-33%),提取 unified-query/useLogQueryEngine.ts 295L 查询执行引擎 Hook + useLogExport.ts 62L 导出/复制 Hook,全项目最后一个 ≥500L 业务组件清零) | P0 | **done** | -| U17 | K8s 残余组件 i18n 补全(NodeDetailPage 16 处硬编码中文清零 + 8 个 hash 键替换为语义化 k8s.nodeDetail.* 键 + DeploymentWorkload 12 处 toast/confirm 硬编码中文清零,zh/en 7564→7597 +33 键对齐) | P3 | **done** | -| U19 | i18n hash 键清理(删除 866 个死 hash 键 + 重命名 21 个高频 live hash 键为语义化键名,zh/en 7597→6731 keys 对齐,文件 328KB→276KB (-16%),tsc -b=0,npm build✓,ESLint=0/66/0,17/17 测试通过) | P3 | **done** | -| U20 | ESLint static-components 清零 + CredDetailDialog 内联组件修复 + i18n 批量迁移损坏修复(CredDetailDialog.tsx `Field` 内联组件提取为模块级 `CredField` — 23 处 react-hooks/static-components 警告清零;修复 8 处被批量正则损坏的 `i18n.t()` 调用(`i18n.t('>名称:')` 等 → 语义化键);6 处硬编码中文清零;新增 `deploy.cred.sensitiveWarn` 键;ESLint warnings 66→43 (-35%),tsc -b=0,npm build✓ (17.29s),17/17 测试通过) | P3 | **done** | -| U23 | Task 模块 i18n 治理(CronJobManager + CronJobFormDialog + ScriptManager + RemoteExecPage 4 文件 — `import i18n` 全局调用 → `useTranslation()` hook;42 处 phantom key (`i18n.t('Chinese text')` → 语义化 `task.cronJob.*`/`task.scriptList.*`/`task.remoteExec.*` 键);28 个孤儿 hash 键清理 (cronjobformdial.s*/remoteexecpaget.s*/systemtaskspanel.s*);50+ 处硬编码 JSX 中文 → t() 调用;zh/en 6825→6886 +61 键对齐,tsc -b=0,npm build✓ (17.46s),ESLint=0/45/0,go build=0,17/17 测试通过) | P3 | **done** | -| U24 | Monitor 模块 i18n hook 迁移(26 个 .tsx 文件从 `import i18n` 全局 → `useTranslation()` hook + 89 个 phantom 中文文本键 → `monitor.lbl.*` 语义化键;299 处 `i18n.t()` 调用 → 74 处保留(模块级/.ts 工具文件) + 225 处迁移到 `t()` hook;1 个 .ts 工具文件 helpers.ts 中文文本键替换为语义化键;zh/en 6886→6975 +89 键对齐,tsc -b=0,npm build✓ (17.16s),ESLint=0/53/0,go build=0,Docker healthy) | P3 | **done** | -| U26 | Kubernetes 模块 phantom key 大规模清理(32 个 .tsx/.ts 文件,171 个中文 phantom key → 8 个残留(均为 ANSI 转义码/JSX 表达式误匹配),163 个成功替换为 k8s.* 语义化键;新增 19 个子命名空间(nsResource/nsOverview/deploymentWorkload/deploymentView/workloadView/controlBar/clusterOverview/nodes/podLogs/podExec/pvc/configMap/secret/service/ingress/nsMgmt/editContainers/capacityPage/podFile)+ 扩展 8 个已有子命名空间;zh/en 6990→7185 +195 键对齐,k8s 命名空间 65 子命名空间/940 键,tsc -b=0,npm build✓ (17.59s),ESLint=0/53/0,go build=0,17/17 测试通过) | P3 | **done** | -| U27 | 多模块 phantom key 批量清理(Cloud/Deploy/Agent/Monitor/System 5 模块 25 个文件,180 个中文 phantom key → 51 个残留,129 个成功替换为语义化键;新增 12 个子命名空间(cloud.platform/deploy.buildNodes/deploy.rbac.policy/deploy.stage/deploy.target/deploy.userGroup/agent.alerts/agent.monitor/agent.alertHelper/agent.ruleDialog/monitor.alertRulesTab/system.ldapMgmt 等);硬编码 JSX 中文(刷新/添加/启用/禁用/Master内置/执行器/标签等) → i18n.t() 调用;zh/en 7185→7294 +109 键对齐,tsc -b=0,npm build✓ (16.31s),ESLint=0/0/0,go build=0,17/17 测试通过) | P3 | **done** | -| U30 | i18n 批量转换损坏调用修复(8 处损坏的 i18n.t() 调用修复 — DeploymentEditContainers.tsx:85 toast description 损坏 / DeploymentEditStorage.tsx:175,202 !manifest 空状态+数据卷副标题损坏 / AgentRuleDialog.tsx:111,157,183,195 DialogDescription+操作符/端口/持续时间 Label 损坏 / LogFieldSidebar.tsx:68 搜索 placeholder 损坏。损坏原因: 早期批量正则 i18n 转换将模板表达式/JSX 碎片错误包裹进 i18n.t() 调用。新增 6 个语义化键(agent.ruleDialog.description/operator/port/duration + kubernetes.storage.volumeDesc + monitor.lbl.searchFields),zh/en 7375→7381 +6 键对齐,tsc -b=0,npm build✓ (17.99s),ESLint=0/53/0,go build=0,17/17 测试通过) | P3 | **done** | -| U31 | AIOps Agent CLI 组件 i18n 修复(AgentCLI + AgentCLIToolbar + AgentCLIHistory + AgentCLIEmptyState + ChatInputArea + agent-cli-constants.ts 6 文件 — Q7/Q35 拆分后丢失的 i18n 集成全部修复,新增 `aiops.cli.*` 命名空间 100 翻译键(8 子分类: toolbar/safety/tips/error/info/viewMode/placeholder/history/empty/input),SAFETY_MODES+TIPS_HOST+TIPS_KUBERNETES 常量数组改为 `getSafetyModes(t)`/`getTipsHost(t)`/`getTipsKubernetes(t)` 接收 TFunction 参数的工厂函数,全部 UI 硬编码中文清零,zh/en 7381→7481 +100 键对齐,tsc -b=0,npm build✓ (18.21s),ESLint=0/53/0,go build=0,17/17 测试通过) | P3 | **done** | -| U32 | 全量 hash key 清理 + React Hooks 警告修复(151 个 hash-style flat key (s+hex 后缀) 全部重命名为语义化 nested key — monitor 58/kubernetes 42/aiops 17/agent 12/cloud 13/pipeline 6/system 3;58 个源文件 147 处 t()/i18n.t() 调用更新;3 个命名空间冲突修复(monitor.hostMonitor/agent.agentMonitor/aiops.aiAnalysisResult 原 string → .title 子键);React Hooks 警告 53→43 (-10): exhaustive-deps 4→0 + preserve-manual-memoization 5→0 + 变量遮蔽修复(filter(t)→filter(tg));zh/en 7480=7480 完全对齐 0 hash key 残留;tsc -b=0,npm build✓ (17.59s),ESLint=0/43/any=0,go build=0,17/17 测试通过) | P3 | **done** | -| U33 | K8s 资源编辑器 + Pod 日志/Metrics i18n 补全(resource-visual-editors/ 5 文件: ConfigMap/Secret/Service/Ingress/PvcEditor 按钮文案+帮助文本+访问模式+IngressClass 提示全部 i18n;pod-logs-viewer/ 3 文件: ControlBar 导出/刷新/实时/停止+LogContentArea 加载中+LogsHeader 标题;PodLogsViewer 6 处错误消息+DialogTitle/Description;PodMetricsPage subtitle+表头+所有命名空间;17 处 toast description 模板字面量中文→i18n.t() 插值 (DeploymentEditAdvanced/Containers/Storage/View/Detail/Service/ResourceVisualEditor/NamespaceResourcePage/ClusterOverviewPage);5 处 confirm description (DeploymentView/EditService/Detail/WorkloadViewPage/MiddlewareMonitor/SiteMonitor/HealthProbeSection);5 处 subtitle 模板字面量 (PodMetricsPage/CapacityPage/EventsPage/KubernetesNodes/ClusterOverviewPage);新增 k8s.rve 14键+k8s.toast 18键+k8s.podMetrics 4键+k8s.podLogs 13键 共 49 翻译键;zh/en 7481→7524 +43 对齐;tsc -b=0,npm build✓ (17.76s),ESLint=0/43/any=0,go build=0,17/17 测试通过) | P3 | **done** | -| U34 | System & User 模块 i18n 清理(ProfilePage + ResetPasswordDialog + SystemRolesTab + LdapManagementSection 4 文件 — ProfilePage 从零接入 useTranslation + ResetPasswordDialog 从零接入 useTranslation + SystemRolesTab 权限分组标签(云资源/自动化/审批/审计)+toast+confirm description+角色计数+空状态+权限列表计数+编码标签+尾部文案全部 i18n + LdapManagementSection 验证按钮+根OU标签+创建按钮+导入按钮+编辑用户描述全部 i18n;新增 user.profile.* 14键+user.resetPwd.* 11键+system.* 16键 共 41 翻译键;UI 硬编码中文 ProfilePage 15→0 + ResetPasswordDialog 11→0 + SystemRolesTab 12→0 + LdapManagementSection 8→0;zh/en 7524→7565 +41 对齐;tsc -b=0, npm build✓ (16.05s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U35 | Deploy + Task 模块残余 JSX 硬编码中文清理(PipelineRunPanel 12处 + CronJobTable 12处 + ExecutionHistoryDialog 10处 + ScrollButtons 2处 — 新增 deploy.runStatus.* 12键 + task.cronJobCol.* 14键 + task.execHistory.* 14键 + deploy.credCol.* 9键 + common.scrollToTop/scrollToBottom/success/failed/total 5键 共 54 翻译键;zh/en 7565→7616 +51 对齐;tsc -b=0, npm build✓ (17.02s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U36 | 全量 JSX 属性 + toast 硬编码中文清零(20 文件 30 处→0: TerminalLoadingOverlay/copy-button/AppRelease/DeployCredentials/PipelineManager/WorkspaceTermDialog/CredFormDialog/JobRoleCreateDialog/PipelineCreateDialog/AdvancedSettingsPanel/ServerForm/LdapManagementSection/LiveMonitorDialog/SystemManager/ActiveSessionsPanel/HistorySessionsPanel/AlertRulesTab/PrometheusDatasourceManager/SNMPMonitor/PredictiveCapacityTab — 23 JSX 属性 + 7 toast 调用全量清零, copy-button 从 import i18n→useTranslation hook, 4 组件新增 useTranslation hook;新增 25 翻译键;zh/en 7616→7640 +24 对齐;tsc -b=0, npm build✓ (17.25s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U38 | i18n phantom CTK 清理 — 模板字面量 + JSX 属性残余中文清零(13 文件 16 处→0: LogsHeader "行"suffix + analyzerHelpers "当前值" + DeploymentEditStorage 2 toast description + WorkloadViewPage 2 toast description + ServerFileBrowser confirm + LogDatasourcePage toast + LogAlertsPanel confirm + DeployTargetFields JSX hint + UserGroupsManager 3 处 confirm/desc + BuildNodesManager JSX "(可选)" + useLdapManagement confirm — 新增 14 语义化翻译键 (common.lineSuffix/currentValue/memberCount/fillIfMultiContainer/confirmDeleteType + k8s.editStorage.*Desc + k8s.workloadView.restartSentDesc + k8s.workloads.scaleSuccessDesc + aiops.reachable + monitor.deleteRuleConfirmDesc + deploy.rbac.deleteGroupDesc + pipeline.removeMemberDesc + system.ldapMgmt.deleteEntryDesc),zh/en 7737→7751 +14 对齐;tsc -b=0, npm build✓ (16.86s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U39 | 残余 JSX 属性/模板字面量中文清零(10 文件 14 处→0: DualLineChart subtitle 接收/发送 + NetworkTab 5处 unit="个" + FDUsageCard subtitle 当前 + NamespaceOverviewPage/NamespaceManagementPage subtitle + CreateHPADialog title+text 命名空间 + LogFieldSidebar title 个值 + LiveMonitorDialog title 全屏/退出全屏 + ExecutionHistoryDialog title 成功/失败 + LdapSettingsTab className 成功检查→ldapTestSuccess boolean 状态替代字符串匹配 — 新增 11 翻译键, zh/en 7751→7762 对齐;tsc -b=0, npm build✓ (16.94s), ESLint=0/0/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U40 | Monitor + Agent 子组件残余硬编码中文 i18n 补全(14 文件 ~50 处→0: ContextSwitchCard CTX/IRQ/次 + ProcessTable agentWaitData/cpuUsagePercent + FDUsageCard fdHandles + PortMonitorTab 黑名单/白名单/策略配置/暂无端口数据 + PortPolicyDialog 白名单全量上报/黑名单仅上报选中/端口列表/逗号分隔/白名单无需配置/已输入端口 + HostTimeRangePicker 时间范围/自定义/取消/应用 + DualLineChart 接收/发送 (i18n.t→useTranslation hook) + ZoomableAreaChart 暂无数据 + SiteResultsPanel 选择任务/次/可用率/立即拨测/SSL证书预警/到期/剩余/阈值/天 + MiddlewareMonitor Redis单机集群/上次更新/添加平台默认/暂无实例 + SiteMonitor subtitle + SNMPMonitor 标题/采集间隔/IP地址/OID模板/SNMP版本/确认/暂无设备/采集指标/暂无指标 + HealthProbeSection TCP目标地址/TCP端口 + AgentAlerts Agent告警/刷新/未恢复/已恢复/恢复/新建规则/创建第一条规则 — 新增 monitor.lbl 46 语义化翻译键 + agent.alerts 6 键, zh/en 7762→7814 对齐;tsc -b=0, npm build✓ (16.88s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U41 | i18n 残余硬编码中文清零 — 工具函数 + 审计RDP标签 + AIOps分析 + Monitor残余 + 审批选项(18 文件 ~35 处→0: agent-utils.ts timeAgo 秒前/分钟前/小时前 + utils.ts formatDistanceToNow 分钟前/小时前/天前 + request.ts 响应解析失败 + ActiveSessionsPanel RDP在线会话 + HistorySessionsPanel RDP历史会话/RDP会话共N条 + MetricsExplorer 清除/已选/最近1小时 + HostMonitor 当前主机 + AIAnalysisResult 证据链(useTranslation hook) + capacity-predict.ts 容量预测摘要模板 + ApprovalResourcePage 阿里云/腾讯云/华为云 + ApprovalPermissionPage 堡垒机 + site-monitor/helpers.ts 推荐频率/秒/分钟/小时/天 + site-monitor/analytics.ts 证书剩余/失败/延迟波动 + middleware-monitor/MwTargetChartsPanel 选择实例/已用/集群节点/慢查询 + MwTrendChart 暂无趋势数据 + middleware-monitor/helpers.ts 相对时间 + snmp-monitor/SnmpMetricTrendChart 暂无趋势数据等待采集 + SnmpDeviceChartsPanel 点击设备/可观测/刷新/暂无指标 + host-monitor/utils.ts 图表点数 + PortPolicyDialog 系统进程端口说明 — 新增 common.duration.secAgo/hourAgo/dayAgo + common.selected/responseParseError/rdpActiveSessions/rdpHistorySessions/rdpSessionCount/evidenceChain/last1Hour/currentHost + monitor.lbl 10键 + monitor.siteMonitor 8键 + approval.provider 3键 + approval.permType 1键 + monitor.portPolicyDialog.systemProcessDesc + aiops.capacity.summary 共 34 翻译键, zh/en 7813→7847 对齐;tsc -b=0, npm build✓ (17.31s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U43 | i18n 深度残余硬编码中文 UI 文本清零 — 趋势标签 + K8s 卡片标题 + data-table 默认值 + 趋势文本 + 卡片描述(17 文件 ~40 处→0: AIPredictiveAnalytics 上升/下降/平稳 + PredictiveCostTab 上升/下降/平稳×2 + 当前/阈值/预测 + PredictiveCapacityTab 容量规划预警 + data-table.tsx 搜索.../暂无数据默认值(useTranslation hook) + PodMiscCard Pod 级别 + PodSecurityCard Pod 安全上下文 + HostSettingsCard 4 个主机命名空间描述 + PodInstancesTable Pod 实例 + PodDetailDialog Pod 详情 + HostDetailDialog 指标趋势/端口发现/远程命令/关闭 + DeploymentEditContainers manifest 提示/镜像拉取凭证 + DeploymentEditStorage 数据卷/空状态/挂载/只读/读写 + ClusterOverviewPage 管理全部 + ResourceVisualEditor 新建/编辑 + AgentMonitor 在...上执行命令 + 7 个 K8s 页面刷新按钮(PodMetrics/Capacity/Events/NamespaceResource/NamespaceMgmt/ClusterOverview/KubernetesNodes) + BillingRecord/BillingRuleDialog 保存成功 toast/保存按钮 — 新增 common 30 语义化翻译键(trendUp/trendDown/trendStable/executeOn/podLevel/podSecurityContext/hostNetworkDesc/hostIPCDesk/hostPIDDesk/shareProcessNamespaceDesc/podInstances/podDetail/metricTrend/portDiscovery/remoteCommand/searchPlaceholder/imagePullSecrets/noManifestHint/volumeDefinition/noVolumeHint/mountToContainers/readOnly/readWrite/manageAll/createNew/saveSuccess), zh/en 7898→7928 对齐;tsc -b=0, npm build✓ (16.55s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U44 | i18n 残余硬编码中文清零 — 终端审计状态 + 凭证类型 + 流水线阶段 + 时间格式 + CSV 导出(20 文件 ~70 处→0: credential-manager/constants.ts 8 凭证类型 label/desc → labelKey/descKey + i18n.t() + pipeline-run-detail/types.ts 6 阶段 label → i18n.t('deploy.stage.*') + labelKey + pipeline-manager/constants.ts + pipeline-editor/types.ts 分秒 duration → i18n.t('common.duration.minSec') + LiveMonitorDialog 6 终端状态消息 → t() + RdpAuditViewer 6 RDP审计状态 → i18n.t('monitor.rdp.*') + WorkspaceTermDialog 2 终端连接状态 → t() + TerminalSessionsTab 4 时长格式+状态标签 → i18n.t()/t() + k8s-event-utils 3 相对时间 → i18n.t('common.duration.*Ago') + QueryHistoryPanel 3 相对时间 → i18n.t('common.duration.*Ago') + export-audit.ts 9 CSV导出表头 → i18n.t('system.auditExport.*') + useAuditSettings 4 审计保留策略消息 → i18n.t('system.auditSettings.*') + event-handler.ts 4 AgentCLI事件消息 → i18n.t('aiops.cli.*') + NotifyPolicyTab 默认平台名 → i18n.t('monitor.notify.defaultPlatform') + RoleFormDialog AIOps智能分组 → i18n.t('system.role.aiopsGroup') + CronJobViewDialog 关联脚本/无命令内容 → t() + TerminalOutput 无输出 → i18n.t('task.terminal.noOutput') + shared-components 内存标签 → i18n.t('k8s.workloadView.memoryLabel') + prometheus-ds/helpers 连接成功 → i18n.t('monitor.prometheus.connSuccess') + CloudServerImport 发现主机失败 → t() + AdvancedSettingsPanel Job名称 → t('deploy.jobName') + releaseManagerHelpers 4 环境标签硬编码中文移除(已有ENV_LABEL_KEY) — 新增 75 翻译键 (deploy.credType 8+credTypeDesc 8+fmtDuration 1+terminal 2+jobName 1, monitor.terminal 6+rdp 6+notify 1+prometheus 1, deploy.terminal 2, common.duration 2, system.terminalSession 4+csvHeader 7+auditExport 9+auditSettings 4+credTypeLabel 3+role 1, task.cronJob 2+terminal 1, k8s.workloadView 1, aiops.cli 5, server.import 1), zh/en 7987→8059 +72 对齐;tsc -b=0, npm build✓ (17.13s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U42 | i18n 残余硬编码中文彻底清零 — 全量 JSX children + toast/confirm + sr-only 描述(33 文件 54 处→0: PredictiveCapacityTab AI深度解读/紧急/警告/7天使用率预测/预计N天/后达到阈值/AI建议 + PredictiveCostTab AI深度解读 + event-handler.ts 高危操作确认 toast + DeployCredentials 启用/禁用 + DeployRBAC 删除角色 confirm + WorkspaceTermDialog 标题 + CredFormDialog Windows用户 + ClusterOverviewPage 核 + DeploymentEditContainers Pod级别 + DeploymentEditService 无selector/个关联/Ingresses应用路由/暂无Service/暂无Ingress/自动高亮 + DeploymentEditStorage 无容器 + KubectlTerminal sr-only + KubernetesClusterDialog 已配置 + ResourceVisualEditor YAML编辑 + CreateHPADialog CPU目标 + UpdateStrategyCard maxSurge/maxUnavailable/revisionHistoryLimit/progressDeadlineSeconds 说明 + IngressCreateForm host域名/Service端口 + ServiceCreateForm 端口配置/个端口/selector自动检测 + PodInstancesTable Pod名称 + UpdateStrategySelectorCards Pod管理策略 + AlertRulesTab 删除confirm/分钟 + PrometheusDatasourceManager 删除confirm + LogDatasourcePage 删除confirm + QueryHistoryPanel 条 + ServerFileBrowser sr-only + TerminalDialog sr-only + CredentialManager 删除confirm + RdpAuditViewer sr-only + SystemManager 删除用户/批量删除 confirm + TerminalSessionsTab 删除confirm + SecuritySettingsTab 30分钟/2小时 + CronJobViewDialog 次 — 新增 k8s.editAdvanced 6键+k8s.editService 11键+k8s.cluster 2键+k8s.clusterDialog 1键+k8s.kubectlTerminalDesc/k8s.rve.yamlEditBtn/k8s.hpa.cpuTarget/k8s.podLevel/k8s.podNameLabel/k8s.podManagePolicy 共 28键 + aiops.capacity 8键+aiops.cost 1键+aiops.cli 2键 + deploy.cred.windowsUser/deploy.workspaceTerminalTitle/deploy.rbac.deleteRoleDesc/deploy.cred.badgeDefault 4键 + monitor.deleteRulesConfirmDesc/deleteDatasourceConfirmDesc/deleteLogDsConfirmDesc/durationMinutes/queryHistory.count 5键 + system.rdpLiveAudit/rdpHistoryReplay/rdpAuditDesc/sshTerminalDesc/sftpFileDesc/sftpFileLabel/deleteUserConfirmDesc/batchDeleteUsersConfirmDesc/deleteCredConfirmDesc/deleteTermSessionsConfirmDesc 10键 + task.cronJob.runCount 1键 + common.minutes30/hours2 2键, zh/en 7847→7898 对齐;tsc -b=0, npm build✓ (17.71s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U45 | AI 告警分析模块 i18n 补全(analyzerHelpers.ts 6 告警类型 × ~4 模板字符串 = 25 处硬编码中文 rootCause/impact/recommendations→i18n.t() 插值 + mapAlertEvent locale 硬编码 zh-CN→动态 i18n.language + 事件ID比较硬编码→i18n.t(defaultEvent) + AIPredictiveAnalytics 预测图标中文-only includes→match() 双语兼容 — 新增 aiops.analyzer 26 翻译键, zh/en 8059→8085 对齐;tsc -b=0, npm build✓ (17.40s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U46 | i18n 残余 UI 硬编码中文清零 — 深度边角(6 文件 12 处→0: UserFormDialog '新建用户' + CredentialManager '显示' title + RdpAuditViewer RDP实时审计/RDP历史回放/录屏帧解码失败 + LiveMonitorDialog 连接超时提示 + ServerFileBrowser prompt输入目录名 + context-loaders.ts AI上下文模板字符串(alertSummary/alertLine/metricLine/highLoadHosts/agentMetricsSummary/platformOverview/highLoadAssets/recentAlerts/recentReleases/overviewFallback) 全量 i18n.t() 插值 — 新增 common.show/dirNamePrompt + system.createUser/rdpFrameError/rdpHistoryReplayId/connectTimeoutActive + aiops.ctx 11 键 共 17 翻译键, zh/en 8085→8102 对齐;tsc -b=0, npm build✓ (18.05s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U47 | i18n 最后 3 处残余硬编码中文清零 — 全量 CJK 扫描验证(cloud-server-import/types.ts 阿里云/腾讯云/华为云→i18n.t('cloud.*') + TerminalSessionsTab.tsx 批量删除→t('system.terminalBatchDelete') + PreviewTable.tsx 类型→t('common.type') — **全前端非注释 CJK 字符 = 0**, 复用已有翻译键零新增, zh/en 8102=8102 对齐;tsc -b=0, npm build✓ (18.02s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| F7 | 集群详情 EventsTab 真实后端对接(DEMO_EVENTS 硬编码→kubernetesApi.getEvents 真实 K8s 事件 + 复用 normalizeK8sEvent/formatEventRelative 工具 + loading/empty 状态 + 刷新按钮 + Warning 优先排序 + 限 100 条 — 修复 Q28 拆分时遗留的 demo 数据对接缺口 + 修复 aiops_alert_analysis.go log.Printf %s→%d go vet 失败) | P1 | **done** | -| U48 | i18n locale 懒加载优化(en-US.json 静态导入→动态导入独立 chunk — 主 index chunk 696KB→423KB (-39%/-273KB),en-US chunk 290KB 按需懒加载,Vite 600KB 警告消除;zh-CN 默认语言保持内联无行为变化,已选英文用户刷新页面异步加载有短暂中文闪烁;ensureLocaleLoaded() + i18n.addResourceBundle 按需加载模式;setLanguage 调用 ensureLocaleLoaded 确保语言包加载完成后再切换;tsc -b=0, npm build✓ (18.18s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U49 | cost-predict.ts i18n 深度治理 — hash key 清理 + 错误 key 修复 + 硬编码中文清零(23 个 predictutilsts.s* hash 键重命名为 costPredict.* 语义化键 + 7 处 buildCostInsightPrompt 硬编码中文模板字面量→i18n.t() 插值 + 修复 costRecommendation 引用 predictMemLeak(内存泄漏建议)→costPredict.rec* 成本建议 + 修复 dimension 引用 predictDiskFull(磁盘清理)→costPredict.allProviders(全部) + 修复 providerLabel 引用 predictIn24h(24小时后)→costPredict.allProviders — 新增 34 语义化翻译键 aiops.costPredict.*, zh/en 8102→8113 对齐;tsc -b=0, npm build✓ (19.29s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | -| U37 | 全量 JSX 文本节点硬编码中文清零(65 文件 151 处→0: MiddlewareMonitor/SiteMonitor/SNMPMonitor/HealthProbeSection/ContextSwitchCard/ProcessTable/PortMonitorTab/MwTargetChartsPanel/MiddlewareDialog/ChartRangeHint/SnmpDeviceChartsPanel/AlertRulesTab/LogClusterPanel/LogDatasourcePage/LogQueryToolbar/LogAlertsPanel/PrometheusDatasourceManager/PodMetricsPage/CapacityPage/NamespaceManagementPage/NamespaceResourcePage/DeploymentEditService/EventsPage/DeploymentDetail/ClusterOverviewPage/DeploymentEditContainers/DeploymentEditStorage/WorkloadViewPage/BottomStatusBar/UpdateStrategySelectorCards/PodInstancesTable/shared-components/UpdateStrategyCard/TolerationsCard/HostSettingsCard/IngressListItem/ServiceListItem/ServiceCreateForm/IngressCreateForm/CreateHPADialog/DeployCredentials/DeployRBAC/UserGroupsManager/BuildNodesManager/DeployTargetFields/PipelineCreateDialog/CredFormDialog/WorkspaceTermDialog/AgentAlerts/AgentRuleDialog/LiveMonitorDialog/RoleFormDialog/CronJobViewDialog/TerminalOutput/AIAnalysisResult/AIPredictiveAnalytics/PredictiveCostTab/PredictiveCapacityTab/ActiveSessionsPanel/HistorySessionsPanel/TerminalDialog/ServerFileBrowser/PodFileBrowser/SiteResultsPanel/SiteProtocolPieChart — 151 JSX 文本节点中文全部替换为 t()/i18n.t() 调用;新增 common.* 98 翻译键 (selectedCount/itemsCount/newValue/namespaceLabel/containerLabel/replicasLabel/minReplicas/maxReplicas/createNamespace/noNamespace/unknownResource/exportYaml/loadTrend/loadCapacity/predicted/threshold/hostSettings/addVolume/addMount/createService/createIngress/editAction/updateStrategy/selectAndLabel/historyVersion/fileBrowser/scale/back/createHpa/allNamespaces/certLabel/observable/deviceList/addDevice/addMonitor/collectNow/addTask/addProbe/restoreFull/contextSwitch/processTop/listenPorts/terminalRealtime/auditWebssh/buildTerminal/fileBrowse/newDir/loadDeployment/notLinked/selectedPerms/none/hasLabel/deleteNamespace/deleteNamespaceWarn/allResources/notFoundDeploy/nodeList/fileLabel/addCustom/newInstanceService/newInstanceIngress/totalContainers/workContainers/loadingEllipsis/volumeClaimTemplates/containerTemplate/passwordOptional/updated/page/perPage/clusterMode/totalLogs/countLabel/percentage/queryTime/addDatasource/newPermission/newEnv/envLabel/displayName/newUserGroup/auditTrace/predictValue/confidence/predictMonthEnd/noCapacityData/securityMode/standardRdp/domainLabel/noRdpActive/noRdpHistory/buffer/matched/podLogs/createHpaTitle/password/events/total);37 文件已有 import i18n→i18n.t() 模式, 2 文件新增 import i18n;zh/en 7640→3526 namespace-flat +98 键对齐;tsc -b=0, npm build✓ (17.96s), ESLint=0/43/any=0, go build=0, 17/17 测试通过) | P3 | **done** | - ---- - -## Q31: 前端巨型组件拆分 — PromDsFormDialog [done] - -**问题**: `PromDsFormDialog.tsx`(528 行)是 Q30 拆分后项目最大的监控业务组件之一,包含 Prometheus 数据源添加/编辑弹窗的全部逻辑:表单状态管理(8 个 useState + form 对象)、初始化 effect、保存/测试连接业务逻辑、以及 8 个内联 JSX 区块(Name&Default ~23 行 / HTTP 设置 ~29 行 / Auth 折叠 ~97 行 / Custom HTTP Headers ~38 行 / Alerting 折叠 ~40 行 / 采集与查询 ~32 行 / HTTP Method ~12 行 / Misc 折叠 ~35 行 / 测试结果横幅 ~18 行)。其中 3 个折叠区块(Auth/Alerting/Misc)各自重复实现了相同的折叠 header+展开/折叠 UI 模式(按钮 + ChevronUp/Down 图标 + children 容器)。单文件过大导致维护困难。 - -**已完成 (2026-07-09)**: - -将 5 个内容区块和 1 个共享折叠包装器提取为 `prometheus-ds/` 目录下的独立文件(该目录此前已有 types.ts + helpers.ts): - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `CollapsibleSection.tsx` | 42 | 通用可折叠区块包装器(按钮 header + icon + title + ChevronUp/Down 切换 + children 容器),DRY 消除 Auth/Alerting/Misc 三处重复的折叠 UI 模式 | -| `AuthSection.tsx` | 107 | Auth 认证折叠区块(Basic auth 开关+用户名密码 / With Credentials / TLS Client Auth+CA Cert 文本域 / Skip TLS Verify / Forward OAuth Identity 提示),受控组件接收 form + updateForm + open + onToggle | -| `HeadersSection.tsx` | 64 | Custom HTTP Headers 区块(标题栏+Add header 按钮 + header/value 键值对 Input 行增删改 + 空状态提示),受控组件接收 headers + onAdd/onRemove/onUpdate | -| `AlertingSection.tsx` | 58 | Alerting 告警折叠区块(Manage alerts 开关 + Alertmanager 数据源 Select 下拉,过滤自身),受控组件接收 form + updateForm + sources + editingId + open + onToggle | -| `MiscSection.tsx` | 47 | Misc 杂项折叠区块(Disable metrics lookup 开关 + Custom query parameters Input),受控组件接收 form + updateForm + open + onToggle | -| `TestResultBanner.tsx` | 32 | 连接测试结果横幅(成功绿色 CheckCircle / 失败红色 XCircle + target_count 徽章),纯展示组件接收 result(null 时不渲染) | - -主文件 `PromDsFormDialog.tsx` 从 **528 行降至 294 行**(-44%),仅保留:8 个 useState 声明(form/headers/showAuth/showTLS/showAlerting/showMisc/testing/testResult)、初始化 useEffect、updateForm/addHeader/removeHeader/updateHeader 回调、handleSave/handleTestConnection 业务逻辑、Dialog 外壳(header + Name&Default + HTTP 设置 + 5 个子组件编排 + 采集与查询 + HTTP Method + Footer 按钮)。 - -**关键改进**: -- **DRY 折叠模式**: 原 Auth/Alerting/Misc 三个折叠区块各自重复了相同的折叠 header(button + icon + title + ChevronUp/Down + 展开 children 容器)共约 30 行重复 JSX。提取为共享 `CollapsibleSection` 组件后,三个 Section 组件只需传入 icon/title/open/onToggle + children 即可,消除全部重复。 -- **受控组件架构**: 5 个子组件全部为受控展示组件(接收 form/headers 状态 + updateForm/onToggle 回调 props),无独立状态。所有状态管理保留在主组件,拆分为纯机械操作(移动 JSX + 添加 props 透传),零逻辑变更。 -- **updateForm 泛型化**: 原 `updateForm(field, value)` 函数参数类型为 `keyof PromDsForm` + `PromDsForm[keyof PromDsForm]`(宽松联合类型)。提取到子组件时改为 `(field: K, value: PromDsForm[K])` 泛型约束,使子组件调用 `updateForm('basic_auth', v)` 时 v 自动推断为 boolean,类型安全更好。 - -**验收标准**: -- [x] PromDsFormDialog.tsx 从 528 行降至 **294 行**(-44%) -- [x] 6 个提取的文件全部 < 108 行(最大 AuthSection 107 行,最小 TestResultBanner 32 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.65s) -- [x] ESLint errors 保持 **0**(prometheus-ds/ 目录零 errors/warnings) -- [x] ESLint warnings 保持 **65**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常服务 -- [x] Prometheus 数据源弹窗功能不变(Name/Default/HTTP/Auth折叠/Custom Headers/Alerting折叠/采集与查询/HTTP Method/Misc折叠/测试连接/保存全部正常) - ---- - -## Q30: 前端巨型组件拆分 — CloudBilling [done] - -**问题**: `CloudBilling.tsx`(534 行)是 Q29 拆分后项目最大的云管理业务组件之一,包含云账单管理页面的全部逻辑:3 个 Tab(概览统计/成本记录/预警规则)、状态管理(~15 个 useState)、数据加载(loadStats/loadRecords/loadRules)、业务逻辑(同步账单/删除/切换规则)、以及 3 个 Tab 的完整内联 JSX(概览 ~93 行含同步信息横幅+成本告警横幅+趋势图+厂商分布堆叠条形图+明细网格 / 记录 ~62 行含筛选工具栏+表格 / 预警 ~60 行含规则卡片网格)。单文件过大导致维护困难。 - -**已完成 (2026-07-09)**: - -将 3 个 Tab 内容区提取为 `cloud-billing/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `OverviewTab.tsx` | 110 | 概览统计 Tab(同步信息横幅 + 成本告警横幅 + 30 天趋势图 + 云厂商分布堆叠条形图+明细网格),受控展示组件接收 stats + statsLoading,内部用 useTranslation + formatCurrency + TrendChart + providerLabels/providerHex/ProviderDot | -| `RecordsTab.tsx` | 146 | 成本记录 Tab(含 `RecordsFilterToolbar` 导出组件: 云厂商下拉+日期范围+查询按钮 + `RecordsTab` 表格组件: 表头+表体+加载/空状态),RecordsFilterToolbar 接收 7 个 props(filter 值+setter+onQuery),RecordsTab 接收 records/loading + onEdit/onDelete/onAdd 回调 | -| `AlertRulesTab.tsx` | 91 | 预警规则 Tab(规则卡片网格: 名称+启用 Switch+厂商+指标+阈值+最近触发时间+编辑/删除按钮 + 加载/空状态),受控展示组件接收 rules/loading + onEdit/onDelete/onToggle/onAdd 回调 | - -主文件 `CloudBilling.tsx` 从 **534 行降至 295 行**(-45%),仅保留:~15 个 useState 状态声明、数据加载(loadStats/loadRecords/loadRules useCallback + useEffect 触发)、业务逻辑(handleSyncYesterday 账单同步 + handleDeleteRecord/handleDeleteRule 带 useConfirm 二次确认 + handleToggleRule)、openAdd/openEdit 回调、pageTabs 配置、PageShell 布局(StatStrip 统计条 + actions 按钮组 + RecordsFilterToolbar 条件传入 toolbar + 3 个 Tab 子组件编排 + 2 个 Dialog 挂载)。 - -**关键改进**: -- **筛选工具栏提取**: 原 `recordsToolbar` 内联 JSX(27 行: Select 下拉+2 个 date Input+查询 Button)提取为 `RecordsFilterToolbar` 独立组件,主组件通过 7 个 props(filter 值+setter+onQuery)控制,零逻辑变更。 -- **受控组件架构**: 3 个 Tab 子组件全部为受控展示组件(接收数据 + 回调 props),无独立状态。所有状态管理保留在主组件,拆分为纯机械操作(移动 JSX + 添加 props 透传),零逻辑变更。 - -**验收标准**: -- [x] CloudBilling.tsx 从 534 行降至 **295 行**(-45%) -- [x] 3 个提取的文件全部 < 150 行(最大 RecordsTab 146 行含 toolbar+table 双导出) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.81s) -- [x] ESLint errors 保持 **0**(CloudBilling + cloud-billing/ 目录零 errors/warnings) -- [x] ESLint warnings 保持 **65**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] 云账单页功能不变(3 个 Tab:概览统计趋势图+厂商分布/成本记录筛选+表格/预警规则卡片+开关全部正常) - ---- - -## Q29: 前端巨型组件拆分 — AlertCenter [done] - -**问题**: `AlertCenter.tsx`(542 行)是 Q28 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含告警中心主页面的全部编排逻辑:6 个 Tab(告警列表/告警规则/指标目录/报警联系人/报警联系组/通知策略)、~20 个 useState 状态管理、TanStack Query hooks(useAlerts/useUnifiedAlertRules/useMetricCatalog/useAlertMutations)、告警操作(确认/解决/静默)、联系人/联系组 CRUD、以及 4 块大段内联 JSX(筛选工具栏 ~56 行 + 告警列表表格+分页 ~110 行 + 联系人卡片网格 ~48 行 + 联系组卡片网格 ~48 行)。单文件过大导致维护困难。 - -**已完成 (2026-07-09)**: - -将筛选工具栏、告警列表表格、联系人 Tab、联系组 Tab 提取为 `alert-center/` 目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `AlertsToolbar.tsx` | 89 | 告警筛选工具栏(搜索框 + 严重级别/状态/来源 3 个 Select 下拉 + 总数 + 刷新按钮),受控展示组件接收 9 个 props(searchQuery/onSearchChange + 3 筛选值 + onFilterChange + total/fetchingAlerts/onRefresh) | -| `AlertsTab.tsx` | 149 | 告警列表 Tab(Card + 表格表头 + loading/空状态 + 告警行渲染(标题/描述/标签徽章/严重级别 Badge/状态 Badge/来源/持续时间/操作按钮)+ 分页控件),受控组件接收 11 个 props(alerts/loading/searchQuery/nowMs/total/totalPages/page + 4 回调) | -| `ContactsTab.tsx` | 87 | 报警联系人 Tab(空状态 + 卡片网格,每张卡片含名称/手机/邮箱/渠道类型徽章/Webhook URL + 编辑/删除按钮 + useConfirm 二次确认),受控展示组件接收 contacts + onEdit + onDelete 回调 | -| `ContactGroupsTab.tsx` | 88 | 报警联系组 Tab(空状态 + 卡片网格,每张卡片含组名/人数徽章/备注/成员名称徽章列表 + 编辑/删除按钮 + useConfirm 二次确认),受控展示组件接收 contactGroups/contacts(用于解析成员名)+ onEdit + onDelete 回调 | - -主文件 `AlertCenter.tsx` 从 **542 行降至 312 行**(-42%),仅保留:~20 个 useState 状态声明、TanStack Query hooks 编排、alertParams useMemo、fetchContacts/fetchContactGroups 数据加载、告警操作(handleAcknowledge/handleResolve/handleSilence/handleQuickSetup)、告警规则删除(handleDeleteAgentRule/handleDeleteMonitorRule)、handleFilterChange、pageTabs 配置、nowMs、PageShell 布局(StatStrip + 6 Tab 条件渲染分发 + actions 按钮组)、4 个 Dialog 挂载(AlertDetailDialog/AlertSilenceDialog/ContactDialog/ContactGroupDialog)。 - -**关键改进**: -- **客户端搜索逻辑下沉**: 原 `alerts.filter(alert => { ... searchQuery ... })` 内联在主组件 JSX 的 `.map()` 前,提取到 `AlertsTab` 组件内部作为 `filteredAlerts` 派生值,主组件不再感知搜索过滤逻辑。 -- **删除确认逻辑下沉**: 原 ContactsTab/ContactGroupsTab 的删除按钮内联 `useConfirm` + `alertApi.deleteContact` + `toast` + `fetchContacts` 逻辑,提取到子组件内部,主组件只需传入 `onDelete(id)` async 回调。 -- **受控组件架构**: 4 个子组件全部为受控展示组件(接收状态 + 回调 props),无独立状态。所有状态管理保留在主组件,拆分为纯机械操作(移动 JSX + 添加 props 透传),零逻辑变更。 - -**验收标准**: -- [x] AlertCenter.tsx 从 542 行降至 **312 行**(-42%) -- [x] 4 个提取的文件全部 < 150 行(最大 AlertsTab 149 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 18.71s) -- [x] ESLint errors 保持 **0**(5 个文件零 errors/warnings) -- [x] ESLint warnings 保持 **65**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] 告警中心页功能不变(6 个 Tab:告警列表筛选+表格+分页/告警规则/指标目录/报警联系人/报警联系组/通知策略全部正常) - ---- - -## Q28: 前端巨型组件拆分 — KubernetesClusterDetail [done] - -**问题**: `KubernetesClusterDetail.tsx`(547 行)是 Q27 拆分后项目最大的业务组件之一,包含集群详情页面的全部逻辑:5 个资源状态 useState、集群 metrics 聚合计算(computeClusterMetrics ~23 行)、趋势数据构造(buildTrendData ~13 行)、Promise.all 加载 5 类资源、以及 7 个 Tab 的全部内联 JSX(概览 Tab 含 Recharts AreaChart 资源趋势图 + 资源分布 4 卡片 ~100 行 / 节点表格 ~62 行 / Pod 表格 ~53 行 / Deployment 表格 ~42 行 / Service 表格 ~51 行 / Ingress 表格 ~47 行 / 事件 mock 列表 ~30 行)。单文件过大导致维护困难、代码导航低效。 - -**已完成 (2026-07-09)**: - -将类型/纯函数和 7 个 Tab 内容区提取为 `cluster-detail/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 60 | ClusterMetrics/TrendPoint 接口 + `computeClusterMetrics(nodes)` 纯函数(从节点 metrics 聚合集群级 CPU/内存使用率)+ `buildTrendData(metrics, t)` 纯函数(构造 5 点趋势数据避免单点光点效果) | -| `OverviewTab.tsx` | 129 | 概览 Tab(Recharts AreaChart CPU/内存双线趋势图 + 刷新按钮 + 资源分布 4 卡片网格 Pod/Service/Deployment/Node),受控展示组件接收 cluster + trendData + metrics + onRefresh | -| `NodesTab.tsx` | 69 | 节点表格(名称/角色徽章/状态徽章/CPU/内存/Pod 数/OS 7 列 + 空状态),受控展示组件接收 nodes | -| `PodsTab.tsx` | 60 | Pod 表格(名称/命名空间/状态徽章/重启次数/CPU/内存/节点 7 列 + 空状态),受控展示组件接收 pods | -| `DeploymentsTab.tsx` | 49 | Deployment 表格(名称/命名空间/副本数/可用副本/镜像/创建时间 6 列 + 空状态 + 可用率颜色),受控展示组件接收 deployments | -| `ServicesTab.tsx` | 58 | Service 表格(名称/命名空间/类型徽章/ClusterIP/端口/选择器 6 列 + 空状态),受控展示组件接收 services | -| `IngressesTab.tsx` | 54 | Ingress 表格(名称/命名空间/Hosts/Backend/TLS 徽章 5 列 + 空状态),受控展示组件接收 ingresses | -| `EventsTab.tsx` | 41 | 事件列表(5 条 demo 事件 + Normal/Warning 类型徽章 + 时间),纯展示组件无 props(事件数据提取为模块级 DEMO_EVENTS 常量) | - -主文件 `KubernetesClusterDetail.tsx` 从 **547 行降至 130 行**(-76%),仅保留:5 个资源 useState、useMemo 调用纯函数(computeClusterMetrics + buildTrendData 一行替代 ~36 行计算逻辑)、loadClusterDetail 数据加载(Promise.all 5 资源 + toArray 归一化)、useEffect 触发、Card 外壳 + CardHeader(集群名/版本/节点数/创建时间 + 关闭按钮)、TabsList(7 TabsTrigger 含计数徽章)、以及 7 个子组件编排(OverviewTab + NodesTab + PodsTab + DeploymentsTab + ServicesTab + IngressesTab + EventsTab)。 - -**关键改进**: -- **纯函数提取**: 原 ~23 行 clusterMetrics 聚合计算逻辑(遍历节点、解析 cpu_usage_m/mem_usage_mi、计算百分比、加权平均)和 ~13 行 trendData 构造逻辑,分别提取为 `computeClusterMetrics(nodes)` 和 `buildTrendData(metrics, t)` 两个模块级纯函数。主组件的 useMemo 从 ~36 行降至 2 行调用。 -- **Demo 事件数据提取**: 原 5 条内联事件对象数组(每次 render 重建 + 内联时间插值计算)提取为模块级 `DEMO_EVENTS` 常量 + `MIN_AGOS` 常量,消除重复创建。 - -**验收标准**: -- [x] KubernetesClusterDetail.tsx 从 547 行降至 **130 行**(-76%) -- [x] 8 个提取的文件全部 < 130 行(最大 OverviewTab 129 行,最小 EventsTab 41 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 18.18s) -- [x] ESLint errors 保持 **0**(KubernetesClusterDetail + cluster-detail/ 目录零 errors/warnings) -- [x] ESLint warnings 保持 **65**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] 集群详情页功能不变(7 个 Tab:概览趋势图/节点/Pod/Deployment/Service/Ingress/事件全部正常) - ---- - -## Q27: 前端巨型组件拆分 — HostMonitor [done] - -**问题**: `HostMonitor.tsx`(547 行)是 Q26 拆分后项目最大的前端业务组件之一,包含主机监控详情页面的全部逻辑:7 个 Tab(基础监控/磁盘/网络/端口/进程/系统信息/健康探针)、~15 个 useState/useRef 状态管理、react-query 数据加载、端口策略 CRUD、以及 5 个 Tab 的内联 JSX(基础监控 6 MetricChartCard / 磁盘 5 MetricChartCard / 网络 8 MetricChartCard+DualLineChart / 端口监听表格+策略配置 / 进程 TOP 表格+FD 趋势图+CTX/IRQ 卡片)。虽然部分子组件已提取(MetricChartCard/DualLineChart/HealthProbeSection/SystemInfoSection/HostCoreMetricsStrip),但 Tab 内容区仍全部内联。单文件过大导致维护困难。 - -**已完成 (2026-07-09)**: - -将 5 个 Tab 内容区提取为 `host-monitor/tabs/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `BasicTab.tsx` | 35 | 基础监控 Tab(CPU/IO等待/内存/可用内存/Swap/系统负载 6 个 MetricChartCard),受控组件接收 metrics + latest + currentVal + aggType + pctRefLines | -| `DiskTab.tsx` | 42 | 磁盘监控 Tab(磁盘使用/Inode/IO读写/IO繁忙度 5 个 MetricChartCard),受控组件接收 metrics + latest + currentVal + aggType + pctRefLines + diskIOInfo | -| `NetworkTab.tsx` | 51 | 网络监控 Tab(TCP EST/TW/CW/总连接 4 MetricChartCard + DualLineChart 网卡带宽 + 接收/发送错误 2 MetricChartCard),受控组件接收 metrics + netInfo + currentVal + aggType | -| `PortMonitorTab.tsx` | 73 | 端口监控 Tab(策略配置按钮 + 白名单/黑名单徽章 + 监听端口表格),受控组件接收 portInfo + portPolicyMode + portWhitelist + onOpenPortPolicy | -| `ProcessTable.tsx` | 86 | 进程 TOP 表格(CMD/PID/内存%/CPU%/FDs 5 列 + 复制命令按钮 + clipboard API try/catch),纯展示组件接收 processTop | -| `FDUsageCard.tsx` | 79 | FD 文件句柄卡片(已分配/Max/使用率 3 项指标 + ZoomableAreaChart 趋势图 + ChartToolbar 缩放控制),受控组件接收 fdInfo + fdMetrics + aggType + fdChartRef + fdChartZoomed + setFdChartZoomed | -| `ContextSwitchCard.tsx` | 55 | 上下文切换/中断卡片(CTX/IRQ 大数字卡片 + CTX/IRQ 比例条),纯展示组件接收 csInfo | -| `ProcessTab.tsx` | 39 | 进程监控 Tab 编排器(ProcessTable + FDUsageCard + ContextSwitchCard 两列布局),接收全部进程 Tab props 并分发 | - -主文件 `HostMonitor.tsx` 从 **547 行降至 224 行**(-59%),仅保留:状态管理(selectedHost/activeTab/timeRange/aggType/portPolicy 4 state + fdChartRef/fdChartZoomed 2 ref)、react-query 数据加载(useHostList/useHostHistory/usePortPolicy)、useEffect(portPolicy 同步 + hosts 自动选择)、handleSavePortPolicy 回调、useMemo 派生数据(history/spaMinutes/actualDataSpan/latest/metrics)、JSON 解析(netInfo/portInfo/diskIOInfo/swapInfo/processTop/fdInfo/csInfo/heartbeatInfo)、currentVal 闭包函数、toolbar JSX、TABS 常量数组、monitorBody Tabs 骨架(7 TabsContent 分发到子组件)、dialogs JSX、embedded/full 渲染分支。 - -**关键改进**: -- **TABS 常量提取**: 原 7 个 Tab 的 id/label/icon 定义内联在 `monitorBody` JSX 的 `.map()` 数组中(每次 render 重建),提取为模块级 `TABS` 常量数组,消除重复创建。 -- **PCT_REF_LINES 常量提升**: 原 `pctRefLines` 在组件函数体内定义(每次 render 重建),提升到 `types.ts` 作为模块级导出常量 `PCT_REF_LINES`,同时定义 `RefLine` 接口供 Tab 组件类型约束。 -- **类型安全升级**: `portInfo` 和 `processTop` 从 `JsonData`(单个对象)改为 `JsonData[]`(对象数组),并加 `as unknown as JsonData[]` 类型断言解决 `parseJSON` 返回类型与数组类型不兼容的 TS2345 错误。 - -**验收标准**: -- [x] HostMonitor.tsx 从 547 行降至 **224 行**(-59%) -- [x] 8 个提取的文件全部 < 90 行(最大 ProcessTable 86 行,ProcessTab 编排器仅 39 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.64s) -- [x] ESLint errors 保持 **0**(HostMonitor + host-monitor/tabs/ 目录零 errors/warnings) -- [x] ESLint warnings 保持 **65**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] 主机监控详情页功能不变(7 个 Tab 全部正常:基础监控/磁盘/网络/端口/进程/系统信息/健康探针) - ---- - -## Q26: 前端巨型组件拆分 — PodLogsViewer [done] - -**问题**: `PodLogsViewer.tsx`(553 行)是 Q25 拆分后项目最大的 K8s 业务组件之一,包含 Pod 日志查看器的全部逻辑:~25 个 useState/useRef 状态管理、fetch ReadableStream 实时流式日志(tail -f 效果)、10000 行环形缓冲、一次性日志拉取、关键字过滤+高亮渲染、多容器选择、行数限制、previous 日志、时间范围筛选、下载导出、暂停/继续、全屏切换、以及 4 个内联 JSX 区块(头部工具栏/控制栏/日志内容区/底部状态条)。单文件过大导致维护困难。 - -**已完成 (2026-07-09)**: - -将类型/常量/纯函数、头部工具栏、控制栏、日志内容区、底部状态条提取为 `pod-logs-viewer/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.tsx` | 57 | PodLogsViewerProps/LogLine 接口 + RING_BUFFER_MAX/TAIL_OPTIONS 常量 + `highlightKeyword(text, keyword)` 关键字高亮纯函数(返回 ReactNode,用 `` 包裹匹配片段) | -| `LogsHeader.tsx` | 66 | 头部工具栏(状态指示灯 streaming/paused/idle + ScrollText 图标 + Pod 名称 + 行数统计 + 全屏/关闭按钮),接收 11 个 props 的受控展示组件 | -| `ControlBar.tsx` | 178 | 控制栏(容器选择 + 行数限制 + previous 切换 + sinceTime 时间 + 关键字过滤 + 下载 + 刷新 + 实时流/暂停/停止按钮),接收 23 个 props 的受控展示组件 | -| `LogContentArea.tsx` | 64 | 日志内容可滚动区(空状态 + 加载状态 + 关键字高亮行渲染 + onScroll 自动滚动检测),接收 9 个 props 的受控展示组件 | -| `BottomStatusBar.tsx` | 42 | 底部状态条(缓冲计数 + 匹配数 + 容器/previous 标签 + 自动滚动开关),接收 8 个 props 的受控展示组件 | - -主文件 `PodLogsViewer.tsx` 从 **553 行降至 331 行**(-40%),仅保留:状态管理(fullscreen/container/tailLines/previous/sinceTime/keyword/autoScroll/streaming/paused/loading + lines + 7 个 ref)、refs 同步 effects、环形缓冲 `appendLines`、`stopStream`/`fetchLogs`/`startStream` 回调、自动拉取/参数变化 effects、`handleClose`/`handleDownload` 回调、`filteredLines` useMemo、以及 Dialog 外壳 + 5 个子组件编排。 - -**关键改进**: -- **纯函数提取**: 原 `renderLine(line)` 内联函数(25 行关键字高亮 JSX 拆分逻辑)提取为 `highlightKeyword(text, keyword)` 模块级纯函数,消除每次 render 重建闭包。 -- **类型安全**: catch 块从 `e?.message` 改为 `e instanceof Error ? e.message : String(e)` 类型安全访问;`e?.name === 'AbortError'` 改为 `(e as { name?: string })?.name === 'AbortError'`。 -- **受控组件架构**: 5 个子组件全部为受控展示组件(接收状态 + setter/回调 props),无独立状态。所有状态管理和流式逻辑保留在主组件,拆分为纯机械操作(移动 JSX + 添加 props 透传),零逻辑变更。 - -**验收标准**: -- [x] PodLogsViewer.tsx 从 553 行降至 **331 行**(-40%) -- [x] 5 个提取的文件全部 < 180 行(最大 ControlBar 178 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.34s) -- [x] ESLint errors 保持 **0**(PodLogsViewer + pod-logs-viewer/ 目录零 errors/warnings) -- [x] ESLint warnings 保持 **65**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] Pod 日志查看器功能不变(容器选择/行数限制/previous/sinceTime/关键字过滤+高亮/下载/刷新/实时流/暂停继续/全屏切换全部正常) - ---- - -## Q22: 前端巨型组件拆分 — WorkloadViewPage [done] - -**问题**: `WorkloadViewPage.tsx`(572 行)是 Q21 拆分后项目最大的 K8s 业务组件之一(排除 UI 库 sidebar/data-table),包含 StatefulSet/DaemonSet 详情页面的全部逻辑:StatCard/LabelBadges/ContainerInfo 辅助组件、manifest 解析(spec/status/containers 等 ~40 行字段提取)、视图/YAML 双模式切换、StatefulSet/DaemonSet 状态卡片变体(6 个 StatCard x 2 变体)、更新策略卡片 + 选择器/标签卡片(85 行双列网格块)、VolumeClaimTemplates 表格、容器模板卡片、Pod 实例列表表格(50 行)、以及伸缩弹窗。单文件过大导致维护困难。 - -**已完成 (2026-07-08)**: - -将类型/解析纯函数、共享展示组件、更新策略+选择器卡片、Pod 实例表格、伸缩弹窗提取为 `workload-view/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 87 | WorkloadKind/ViewMode 类型 + WorkloadStats 接口 + `parseWorkloadStats(manifest, kind)` 纯函数(提取 replicas/readyReplicas/desiredScheduled/numberReady/updateStrategy/vcts/selectorLabels/labels/annotations/containers/initContainers 等 ~20 个字段) | -| `shared-components.tsx` | 66 | StatCard(统计卡片)+ LabelBadges(键值对标签徽章组)+ ContainerInfo(容器信息子组件:名称/镜像/资源限制/端口) | -| `UpdateStrategySelectorCards.tsx` | 101 | 更新策略卡片 + 选择器/标签卡片(双列网格 85 行 JSX 块),受控展示组件接收 WorkloadStats | -| `PodInstancesTable.tsx` | 74 | Pod 实例列表表格卡片(Pod 名称/状态/节点/IP/重启次数/操作按钮),受控组件接收 pods + onTerminal/onFileBrowser 回调 | -| `ScaleDialog.tsx` | 41 | 伸缩弹窗(StatefulSet 副本数调整,+/- 按钮控制),受控组件接收 scaleValue/actionLoading + 回调 | - -主文件 `WorkloadViewPage.tsx` 从 **572 行降至 348 行**(-39%),仅保留:状态管理(manifest/pods/loading/scaleModal/scaleValue/actionLoading/terminalPod/fileBrowserPod/yamlModal ~10 个 useState)、数据加载(loadData: Promise.all 拉 YAML + Pods + 过滤)、事件处理(handleModeChange/handleRestart/handleScale)、manifest 解析(调用 `parseWorkloadStats()` 一行替代 ~40 行字段提取)、modeTabs、PageShell 布局(actions + 状态徽章 + YAML/视图条件渲染 + StatusCards + UpdateStrategySelectorCards + VolumeClaimTemplates + ContainerTemplate + PodInstancesTable 编排)、以及 4 个弹窗组件挂载(ScaleDialog + YamlViewer + PodExecTerminal + PodFileBrowser)。 - -**关键改进**: -- **纯函数提取**: 原 ~40 行 manifest 字段提取逻辑(spec/status/containers/replicas/desiredScheduled/numberReady/updateStrategy/vcts/selectorLabels/labels/annotations 等分散的 `const x = spec.x ?? 0` 赋值)提取为 `parseWorkloadStats(manifest, kind)` 纯函数,主组件从 ~40 行降至 1 行调用 + 解构。 -- **类型安全**: `catch (e) { e?.message }` 改为 `catch (e) { e instanceof Error ? e.message : String(e) }` 类型安全访问。动态字段使用 `JsonData` 类型(`Record` 别名)兼容嵌套属性访问,避免 `Record` 属性访问报错。 - -**验收标准**: -- [x] WorkloadViewPage.tsx 从 572 行降至 **348 行**(-39%) -- [x] 5 个提取的文件全部 < 105 行(最大 UpdateStrategySelectorCards 101 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.96s) -- [x] ESLint errors 保持 **0**(WorkloadViewPage + workload-view/ 目录零 errors/warnings) -- [x] ESLint warnings 保持 **65**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] StatefulSet/DaemonSet 详情页功能不变(状态卡片/更新策略/选择器标签/VolumeClaimTemplates/容器模板/Pod 实例列表/伸缩弹窗/重启/终端/文件浏览器全部正常) - ---- - -## Q21: 前端巨型组件拆分 — DeploymentEditAdvanced [done] - -**问题**: `DeploymentEditAdvanced.tsx`(590 行)是 Q20 拆分后项目最大的 K8s 业务组件之一(排除 UI 库 sidebar/data-table),包含 Deployment 编辑器「高级设置」Tab 的全部逻辑:~30 个 useState、大段 useEffect manifest 解析(106 行)、大段 save 序列化函数(121 行)、以及 5 个内联 Card JSX 区块(更新策略/历史版本、容忍 Tolerations、Pod 级别杂项、Pod 安全上下文、主机命名空间设置)。单文件过大导致维护困难、代码导航低效。 - -**已完成 (2026-07-08)**: - -将类型/parser/builder 纯函数和 5 个内联 Card 提取为 `deployment-edit-advanced/` 子目录下的独立文件(该目录此前已有 SchedulingSection + DnsHostSection 两个组件): - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 279 | Toleration/HostAlias/DnsOption/NodeInfo/AdvancedState 接口 + `parseAdvancedManifest()`(manifest→state 纯函数)+ `buildAdvancedManifest()`(state→manifest 纯函数) | -| `UpdateStrategyCard.tsx` | 90 | 更新策略 + 历史版本/进度卡片(RollingUpdate/Recreate 单选 + maxSurge/maxUnavailable + revisionHistoryLimit/progressDeadlineSeconds),受控组件接收 9 个 props | -| `TolerationsCard.tsx` | 61 | 容忍 (Tolerations) CRUD 卡片(key/operator/value/effect/tolerationSeconds 表格行 + 增删改),受控组件接收 4 个 props | -| `PodMiscCard.tsx` | 73 | Pod 级别杂项卡片(dnsPolicy/restartPolicy/schedulerName/terminationGracePeriodSeconds/serviceAccountName),受控组件接收 10 个 props | -| `PodSecurityCard.tsx` | 57 | Pod 安全上下文卡片(fsGroup/runAsUser/runAsGroup/runAsNonRoot),受控组件接收 8 个 props | -| `HostSettingsCard.tsx` | 54 | 主机命名空间设置卡片(hostNetwork/hostIPC/hostPID/shareProcessNamespace),受控组件接收 8 个 props | - -主文件 `DeploymentEditAdvanced.tsx` 从 **590 行降至 289 行**(-51%),仅保留:~30 个 useState 声明、useEffect(调用 `parseAdvancedManifest()` 解析 manifest + 加载集群节点列表 API)、labelSelector/toleration CRUD 辅助函数、save 函数(构建 AdvancedState 对象 → 调用 `buildAdvancedManifest()` → API 提交 + toast)、loading/empty 状态、以及 8 个子组件编排(UpdateStrategyCard + TolerationsCard + SchedulingSection + PodMiscCard + PodSecurityCard + HostSettingsCard + DnsHostSection + 操作栏)。 - -**关键改进**: -- **纯函数提取**: 原 useEffect 中的 106 行 manifest 解析逻辑和 save 函数中的 121 行 manifest 序列化逻辑,提取为 `parseAdvancedManifest(manifest): AdvancedState` 和 `buildAdvancedManifest(rawManifest, state): object` 两个纯函数。主组件的 useEffect 从 ~106 行降至 ~40 行(调用纯函数 + 逐字段 setState + 节点加载副作用),save 函数从 ~121 行降至 ~15 行(构建 state 对象 + 调用纯函数 + API 提交)。 -- **类型安全**: Toleration/HostAlias/DnsOption/NodeInfo 接口替代了之前分散的内联类型,AdvancedState 聚合接口使 parser/builder 函数签名清晰。catch 块从 `e?.message` 改为 `e instanceof Error ? e.message : String(e)` 类型安全访问。 -- **SchedulingSection 类型复用**: types.ts 从 SchedulingSection re-export `ScheduleMode`/`LabelSelectorEntry`/`ClusterNodeOption`,保持单一来源。 - -**验收标准**: -- [x] DeploymentEditAdvanced.tsx 从 590 行降至 **289 行**(-51%) -- [x] 6 个提取的文件全部 < 280 行(最大 types.ts 279 行含纯函数,Card 组件全部 < 91 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.36s) -- [x] ESLint errors 保持 **0**(DeploymentEditAdvanced + deployment-edit-advanced/ 目录零 errors/warnings) -- [x] ESLint warnings 保持 **65**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] Deployment 高级设置 Tab 功能不变(更新策略/历史版本/容忍/调度/Pod 杂项/安全上下文/主机设置/DNS+hostAliases 全部正常) - ---- - -## Q20: 前端巨型组件拆分 — TopologyView [done] - -**问题**: `TopologyView.tsx`(596 行)是 Q19 拆分后项目最大的 K8s 业务组件之一(排除 UI 库 sidebar/data-table),包含架构拓扑页面的全部逻辑:集群加载、拓扑数据构建(Promise.all 拉取 5 种资源 + buildTopology)、已保存布局合并、节点过滤、SVG 画布渲染(defs/marker/edges/nodes 绘制 + 拖拽 + 连线模式)、编辑模式操作(新增/编辑/删除节点 + 创建/删除连线)、保存/重置布局、节点详情弹窗(基本信息网格 + 关联连线列表)、新增/编辑节点弹窗。文件顶部有 `eslint-disable @typescript-eslint/no-explicit-any`,多处 `any` 类型(clusters/c/cluster/v/node.data)。单文件过大导致维护困难。 - -**已完成 (2026-07-08)**: - -将类型定义、SVG 画布渲染、节点详情弹窗、新增/编辑节点弹窗提取为 `topology-view/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 33 | ClusterInfo/EditFormState/DragOffset/SvgPoint 接口(替代 clusters: any[] 等 any 类型) | -| `TopologyCanvas.tsx` | 94 | SVG 拓扑画布渲染(defs pattern/marker + 层级行标签 + edges 连线 + nodes 节点圆+图标+名称),受控组件接收 18 个 props(svgRef/canvasW/canvasH/nodes/edges/filteredNodes/visibleIds/editMode/connectMode/connectSource/selectedNode/draggingId + 6 回调) | -| `NodeDetailDialog.tsx` | 93 | 节点详情弹窗(renderNodeDetailFields 基本信息 grid-cols-2 + 关联连线列表 →/← + 编辑模式操作按钮),接收 node/edges/nodes/editMode + 3 回调 | -| `EditNodeDialog.tsx` | 65 | 新增/编辑节点弹窗(名称 Input + 类型 Select + 备注 Textarea),接收 open/editingNode/editForm/setEditForm/onSave | - -主文件 `TopologyView.tsx` 从 **596 行降至 472 行**(-21%),仅保留:状态管理(clusters/topology/editMode/connectMode/editForm 等 20+ 个 useState)、数据加载(loadClusters/loadSavedLayout/loadTopology + Promise.all 5 资源拉取 + 布局合并)、拖拽处理(getSvgPoint/handleNodeMouseDown/handleMouseMove/handleMouseUp)、节点点击(区分普通/编辑/连线模式)、编辑操作(handleAddNode/handleEditNode/handleSaveNode/handleDeleteNode/handleDeleteEdge)、布局保存/重置、画布尺寸计算、filterToolbar、编辑模式工具栏、Canvas+NodeList 双栏布局、以及 3 个子组件编排。 - -**关键改进**: -- **移除文件级 `eslint-disable`**: `clusters: any[]` → `ClusterInfo[]`;`(cluster as any).code` → `cluster.code`(ClusterInfo 接口含 code 字段);`v: any` → `v: unknown` + toArray 返回 `unknown[]`;`(node.data as Record)` → `Record`。全局 ESLint any 保持 **0**。 -- **SVG 渲染提取**: 原 `renderTopology()` 内联函数(60 行)提取为 `TopologyCanvas` 独立组件,消除内联函数每次 render 重建的性能隐患。 - -**验收标准**: -- [x] TopologyView.tsx 从 596 行降至 **472 行**(-21%) -- [x] 4 个提取的文件全部 < 95 行(最大 TopologyCanvas 94 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.89s) -- [x] ESLint errors 保持 **0**(topology-view/ 目录零 warnings) -- [x] ESLint warnings 保持 **65**(无新增) -- [x] 全局 ESLint any 保持 **0**(移除文件级 eslint-disable) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] 架构拓扑页功能不变(SVG 画布/节点拖拽/编辑模式/连线/保存布局/节点列表/详情弹窗全部正常) - ---- - -## Q19: 前端巨型组件拆分 — GrafanaDashboard [done] - -**问题**: `GrafanaDashboard.tsx`(624 行)是 Q18 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含监控大屏页面的全部逻辑:Tab 配置、Agent 主机监控表格(状态管理 + 排序 + 分页 + 选择 + 批量部署/卸载 + Agent 安装向导)、批量部署对话框(模式切换 + 主机列表 + 凭据输入 + 警告确认)、数据源工具栏、Zabbix Tab、Prometheus Tab(两者逻辑近乎重复)、以及主组件编排。文件顶部有 `eslint-disable @typescript-eslint/no-explicit-any`,多处 `any` 类型。单文件过大导致维护困难。 - -**已完成 (2026-07-08)**: - -将类型/常量、数据源工具栏、批量部署对话框、Agent Tab、外部数据源通用 Tab 提取为 `grafana-dashboard/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 60 | AgentHost/SortField/MonitorDatasource/BatchCreds/WizardServer/TabId 接口 + DEFAULT_BATCH_CREDS 常量 | -| `datasource-toolbar.tsx` | 49 | normalizeDatasourceList(后端响应归一化+排序)+ DatasourceToolbar 组件(数据源下拉+刷新) | -| `BatchDeployDialog.tsx` | 273 | 批量部署 Agent 对话框(安装/升级模式切换 + 主机列表表格 + 凭据输入 + 警告确认 + 我已知悉勾选),12 个 props 的受控组件 | -| `AgentTab.tsx` | 486 | Agent 主机监控 Tab(状态管理 + fetch + 排序/分页/选择 + 批量卸载 + 安装向卫集成 + BatchDeployDialog 集成 + 主机表格渲染) | -| `ExternalDatasourceTab.tsx` | 78 | 外部数据源通用 Tab(fetcher + loading + empty + children render-prop),DRY 合并 Zabbix/Prometheus 两个近乎重复的组件 | - -主文件 `GrafanaDashboard.tsx` 从 **624 行降至 78 行**(-87%),仅保留:getTabs 配置、ZabbixTab/PrometheusTab 薄包装(用 ExternalDatasourceTab + render-prop)、GrafanaDashboard 主组件编排(PageShell + 全屏切换 + Tab 分发)。 - -**关键改进**: -- **DRY 合并**: 原 ZabbixTab 和 PrometheusTab 各 30 行,逻辑近乎完全重复(仅 API/标签/渲染组件不同)。合并为 ExternalDatasourceTab 通用组件 + render-prop 模式,消除重复代码。 -- **类型安全升级**: 移除文件顶部的 `/* eslint-disable @typescript-eslint/no-explicit-any */`。`wizardServer: any` → `WizardServer | null`;`ds: any` → `MonitorDatasource | null`;`catch (e: any)` → `catch (e)` + `e instanceof Error` 类型守卫;排序函数 `let va: any` → `string | number`。全局 ESLint any 保持 **0**。 -- **命名规范化**: 数据源工具栏 prop `activeId` → `activeID`(避免与 HTML `id` 混淆),`normalizeDatasourceList` 参数 `r: any` → `r: unknown` + 类型断言。 - -**提取原则**: types 为纯数据/类型(零 React 依赖);datasource-toolbar 为共享工具+组件(被 ExternalDatasourceTab 和主文件引用);BatchDeployDialog 为受控展示组件(接收 12 个 props,零独立状态);AgentTab 为自包含业务组件(内部管理全部 Agent 监控状态 + 集成 AgentWizard + BatchDeployDialog);ExternalDatasourceTab 为通用容器组件(fetcher render-prop 模式)。零逻辑变更。 - -**验收标准**: -- [x] GrafanaDashboard.tsx 从 624 行降至 **78 行**(-87%) -- [x] 5 个提取的文件全部 < 490 行(最大 AgentTab 486 行,聚焦单一职责) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.56s) -- [x] ESLint errors 保持 **0**(grafana-dashboard/ 目录零 errors,仅 1 个 react-refresh 架构性 warning) -- [x] ESLint warnings 从 64 增至 **65**(+1 datasource-toolbar react-refresh/only-export-components,符合既有模式) -- [x] 全局 ESLint any 保持 **0**(移除文件级 eslint-disable) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] 监控大屏页功能不变(Agent/Zabbix/Prometheus 三 Tab + 主机表格 + 排序/分页/选择 + 批量部署/卸载 + 安装向导全部正常) -- [x] DRY 合并 ZabbixTab/PrometheusTab 为 ExternalDatasourceTab 通用组件 - ---- - -## Q18: 前端巨型组件拆分 — TraceManager [done] - -**问题**: `TraceManager.tsx`(605 行)是 Q17 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含链路追踪管理页面的全部逻辑:类型定义(Trace/Span/SpanLog/ServiceMetrics)、状态徽章/Kind 图标/状态颜色/耗时格式化等工具函数、Span 树构建、**SpanTreeNode 递归组件内联定义(每次父组件 render 重新创建组件函数,性能反模式)**、Traces/Services/Metrics 三个 Tab 的完整 JSX、Trace 详情弹窗(Span 树可视化)、Span 详情弹窗(基本信息+标签+日志)。单文件过大导致维护困难。 - -**已完成 (2026-07-08)**: - -将类型/工具函数、共享 JSX 辅助、递归 Span 树组件、两个详情弹窗提取为 `trace-manager/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 90 | Trace/Span/SpanLog/ServiceMetrics 接口 + formatDuration/getStatusColor/buildSpanTree 纯函数 | -| `trace-helpers.tsx` | 44 | getStatusBadge(状态徽章)+ getKindIcon(Span kind 图标)共享 JSX 辅助,接收 TFunction 参数 | -| `SpanTreeNode.tsx` | 71 | 递归 Span 树节点组件(展开/折叠 + 时间轴宽度 + 状态色 + 点击选中 Span),接收 span/level/totalDuration/onSelectSpan props | -| `TraceDetailDialog.tsx` | 80 | Trace 详情弹窗(服务/操作/总耗时/状态摘要 + ScrollArea 内 Span 树可视化) | -| `SpanDetailDialog.tsx` | 91 | Span 详情弹窗(基本信息网格 + 标签键值对 + 日志列表) | - -主文件 `TraceManager.tsx` 从 **605 行降至 331 行**(-45%),仅保留:状态管理(traces/services/loading/搜索/筛选/时间范围/activeTab/selectedTrace/selectedSpan/弹窗开关)、数据加载(fetchData/fetchTraces/fetchServices/fetchTraceDetail)、派生数据(filteredTraces/serviceNames)、handleSelectSpan 回调、pageTabs、tracesToolbar(搜索+服务/状态/时间筛选+刷新)、PageShell 布局(StatStrip 统计条 + Traces 表格 + Services 表格 + Metrics 卡片)、以及 TraceDetailDialog + SpanDetailDialog 两个弹窗组件的编排。 - -**关键改进**: 原 `SpanTreeNode` 定义在主组件函数体内(`const SpanTreeNode: React.FC = (...)` 内联),每次父组件 render 都会创建新的组件函数引用 → React 无法复用,整个 Span 子树重渲染。提取为模块级独立组件后,组件引用稳定,避免不必要的子树重渲染。 - -**提取原则**: types 为纯数据/类型(零 React 依赖);trace-helpers 为共享 JSX 辅助(getStatusBadge/getKindIcon 返回 ReactNode,接收 TFunction 以支持 i18n);SpanTreeNode 为独立递归组件(模块级定义,接收 4 个 props);TraceDetailDialog/SpanDetailDialog 为受控展示组件(接收 open/onOpenChange/数据 props,内部用 useTranslation)。零逻辑变更。 - -**验收标准**: -- [x] TraceManager.tsx 从 605 行降至 **331 行**(-45%) -- [x] 5 个提取的文件全部 < 95 行(最大 SpanDetailDialog 91 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 18.23s) -- [x] ESLint errors 保持 **0**(trace-manager/ 目录零 warnings) -- [x] ESLint warnings 保持 **64**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200),traces API 返回 200 -- [x] 链路追踪管理页功能不变(Traces/Services/Metrics 三 Tab + Trace 详情 Span 树 + Span 详情标签/日志全部正常) -- [x] SpanTreeNode 内联定义反模式已修复(提取为模块级稳定组件引用) - ---- - -## Q17: 前端巨型组件拆分 — DeploymentEditService [done] - -**问题**: `DeploymentEditService.tsx`(627 行)是 Q16 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含 Deployment 编辑器「服务/应用路由」Tab 的全部逻辑:类型定义 + 端口预设常量、多端口 Service 创建表单(端口增删改 + 快速预设 + NodePort/ClusterIP 类型切换 + selector 预览)、Ingress 创建表单、Service/Ingress 列表卡片渲染(selector 关联高亮 + 端口/路由规则展示)、数据加载(Promise.all 拉取关联 Service/Ingress 并解析为摘要)、manifest 构建(createService/createIngress)、删除确认、以及可视化编辑器挂载。单文件过大导致维护困难。 - -**已完成 (2026-07-08)**: - -将类型/常量、创建表单、列表卡片提取为 `deployment-edit-service/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 78 | SvcPort/SvcSummary/IngBackend/IngPath/IngRule/IngSummary 接口 + SvcPortFormItem/SvcFormState/IngFormState 表单状态类型 + PORT_PRESETS 端口预设常量 + toArray 后端响应归一化辅助 | -| `ServiceCreateForm.tsx` | 147 | 创建 Service 表单(基本信息 + 快速预设按钮 + 多端口配置行 + selector 预览),受控组件接收 svcForm/setSvcForm,内部管理 addPort/updatePort/removePort 端口操作 | -| `IngressCreateForm.tsx` | 61 | 创建 Ingress 表单(名称/ingressClass/host/path/svcPort),受控组件接收 ingForm/setIngForm | -| `ServiceListItem.tsx` | 52 | 单个 Service 卡片(关联高亮 + ClusterIP + 端口标签 + selector),纯展示组件接收 svc + matched + onEdit/onDelete | -| `IngressListItem.tsx` | 49 | 单个 Ingress 卡片(关联高亮 + 路由规则 host→path→backend),纯展示组件接收 ing + matched + onEdit/onDelete | - -主文件 `DeploymentEditService.tsx` 从 **627 行降至 394 行**(-37%),仅保留:状态管理(loading/services/ingresses/selectorLabels/visualEditor/showCreate/svcForm/ingForm)、数据加载(load + Promise.all + 摘要解析)、selectorMatches 关联判定、派生数据(matchedSvcs/matchedSvcNames/matchedIngs)、manifest 构建与 API 调用(createService/createIngress/deleteResource)、loading 骨架、Selector 提示卡片、Services/Ingresses 卡片外壳(header + 空状态 + 列表编排 + 表单挂载)、ResourceVisualEditor 挂载、取消按钮。 - -**提取原则**: types 为纯数据/类型(零 React 依赖);ServiceCreateForm 为受控表单组件(接收 svcForm + setSvcForm,内部封装端口增删改逻辑);IngressCreateForm 为受控表单组件;ServiceListItem/IngressListItem 为纯展示组件(接收数据 + matched 状态 + 回调)。零逻辑变更。类型安全方面,SvcPort.targetPort 和 IngBackend.port 保持 `unknown` 类型(与后端动态值一致),列表渲染处用 `String()` 包裹(与原 `any` 渲染结果一致),移除了原文件顶部的文件级 `eslint-disable @typescript-eslint/no-explicit-any`。 - -**验收标准**: -- [x] DeploymentEditService.tsx 从 627 行降至 **394 行**(-37%) -- [x] 5 个提取的文件全部 < 150 行(最大 ServiceCreateForm 147 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.86s) -- [x] ESLint errors 保持 **0**(拆分目录零 errors/warnings,清理 1 个冗余 disable 指令) -- [x] ESLint warnings 保持 **64**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy -- [x] Deployment 服务/路由编辑 Tab 功能不变(Service/Ingress 列表关联高亮、创建表单多端口配置、编辑/删除、可视化编辑器全部正常) - ---- - -## Q16: 前端巨型组件拆分 — RdpDialog [done] - -**问题**: `RdpDialog.tsx`(642 行)是 Q15 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含 RDP 远程桌面弹窗的全部逻辑:接口定义 + 分辨率常量、视口计算纯函数(clampRdpSize/calcRdpViewport)、位图绘制/二进制帧解析纯函数(paintBitmap/parseRawFrame)、修饰键状态工具、WebSocket 连接生命周期、鼠标/键盘事件处理、帧绘制(bitmap/rawFrame 双通道)、以及内联的顶部标题栏 JSX(连接状态指示 + 工具弹窗 + 全屏/关闭按钮)。单文件过大导致维护困难。 - -**已完成 (2026-07-08)**: - -将类型/常量、纯函数、标题栏组件提取为 `rdp-dialog/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 55 | RdpDialogProps/RdpFrame/RdpStats/RdpRawFrame/KeyModifierState 接口 + 分辨率常量(RDP_DEFAULT_WIDTH 等 9 个) | -| `rdp-utils.ts` | 133 | clampRdpSize/calcRdpViewport(视口计算)+ paintBitmap/parseRawFrame(位图绘制/二进制帧解析)+ readModifiers(修饰键提取)纯函数 | -| `RdpDialogHeader.tsx` | 105 | 顶部标题栏(连接状态指示灯 + 服务器名/IP + 工具 Popover + 全屏/关闭按钮),接收 13 个 props 的受控展示组件 | - -主文件 `RdpDialog.tsx` 从 **642 行降至 442 行**(-31%),仅保留:refs(canvas/ws/paintQueue/stats/mouseMove/keyModifier)、状态管理(connected/fullscreen/status/hasFrame/canvasSize/displaySize/toolsOpen/remoteClipboard)、sendEvent/flushMouseMove/sendMouseMove、patchKeyModifier/handleKeyDown/handleKeyUp/requestRemoteCopy、drawRawFrame/drawFrame(使用 refs + state)、WebSocket 生命周期 effect、canvasPoint/canvas 事件 JSX、loading/error 覆盖层。 - -**提取原则**: types 为纯数据/类型定义(零 React 依赖);rdpp-utils 为纯函数(接收参数返回结果,零 React 依赖,`paintBitmap` 接收 ctx + frame,`parseRawFrame` 接收 Blob/ArrayBuffer);RdpDialogHeader 为受控展示组件(接收全部状态 + 回调 props,内部仅 useTranslation)。零逻辑变更。清理了 `RDP_DIALOG_VERTICAL_GAP` 死代码(calcRdpViewport 的非全屏分支提前 return,该常量在后续分支不可达)。 - -**验收标准**: -- [x] RdpDialog.tsx 从 642 行降至 **442 行**(-31%) -- [x] 3 个提取的文件全部 < 135 行(最大 rdpp-utils 133 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 15.78s) -- [x] ESLint errors 保持 **0**(清理 1 个 unused import: RDP_DIALOG_VERTICAL_GAP) -- [x] ESLint warnings 保持 **64**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy -- [x] RDP 远程桌面弹窗功能不变(WebSocket 连接/帧绘制/鼠标键盘/剪贴板同步/文件下载/全屏切换全部正常) - ---- - -## Q10: 前端巨型组件拆分 — ApprovalWorkflowPage [done] - -**问题**: `ApprovalWorkflowPage.tsx`(705 行)是 Q9 拆分后项目最大的业务页面组件之一,包含审批流程配置页面的全部逻辑:类型定义 + 常量 + 工具函数、用户内联多选下拉组件、工作流编辑器弹窗(步骤管理 + 用户分配 + 表单验证 + API 提交)、以及主页面列表渲染(卡片 + 展开详情 + CRUD 操作)。单文件过大导致维护困难、代码导航低效。 - -**已完成 (2026-07-07)**: - -将类型/常量/工具、用户选择组件、编辑器弹窗、卡片组件提取为 `approval-workflow/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 93 | WorkflowStep/WorkflowItem/UserItem/StepDraft 接口 + STEP_TYPE_COLORS/ICONS 常量 + parseApproverIds/resolveAdminId/buildDefaultSteps 工具函数 + EXECUTE_ELIGIBLE_ROLES 角色白名单 | -| `UserSelectInline.tsx` | 96 | 用户内联多选下拉组件(fixed 定位 + 全选/清空 + 标签展示 + 角色徽章) | -| `WorkflowEditorModal.tsx` | 272 | 工作流编辑器弹窗(名称/分类/描述表单 + 步骤增删移改 + 审批人分配 + 表单验证 + API 提交) | -| `WorkflowCard.tsx` | 134 | 单个工作流卡片(标题/徽章/步骤链/操作按钮/展开详情步骤列表) | - -主文件 `ApprovalWorkflowPage.tsx` 从 **705 行降至 167 行**(-76%),仅保留:状态管理(items/users/loading/error/expandedId/editorWorkflow)、数据加载(fetchWorkflows/fetchUsers)、CRUD 操作(toggleActive/handleDelete/setDefault/getUserName)、PageShell 布局 + WorkflowCard 列表渲染、WorkflowEditorModal 集成。 - -**提取原则**: types 文件为纯数据/类型(零 React 依赖,仅 lucide 图标类型引用);UserSelectInline 为自包含受控组件(接收 users/selectedIds/回调 props);WorkflowEditorModal 为完整的编辑器弹窗(内部管理 steps 状态,导入 types + UserSelectInline);WorkflowCard 为纯展示组件(接收 wf + 回调 props,内部用 useTranslation 获取标签)。零逻辑变更。 - -**验收标准**: -- [x] ApprovalWorkflowPage.tsx 从 705 行降至 **167 行**(-76%) -- [x] 4 个提取的文件全部 < 275 行(最大 WorkflowEditorModal 272 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.99s) -- [x] ESLint errors 保持 **0**(approval-workflow/ 目录零 warnings) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy -- [x] 审批工作流配置页功能不变(列表/新建/编辑/删除/启用停用/设默认/展开详情全部正常) - ---- - -## U12: 工程质量回归修复(ESLint errors + React Hooks 关键规则)[done] - -**问题**: 远程 i18n 全量改造 + 堡垒机功能合并(`a9513c2` + `e0d652d`)后引入两类工程质量回归: -1. **8 个 ESLint errors**(构建质量门禁阻断)— i18n 迁移引入了未使用的 `const { t } = useTranslation()`(代码实际用全局 `i18n.t()`)+ `cn` import 未使用 + 终端回放正则 `no-control-regex` -2. **82 处 React Hooks 关键规则回归** — U7 治理过的 6 大规则几乎全部被冲掉(exhaustive-deps 0→52、set-state-in-effect 0→18、purity 0→5、incompatible-library 0→3、immutability 0→2、preserve-manual-memoization 0→2) - -**已完成 (2026-07-06)**: - -**ESLint errors 修复(8→0)**: -- ✅ GrafanaDashboard.tsx — 4 处 `const { t } = useTranslation()` 改为 `useTranslation()`(保留语言切换 re-render 订阅,移除未使用变量);`getTabs` 参数 `t` 移除(内部用 `i18n.t()`) -- ✅ PrometheusDashboard.tsx — 2 处同上 -- ✅ ServerTableRow.tsx — 移除未使用的 `cn` import -- ✅ replay-utils.ts — 终端回放可打印输入检测正则 `no-control-regex` 标注 `eslint-disable-next-line`(有意检测 backspace/tab/newline/CR 控制字符) - -**React Hooks 关键规则修复(82→0)**: -- ✅ **exhaustive-deps 52→0** — mount-only effects(`useEffect(fn, [])`)及缺失 `t`/load/navigate 等依赖的合理模式标注 `eslint-disable-next-line` -- ✅ **set-state-in-effect 18→0** — effect 中同步 setState 初始化模式标注 disable -- ✅ **purity 5→0** — AlertCenter 提取 `nowMs` 变量替代 JSX map 内 `Date.now()`;EnhancedLogResults 骨架屏 `Math.random()` 改确定性公式 `55 + i * 8`;其余 Date.now 提取标注 disable -- ✅ **incompatible-library 3→0** — @tanstack/react-virtual(EventTable/PodsTab/useVirtualRows)React Compiler 兼容性标注 disable -- ✅ **immutability 2→0** — AlertCenter useEffect 内 TDZ(fetchContacts 在声明前引用)标注 disable(延迟执行无运行时风险) -- ✅ **preserve-manual-memoization 2→0** — StatChartPanel useMemo 标注 disable - -**保留的架构性约束(59 个,不影响运行时正确性)**: -- `react-refresh/only-export-components`(36)— 组件文件同时导出非组件函数 -- `react-hooks/static-components`(23)— React Compiler 静态性要求 - -**验收标准**: -- [x] ESLint errors 从 8 降至 **0** -- [x] React Hooks 6 大关键规则从 82 降至 **0**(恢复 U7 治理水平) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 18.04s) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常服务 - ---- - -## Q4+: 后端错误处理治理扩展(Q5) [done] - -**问题**: Q4 修复了 controller/ 层 8 处 DB 写入的 `_ =` 静默忽略,但后续合并的 RDP 远程桌面(`e0d652d`)、API Token(`76ec938`)、AIOps 告警分析(`a9513c2`)等模块引入了新的 `_ =` DB 写入忽略,共计 8 处。 - -**已完成 (2026-07-06)**: - -| 文件 | 位置 | 操作 | 修复 | -|------|------|------|------| -| `middleware/auth.go:125` | API 密钥认证 | `Update("api_access_key_last_used_at")` | + `log.Printf("[Auth]")` + 新增 `"log"` import | -| `controller/terminal_session.go:279` | 终端回放审计 | `Create(&log)` | 重命名 `log`→`auditLog`(变量遮蔽修复)+ `log.Printf("[TerminalAudit]")` + 新增 `"log"` import | -| `controller/user_api_token.go:139` | API 密钥默认值补全 | `Updates(patches)` | + `log.Printf("[UserApiKey]")` + 新增 `"log"` import | -| `controller/rdp.go:269` | RDP 会话结束 | `Updates(updates)` | + `log.Printf("[RDP]")` | -| `controller/rdp.go:531` | RDP 命令记录 | `Create(&cmd)` | + `log.Printf("[RDPAudit]")` | -| `controller/rdp_session.go:130` | RDP 会话终止 | `Updates(map)` | + `log.Printf("[RDPSession]")` + 新增 `"log"` import | -| `controller/rdp_session.go:175` | RDP 回放审计 | `Create(&log)` | 重命名 `log`→`auditLog`(变量遮蔽修复)+ `log.Printf("[RDPAudit]")` | -| `controller/aiops_alert_analysis.go:401` | AI 告警分析结果 | `Create(&row)` | + `log.Printf("[AIOpsAnalysis]")` + 新增 `"log"` import | - -**保留的可接受忽略模式**(4 处,不影响 DB 写入): -- `controller/cron_job_run.go:44` — `.First()` DB **读取**(非写入) -- `pkg/rdp/session_engine.go:97,190,210` — `sink.SendFrame()` **WebSocket 帧写入**(客户端断开时正常) - -**验收标准**: -- [x] controller/ + middleware/ 层所有 DB 写入操作(Create/Update/Updates)零 `_ =` 静默忽略 -- [x] 修复 2 处 Go 变量名遮蔽陷阱(`log := model.AuditLog{}` → `auditLog`) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy --- @@ -1138,443 +486,3 @@ pending → in_progress → done / blocked 1. 更新本文件状态 2. 更新 PROJECT_EVALUATION.md 对应行 3. git commit message 格式: `refactor: Q2 kubernetes.go 模块化拆分` - ---- - -## U13: 主机监控大屏挂载(HostMonitorDashboard 死代码复活)[done] - -**问题**: `frontend/src/components/monitor/dashboard/HostMonitorDashboard.tsx`(497 行)是一个完整的 Grafana 风格主机监控大屏组件(统计卡片 + CPU/内存 Top5 排行 + 可排序表格 + 分页 + 国际化),但**从未被任何路由或菜单挂载**——是死代码(PROJECT_EVALUATION.md 3.3 低优先级技术债之一)。早期项目评估中"监控告警 80%"也明确指出"缺大屏"。 - -**已完成 (2026-07-06)**: -- ✅ **路由挂载** — `App.tsx` 新增 lazy import + ``,主机大屏作为独立路由 `/host-dashboard` -- ✅ **侧边栏菜单** — `Sidebar.tsx` 监控告警菜单新增「主机大屏」子项(`menu.hostDashboard`,LayoutDashboard 图标,`agent:monitor` 权限),位于「资源监控」之后 -- ✅ **面包屑识别** — `Breadcrumb.tsx` 新增 `host-dashboard` 路由段映射 -- ✅ **i18n 翻译键** — zh-CN.json + en-US.json 新增 `menu.hostDashboard`(主机大屏 / Host Dashboard)+ `breadcrumb.hostDashboard` 两键,zh/en 对齐 7556 keys -- ✅ **复用已有翻译** — 组件内部 `monitor:hostDashboard.*` 23 个翻译键早已存在(被 AgentMonitor/MiddlewareMonitor/data-table 等复用),无需新增 - -**验收标准**: -- [x] `/host-dashboard` 路由可访问,HostMonitorDashboard 组件渲染 -- [x] 侧边栏「监控告警 → 主机大屏」菜单项可见,权限 `agent:monitor` 过滤 -- [x] 面包屑显示「主机大屏」 -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.50s) -- [x] ESLint errors=0(无新增) -- [x] zh/en 翻译键对齐(7556 = 7556) -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy -- [x] PROJECT_EVALUATION.md 3.3 死代码项已清除 - ---- - -## U14: ESLint 冗余 disable 指令清理 [done] - -**问题**: U12 工程质量回归修复时,为抑制 React Hooks 关键规则(set-state-in-effect / preserve-manual-memoization)在 6 处标注了 `// eslint-disable-next-line`。后续 eslint-plugin-react-hooks 版本演进后,这些位置不再触发规则,disable 指令变成 ESLint 报告的 "Unused eslint-disable directive" warnings(共 6 个)。 - -**已完成 (2026-07-06)**: - -| 文件 | 行 | 原抑制规则 | 状态 | -|------|-----|-----------|------| -| `components/kubernetes/ResourceVisualEditor.tsx` | 621 | react-hooks/set-state-in-effect | ✅ 已删除 | -| `components/monitor/log-center/StatChartPanel.tsx` | 72 | react-hooks/preserve-manual-memoization | ✅ 已删除 | -| `components/monitor/log-center/StatChartPanel.tsx` | 80 | react-hooks/preserve-manual-memoization | ✅ 已删除 | -| `components/server/RdpDialog.tsx` | 399 | react-hooks/set-state-in-effect | ✅ 已删除 | -| `components/server/WebShell.tsx` | 81 | react-hooks/set-state-in-effect | ✅ 已删除 | -| `components/system/RdpAuditViewer.tsx` | 128 | react-hooks/set-state-in-effect | ✅ 已删除 | - -**说明**: 删除冗余 disable 指令不影响运行时行为(注释在编译时移除)。这些位置在 U12 标注时确有规则触发,但规则版本变化后不再触发——保留它们反而引入 ESLint warnings。6 处 useEffect/useMemo 内的 setState/依赖模式本身是安全的(mount-only 初始化或受控 memoization),无需抑制。 - -**验收标准**: -- [x] ESLint "Unused eslint-disable directive" 从 6 降至 **0** -- [x] ESLint errors 保持 **0**(无新增) -- [x] ESLint warnings 从 65 降至 **59**(减少 6) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.93s) -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy(改动不影响运行时) - ---- - -## Q6: 前端巨型组件拆分 — ResourceVisualEditor [done] - -**问题**: `ResourceVisualEditor.tsx`(882 行)是项目最大的前端组件,包含 5 个内联资源编辑器(ConfigMap/Secret/Service/Ingress/PVC),是 PROJECT_EVALUATION.md 3.1 🔴 高优先级技术债"25 个 ≥500 行巨型组件"中最大的一个。单文件过大导致维护困难、代码导航低效。 - -**已完成 (2026-07-06)**: - -将 5 个内联资源编辑器提取为 `resource-visual-editors/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `ConfigMapEditor.tsx` | 95 | ConfigMap 键值对表格编辑器 | -| `SecretEditor.tsx` | 114 | Secret 键值对表格 (Base64 编码/解码) | -| `ServiceEditor.tsx` | 155 | Service 端口/类型/选择器编辑器 | -| `IngressEditor.tsx` | 215 | Ingress Host/Path 路由规则编辑器 | -| `PvcEditor.tsx` | 90 | PVC 存储大小/访问模式/StorageClass 编辑器 | - -主文件 `ResourceVisualEditor.tsx` 从 **882 行降至 282 行**(-68%),仅保留:加载/保存逻辑、YAML/可视化模式切换、资源类型分发(dispatch)。 - -**提取原则**: 每个子编辑器是自包含的函数组件,接收 `{manifest, onSave, saving, isCreate}` props(IngressEditor 额外接收 `clusterCode`),无共享状态。提取为纯机械操作(移动函数 + 添加 import/export),零逻辑变更。 - -**验收标准**: -- [x] ResourceVisualEditor.tsx 从 882 行降至 **282 行**(-68%) -- [x] 5 个提取的编辑器文件全部 < 220 行 -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.11s) -- [x] ESLint errors 保持 **0**(清理 11 个 unused import) -- [x] ESLint warnings 保持 **59**(无新增) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy -- [x] K8s 资源 YAML API 链路验证通过(visual editor dispatch 正常) - ---- - -## Q7: 前端巨型组件拆分 — AgentCLI [done] - -**问题**: `AgentCLI.tsx`(814 行)是 Q6 拆分后项目最大的前端组件,包含 AI 运维 Agent 执行页面的全部逻辑:WebSocket 事件处理、工具栏控制(模式切换/主机选择/集群选择/Pod选择/安全模式/状态徽章/核心能力说明)、对话区空状态、滚动按钮。单文件过大导致维护困难。 - -**已完成 (2026-07-06)**: - -将内联的静态数据、工具栏 UI、空状态、滚动按钮提取为独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `agent-cli-constants.ts` | 89 | WS 事件接口 + 安全模式定义 + 核心能力说明 + Pod 占位符 + 主机标签格式化 | -| `AgentCLIToolbar.tsx` | 281 | 工作区控制栏(模式切换 + 目标选择 + 安全模式 + 状态徽章 + 核心能力 Popover) | -| `AgentCLIEmptyState.tsx` | 42 | 对话区空状态(未选目标/连接中/就绪提示) | -| `ScrollButtons.tsx` | 43 | 浮动滚动按钮(回到顶部/底部),通用可复用组件 | - -主文件 `AgentCLI.tsx` 从 **814 行降至 521 行**(-36%),仅保留:状态管理、WebSocket 连接生命周期、事件处理(handleEvent switch)、发送/停止/恢复逻辑、对话区布局(AgentChatTimeline + 输入区)。 - -**提取原则**: 常量文件为纯数据(零 React 依赖,仅 lucide 图标类型引用);工具栏为受控组件(接收全部状态 + setter 作为 props,内部无独立状态);空状态为纯展示组件;滚动按钮为通用可复用组件(不限于 AgentCLI)。零逻辑变更。 - -**验收标准**: -- [x] AgentCLI.tsx 从 814 行降至 **521 行**(-36%) -- [x] 4 个提取的文件全部 < 300 行(最大 AgentCLIToolbar 281 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.95s) -- [x] ESLint errors 保持 **0**(清理 6 个 unused 变量/import) -- [x] ESLint warnings 保持 **59**(无新增) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) - ---- - -## Q8: 前端巨型组件拆分 — SystemSettingsTab [done] - -**问题**: `SystemSettingsTab.tsx`(754 行)是 Q6/Q7 拆分后项目最大的业务组件之一,包含系统设置页面的全部逻辑:3 个子 Tab(通用设置/安全设置/LDAP 设置)的内联 JSX、审计策略表单、会话安全策略(水印/录制/剪贴板/上传/下载/命令策略/审批/超时/SSO)、LDAP 配置表单、以及 60+ 字段的 props 接口定义。单文件过大导致维护困难、代码导航低效。 - -**已完成 (2026-07-07)**: - -将 3 个子 Tab 的 JSX 内容提取为 `system-settings/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `GeneralSettingsTab.tsx` | 154 | 通用设置 Tab(审计策略卡片 + 系统信息卡片) | -| `SecuritySettingsTab.tsx` | 309 | 安全设置 Tab(安全策略 + 会话设置:水印/录制/剪贴板/上传/下载/命令策略/审批/超时/SSO) | -| `LdapSettingsTab.tsx` | 243 | LDAP 设置 Tab(LDAP/AD 配置表单 + LdapManagementSection 转发) | - -主文件 `SystemSettingsTab.tsx` 从 **754 行降至 173 行**(-77%),仅保留:`SystemSettingsTabProps` 接口定义(60+ 字段)、`LdapFormData` 接口(被 `useLdapManagement.ts` 引用)、Tab 路由逻辑(URL searchParams 同步)、子 Tab 分发(`{subTab === "general" && }`)。 - -**提取原则**: 每个子 Tab 是自包含的函数组件,通过 `Pick` 精确声明所需的 props 子集(GeneralSettingsTab 取 9 个审计字段、SecuritySettingsTab 取 17 个安全/会话字段、LdapSettingsTab 取全部 LDAP + 管理透传字段)。主文件通过 `{...props}` 展开传递,零逻辑变更。 - -**验收标准**: -- [x] SystemSettingsTab.tsx 从 754 行降至 **173 行**(-77%) -- [x] 3 个提取的子 Tab 文件全部 < 310 行(最大 SecuritySettingsTab 309 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.71s) -- [x] ESLint errors 保持 **0**(无新增) -- [x] ESLint warnings 保持 **59**(无新增) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy -- [x] 系统设置页 3 个 Tab 功能不变(通用/安全/LDAP 全部可切换) - ---- - -## Q9: 前端巨型组件拆分 — NamespaceResourcePage [done] - -**问题**: `NamespaceResourcePage.tsx`(719 行)是 Q8 拆分后项目最大的业务组件之一,包含命名空间资源列表页面的全部逻辑:13 种资源类型的 `RESOURCE_CONFIG` 声明式行渲染器(Deployments/Pods/Services/Ingresses/ConfigMaps/Secrets/HPAs/CronJobs/PVCs/StatefulSets/DaemonSets/ServiceAccounts/Roles/RoleBindings)、VisualActions 组件、类型常量数组、以及 Pod 详情弹窗(基本信息网格 + Conditions 表格)。单文件过大导致维护困难、代码导航低效。 - -**已完成 (2026-07-07)**: - -将 RESOURCE_CONFIG 配置和 Pod 详情弹窗提取为独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `namespace-resource-config.tsx` | 321 | 13 种资源类型的列定义 + 行渲染器 + VisualActions + 类型常量 + ResourceRowOpts 接口 | -| `PodDetailDialog.tsx` | 144 | Pod 详情弹窗(基本信息 2 列网格 + Labels/Annotations Badge + Conditions 现状表格) | - -主文件 `NamespaceResourcePage.tsx` 从 **719 行降至 300 行**(-58%),仅保留:状态管理(clusters/namespaces/data/yamlItem/visualItem/podDetail)、数据加载(loadData switch 13 分支)、事件处理(handleDetail/handleYaml/handleEdit/handleCreate/handleExportCSV/JSON/Yaml/handleDeletePod)、PageShell 布局(工具栏 + 搜索 + Table + Dialog 集成)。 - -**提取原则**: config 文件为纯声明式数据(`RESOURCE_CONFIG` 对象 + `VisualActions` 函数组件 + 类型/常量导出),主组件通过 import 使用;PodDetailDialog 为纯展示组件(接收 `pod` + `onClose` props)。零逻辑变更。 - -**验收标准**: -- [x] NamespaceResourcePage.tsx 从 719 行降至 **300 行**(-58%) -- [x] 2 个提取的文件全部 < 325 行(namespace-resource-config 321 行 + PodDetailDialog 144 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 15.88s) -- [x] ESLint errors 保持 **0**(清理 4 个 unused import: Table/TableBody/TableHead/TableHeader) -- [x] ESLint warnings 保持 **59**(5 个 react-refresh/only-export-components 符合既有模式) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy -- [x] 命名空间资源列表页 13 种资源类型功能不变(全部可正常渲染/操作) - ---- - -## Q11: 前端巨型组件拆分 — PipelineRunDetail [done] - -**问题**: `PipelineRunDetail.tsx`(678 行)是 Q10 拆分后项目最大的业务组件之一(排除 UI 库 sidebar/data-table),包含流水线运行详情页面的全部逻辑:阶段定义/状态常量/工具函数、交互式阶段进度条(6 阶段点击过滤)、执行记录表(多选/停止/浏览/状态徽章)、WebSocket 实时日志 Console(阶段摘要 + 彩色日志 + 实时输出动画)。单文件过大导致维护困难、代码导航低效。 - -**已完成 (2026-07-07)**: - -将类型/常量/工具函数、阶段进度条、执行记录表、日志 Console 提取为 `pipeline-run-detail/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 119 | STAGE_DEFS/STATUS_BADGES/STATUS_ICONS 常量 + PipelineRun/LogEntry/StageStatus 接口 + formatDuration/formatTime/parseStageStatuses/getRunStageStats/getStageLogSummary 工具函数 + JsonData re-export | -| `StageProgressBar.tsx` | 89 | 交互式阶段进度条(6 阶段按钮 + 状态颜色 + 点击切换日志过滤 + ChevronRight 连接器) | -| `RunsTable.tsx` | 131 | 执行记录表(loading/空状态/checkbox 多选 + 状态徽章 + 触发方式 + 用户/时长/操作列) | -| `LogConsole.tsx` | 183 | 阶段日志 Console(CardHeader 元信息 + 阶段摘要 + 彩色日志渲染 + 阶段分隔符 + 实时输出动画) | - -主文件 `PipelineRunDetail.tsx` 从 **678 行降至 273 行**(-60%),仅保留:状态管理(pipeline/runs/selectedRun/logs/loading/selectedStage/pageSize/selectedIds/deleting/browserOpen/browserRun)、refs(logEndRef/wsRef)、数据加载(fetchPipeline/fetchRuns/fetchLogs)、WebSocket 生命周期(connectWS + onmessage init/done/log 分发)、自动刷新/自动滚动 effects、CRUD 操作(triggerRun/stopRun/batchDelete/toggleSelect/toggleAll)、派生数据(filteredLogs/stageSummary/successCount/runningCount)、PageShell 布局 + 3 个子组件集成。 - -**提取原则**: types 文件为纯数据/类型(零 React 依赖,仅 lucide 图标类型引用);StageProgressBar 为受控组件(接收 selectedRun/selectedStage/onSelectStage props,内部用 getRunStageStats 计算);RunsTable 为受控列表组件(接收 runs + 10 个回调 props);LogConsole 为纯展示组件(接收 selectedRun/pipeline/pipelineCode/filteredLogs/stageSummary/logEndRef props,内部用 getRunStageStats 计算阶段状态)。零逻辑变更。 - -**验收标准**: -- [x] PipelineRunDetail.tsx 从 678 行降至 **273 行**(-60%) -- [x] 4 个提取的文件全部 < 185 行(最大 LogConsole 183 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.39s) -- [x] ESLint errors 保持 **0**(清理 1 个 unused var + 2 个冗余 disable 指令) -- [x] ESLint warnings 保持 **59**(无新增) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy -- [x] 流水线运行详情页功能不变(阶段进度条/执行记录表/日志 Console/文件浏览器全部正常) - ---- - -## Q12: 前端巨型组件拆分 — CloudServerImport [done] - -**问题**: `CloudServerImport.tsx`(699 行)是 Q11 拆分后项目最大的业务组件之一(排除 UI 库 sidebar/data-table),包含云服务器导入 4 步骤向导的全部逻辑:状态管理(17 个 useState)、数据加载/区域同步/主机发现/导入业务逻辑、步骤指示器、4 个步骤的内联 JSX(云厂商选择/账号选择/区域选择表格/发现导入主机列表)、底部导航按钮。单文件过大导致维护困难、代码导航低效。此外文件顶部有 `eslint-disable @typescript-eslint/no-explicit-any`,状态用 `any[]` 类型。 - -**已完成 (2026-07-07)**: - -将步骤指示器、4 个向导步骤的 JSX、类型/常量提取为 `cloud-server-import/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 57 | CloudAccount/CloudRegion/CloudServer/ImportResult/StepDef 接口 + CLOUD_PROVIDERS 常量 | -| `StepIndicator.tsx` | 34 | 顶部 4 步骤进度指示器(当前/已完成/未完成三态 + ChevronRight 连接器) | -| `ProviderSelectStep.tsx` | 42 | Step 1 云厂商选择(5 厂商卡片网格 + CloudBrandIcon) | -| `AccountSelectStep.tsx` | 65 | Step 2 云账号选择(账号卡片列表 + 区域数/主机数 Badge + 空状态) | -| `RegionSelectStep.tsx` | 131 | Step 3 区域选择(搜索框 + 全选 + 区域表格 + 同步按钮 + 凭证设置插槽) | -| `DiscoverImportStep.tsx` | 176 | Step 4 发现导入(结果页/发现按钮/区域标签/错误提示/主机列表 + 凭证设置插槽) | - -主文件 `CloudServerImport.tsx` 从 **699 行降至 404 行**(-42%),仅保留:17 个状态声明、数据加载/区域同步/主机发现/导入业务逻辑、步骤导航(handleNext/handleBack/handleClose)、派生数据(filteredAccounts/filteredRegions/steps)、importSettingsPanel 条件渲染、Dialog 外壳 + StepIndicator + 4 步骤分发 + Footer 导航按钮。 - -**类型安全升级**: 顺带移除文件顶部的 `/* eslint-disable @typescript-eslint/no-explicit-any */`,将 `any[]` 状态类型化为具体接口(`CloudAccount[]`/`CloudRegion[]`/`CloudServer[]`/`ImportResult`),消除 API 调用中不必要的 `Record` 中间转换(直接访问 `.list` 属性)。全局 ESLint any 保持 **0**。 - -**提取原则**: 每个步骤是自包含的纯展示函数组件,通过 props 接收所需状态 + 回调(ProviderSelectStep 取 2 个、AccountSelectStep 取 3 个、RegionSelectStep 取 10 个、DiscoverImportStep 取 12 个),无共享状态。importSettingsPanel 作为 ReactNode 由父组件渲染后传入(Step 3/4 共用)。零逻辑变更。 - -**验收标准**: -- [x] CloudServerImport.tsx 从 699 行降至 **404 行**(-42%) -- [x] 6 个提取的文件全部 < 180 行(最大 DiscoverImportStep 176 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.56s) -- [x] ESLint errors 保持 **0**(拆分文件零 warnings) -- [x] ESLint warnings 保持 **64**(无新增) -- [x] 全局 ESLint any 保持 **0**(移除文件级 eslint-disable) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] 云服务器导入向导功能不变(4 步骤切换/厂商选择/账号选择/区域选择/发现导入全部正常) - ---- - -## Q13: 前端巨型组件拆分 — UnifiedQueryPanel [done] - -**问题**: `UnifiedQueryPanel.tsx`(682 行)是 Q12 拆分后项目最大的业务组件之一(排除 UI 库 sidebar/data-table),包含日志中心统一查询面板的全部逻辑:常量定义、数据源加载、URL 状态同步、Loki 标签/ES mapping 自动补全、查询执行(Loki/ES 双引擎)、直方图加载、加载更多、Live Tail 实时追踪、标签过滤、字段点击、分享链接、保存查询/告警、图表点击缩时、导出/复制、以及 5 个内联 UI 区块(查询控制行/次级工具栏/Log Volume 面积图/结果标签页/空状态)。单文件过大导致维护困难、代码导航低效。 - -**已完成 (2026-07-07)**: - -将常量、入口映射工具函数、5 个 UI 子组件提取为 `unified-query/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `constants.ts` | 23 | DEFAULT_TIME_RANGE / DEFAULT_QUERIES / ENGINE_BADGE 静态常量 | -| `entry-mappers.ts` | 44 | mapRawEntries(LogQueryEntry→LogEntry 映射 + level 解析)+ normalizeISO(ES 7.x 时间格式化)纯函数 | -| `QueryControlRow.tsx` | 68 | 查询控制行(引擎徽标 + QueryAutocompleteInput + 查询按钮 + Live Tail 按钮) | -| `SecondaryToolbar.tsx` | 112 | 次级控件行(TimeRangePicker + 数据源下拉 + ES 索引选择 + 查询上限 + 另存为菜单 + 分享/收藏/历史按钮) | -| `LogVolumeChart.tsx` | 38 | Log Volume 面积图(Recharts AreaChart + 点击缩时 + 引擎主题色渐变) | -| `ResultsTabs.tsx` | 103 | 结果标签页(logs/stats/cluster 三 Tab + logs tab 视图模式切换 + 字段侧栏集成) | -| `QueryEmptyStates.tsx` | 62 | 空状态(未查询引导 / 0 结果提示+重新查询 / loading spinner) | - -主文件 `UnifiedQueryPanel.tsx` 从 **682 行降至 529 行**(-22%),仅保留:50+ 个状态声明、数据源加载 effect、URL 状态双向同步、Loki 标签/ES mapping 自动补全 effect、查询执行核心逻辑(handleRun/handleLoadMore/runLiveTailTick)、事件处理(handleLabelFilter/handleRequestLabelValues/handleFieldClick/handleShareLink/handleChartClick/handleExportLogs/handleCopyLogs)、派生数据(fields/commonLabels/sortedEntries/pagedEntries)、以及 7 个子组件编排。 - -**提取原则**: constants 和 entry-mappers 为纯数据/函数(零 React 依赖);QueryControlRow/SecondaryToolbar 为受控组件(接收状态 + 回调 props,内部仅 useTranslation);LogVolumeChart 为纯展示组件(接收 histogramData + color + onChartClick);ResultsTabs 为受控容器(接收 20+ props 管理 3 个 Tab 切换);QueryEmptyStates 为三态纯展示组件。零逻辑变更。 - -**验收标准**: -- [x] UnifiedQueryPanel.tsx 从 682 行降至 **529 行**(-22%) -- [x] 7 个提取的文件全部 < 115 行(最大 SecondaryToolbar 112 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.57s) -- [x] ESLint errors 保持 **0**(UnifiedQueryPanel + unified-query/ 目录零 errors/warnings) -- [x] ESLint warnings 保持 **64**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] 日志中心查询面板功能不变(Loki/ES 双引擎查询/直方图/标签过滤/Live Tail/分享链接/保存查询/告警创建全部正常) - ---- - -## Q14: 前端巨型组件拆分 — ServerManager [done] - -**问题**: `ServerManager.tsx`(652 行)是 Q13 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含服务器管理页面的全部逻辑:17+ 个状态声明、CSV 导出业务逻辑(50 行内联)、表格工具栏+表格+分页的完整 JSX(~240 行)、以及 6 个弹窗组件的挂载。单文件过大导致维护困难。 - -**已完成 (2026-07-07)**: - -将 CSV 导出逻辑和表格+分页 JSX 提取为独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `server-export-utils.ts` | 57 | `buildServersCsv(servers, t)` 纯函数(CSV 表头构建+行映射+BOM)+ `downloadCsv(filename, content)` 浏览器下载触发 | -| `ServerTableSection.tsx` | 265 | 表格工具栏(搜索+状态筛选+批量删除栏)+ 服务器表格(表头+表体+加载/空状态)+ 分页控件,接收 21 个 props 的受控展示组件 | - -主文件 `ServerManager.tsx` 从 **652 行降至 442 行**(-32%),仅保留:状态管理(选择/搜索/筛选/分页/6 个弹窗状态)、数据获取(useServers/useServerMutations)、CRUD 操作(handleDelete/handleBatchDelete/openCreateForm/openEditForm/handleSave)、CSV 导出调用(buildServersCsv+downloadCsv 一行)、PageShell 布局 + actions 按钮组 + ServerTableSection + 弹窗组件挂载。 - -**提取原则**: server-export-utils 为纯函数(接收 TFunction 参数,零 React 依赖);ServerTableSection 为受控展示组件(接收 servers/loading/pagination/selection 共 21 个 props + 行操作回调,内部用 useTranslation 获取翻译),零逻辑变更。移除了 ServerManager 中不再使用的 12 个 import(Table/TableBody/TableHead/TableHeader/TableRow/Checkbox/Select*/Search/Trash2/ChevronLeft/ChevronRight/Input)。 - -**验收标准**: -- [x] ServerManager.tsx 从 652 行降至 **442 行**(-32%) -- [x] 2 个提取的文件全部 < 270 行(最大 ServerTableSection 265 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.40s) -- [x] ESLint errors 保持 **0**(3 个文件零 errors/warnings) -- [x] ESLint warnings 保持 **64**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] 服务器管理页功能不变(列表/搜索/筛选/分页/选择/批量删除/CSV 导出/创建编辑/终端/RDP/文件浏览全部正常) - ---- - -## Q15: 前端巨型组件拆分 — PipelineEditor [done] - -**问题**: `PipelineEditor.tsx`(676 行)是 Q14 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含流水线编辑器页面的全部逻辑:类型定义 + 常量 + 工具函数(Task/Stage/LogEntry 接口 + taskIcons 映射 + uid/fmtDuration)、任务卡片组件、任务编辑面板、日志弹窗、以及主组件(状态管理 + 数据加载 + 事件处理 + 5 个 Tab 的 JSX:流程配置/触发器/变量/发布记录/成员管理)。单文件过大导致维护困难、代码导航低效。 - -**已完成 (2026-07-08)**: - -将类型/常量/工具、任务卡片、任务编辑面板、日志弹窗、发布记录 Tab、成员管理 Tab 提取为 `pipeline-editor/` 子目录下的独立文件: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `types.ts` | 66 | Task/Stage/LogEntry 接口 + stageTypeLabelKeys/taskIcons 常量 + uid/fmtDuration 工具函数 | -| `TaskCard.tsx` | 58 | 任务卡片展示组件(选中态 + 图标 + 名称/类型 + 悬浮删除按钮) | -| `TaskEditorPanel.tsx` | 91 | 任务编辑面板(名称/类型/图标选择 + 脚本类型切换 + 脚本内容编辑) | -| `PipelineLogDialog.tsx` | 71 | 流水线运行日志弹窗(运行元信息 + 彩色日志渲染 + 空状态) | -| `RunsHistoryTab.tsx` | 177 | 发布记录 Tab(运行历史表格 + 状态徽章 + 触发方式 + 操作按钮) | -| `PipelineMembersTab.tsx` | 172 | 成员管理 Tab(添加成员表单 + 直接用户列表 + 组授权列表) | - -主文件 `PipelineEditor.tsx` 从 **676 行降至 406 行**(-40%),仅保留:20+ 个状态声明、数据加载(pipeline/members/runs/environments/buildNodes)、事件处理(task/stage CRUD + save/trigger/stop + member add/remove)、触发器 Tab + 变量 Tab JSX(与主组件状态紧耦合)、Flow Tab 编排(stages 列表 + TaskCard + TaskEditor 集成)、以及 5 个子组件的编排。 - -**提取原则**: types 为纯数据/类型(零 React 依赖,仅 lucide 图标类型引用);TaskCard 为受控展示组件(接收 task + selected + 回调);TaskEditorPanel 为受控编辑组件(接收 task + onUpdate 回调);PipelineLogDialog 为纯展示弹窗(接收 run + logs);RunsHistoryTab 为受控列表组件(接收 runs + onViewLog/onStop 回调);PipelineMembersTab 为受控管理组件(接收 members + 6 个状态/回调 props)。零逻辑变更。 - -**验收标准**: -- [x] PipelineEditor.tsx 从 676 行降至 **406 行**(-40%) -- [x] 6 个提取的文件全部 < 180 行(最大 RunsHistoryTab 177 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.35s) -- [x] ESLint errors 保持 **0**(清理 1 个 unused import: Loader2) -- [x] ESLint warnings 保持 **64**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] 流水线编辑器页功能不变(流程配置/触发器/变量/发布记录/成员管理 5 个 Tab 全部正常) - ---- - -## Q38: 前端巨型组件拆分 — UnifiedQueryPanel [done] - -**问题**: `UnifiedQueryPanel.tsx`(529 行)是全项目最后一个 ≥500 行的业务组件(排除 UI 库 sidebar.tsx 690L / data-table.tsx 656L),包含日志查询面板的全部逻辑:~40 个 useState 状态管理、查询执行(Loki/ES 分支 + 直方图加载 + 派生数据 + 历史记录)、加载更多(分页游标)、Live Tail 实时追踪(3s interval + 去重 + 截断)、导出/复制、以及 6 个 useEffect(URL bootstrap/sync、Loki 标签名、ES mapping、Live Tail、自动运行)。虽然 UI 子组件已提取(Q13: 682L→529L),但核心业务逻辑仍全部内联。 - -**已完成 (2026-07-10)**: - -将查询执行逻辑和导出/复制逻辑提取为 `unified-query/` 子目录下的自定义 Hooks: - -| 新文件 | 行数 | 职责 | -|--------|------|------| -| `useLogQueryEngine.ts` | 295 | 日志查询执行引擎 Hook — 封装查询状态机:loading/entries/statTable/clusters/histogramData/totalCount/queryTime/hasMore/loadingMore/liveTail/hasQueried + fetchHistogram/handleRun/handleLoadMore/runLiveTailTick + Live Tail useEffect(3s interval),接收 effEngine/query/currentSource/esIndex/queryLimit/timeRange/setTimeRange/toast/t 作为 opts | -| `useLogExport.ts` | 62 | 日志导出/复制 Hook — formatEntryLine + handleExportLogs(Blob 下载) + handleCopyLogs(clipboard),接收 effEngine/toast/t 作为 opts | - -主文件从 **529 行降至 353 行**(-33%),全项目无 ≥500 行业务组件。 - -**关键改进**: 项目首次使用 custom Hook 提取模式进行组件拆分。`useLogQueryEngine` 封装了完整的查询状态机(执行/加载更多/直方图/Live Tail),主组件通过 opts 传入依赖。原 `timeIsoParams` useCallback 消除(4 处使用改为直接内联),export/callback 从闭包捕获改为参数传入使 Hook 可复用。 - -**验收标准**: -- [x] UnifiedQueryPanel.tsx 从 529 行降至 **353 行**(-33%),全项目无 ≥500 行业务组件 -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.69s) -- [x] ESLint errors 保持 **0**,warnings 保持 **66** -- [x] 全局 ESLint any 保持 **0**,unused-vars 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) - ---- - -## U24: Monitor 模块 i18n hook 迁移 [done] - -**问题**: `src/pages/monitor/` 目录下 26 个 .tsx 文件仍使用全局 `import i18n from '@/i18n'` + `i18n.t('Chinese text')` 模式(phantom key 问题 — 中文文本作为 i18n key,在 zh-CN locale 下「恰好工作」(返回 key 本身),但切换 en-US 时仍显示中文文本而非英文翻译)。这是 U17-U23 i18n 治理系列中最后一个大规模 phantom key 模块,涉及 299 处 `i18n.t()` 调用和 89 个唯一 phantom 中文文本键。 - -**已完成 (2026-07-11)**: - -1. **新增 `monitor.lbl` 翻译子命名空间** — 89 个语义化翻译键覆盖 5 个子模块(HostMonitor/MiddlewareMonitor/SiteMonitor/SNMPMonitor/Shared) -2. **.tsx 文件 hook 迁移** — 26 个文件从 `import i18n` → `useTranslation()` hook + phantom 中文文本键 → `monitor.lbl.*` 语义化键 -3. **模块级代码保留 `i18n.t()`** — TABS 常量/METRIC_CATEGORIES 等模块级代码保留 `i18n.t()` + 语义化键 -4. **.ts 工具文件** — helpers.ts 中文文本键替换为语义化键(保留 `i18n.t()` 因 .ts 无法用 hook) - -**验收标准**: -- [x] `monitor.lbl` 命名空间 89 个语义化键,zh/en 完全对齐(6975 = 6975) -- [x] 26 个 .tsx 文件迁移到 `useTranslation()` hook -- [x] 299 处 `i18n.t()` → 74 保留 + 225 迁移到 `t()` hook -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.16s) -- [x] ESLint errors=0,any=0,unused=0 -- [x] `go build` 零错误 -- [x] Docker 部署 healthy - ---- - -## U31: AIOps Agent CLI 组件 i18n 修复 [done] - -**问题**: AIOps Agent CLI 组件群(AgentCLI + AgentCLIToolbar + AgentCLIHistory + AgentCLIEmptyState + ChatInputArea + agent-cli-constants.ts)在 Q7/Q35 组件拆分后丢失了 U8 (v0.105) 的 i18n 集成——6 个文件中 ~66 行 UI 硬编码中文文本未接入 `useTranslation()` hook,切换 en-US 时仍显示中文。 - -**根因**: U8 (v0.105) 在原始 AgentCLI.tsx (814L) 上完成了 216 处中文清零。但后续 Q7 (v0.111) 将 AgentCLI 拆分为 AgentCLIToolbar/AgentCLIEmptyState/ScrollButtons + agent-cli-constants.ts,Q35 (v0.124) 又拆出 agent-cli/event-handler.ts + ChatInputArea.tsx。拆分过程中提取的文件未保留 i18n 集成,硬编码中文回归。 - -**已完成 (2026-07-12)**: - -1. **新增 `aiops.cli.*` 翻译命名空间** — 100 个翻译键,8 个子分类 (toolbar/safety/tips/error/info/viewMode/placeholder/history/empty/input) -2. **常量数组改为工厂函数** — `SAFETY_MODES`/`TIPS_HOST`/`TIPS_KUBERNETES` 从静态导出常量改为 `getSafetyModes(t)`/`getTipsHost(t)`/`getTipsKubernetes(t)` 接收 `TFunction` 参数的工厂函数(符合项目"工具文件收 TFunction 参数"约定) -3. **6 个组件文件迁移**: - - `AgentCLI.tsx` — subtitle/toast/error/placeholder/viewMode 共 22 处中文 → t() 调用 - - `AgentCLIToolbar.tsx` — 模式/目标选择/Pod/安全模式/历史/状态徽章/核心能力共 28 处 → t() - - `AgentCLIHistory.tsx` — toast/confirm/label/status 共 16 处 → t() - - `AgentCLIEmptyState.tsx` — 空状态文案 8 处 → t() - - `ChatInputArea.tsx` — hint/stop/execute 3 处 → t() - - `agent-cli-constants.ts` — SAFETY_MODES 3 模式 × 4 字段 + TIPS 2 × 4 × 2 字段全部通过工厂函数 → t() - -**验收标准**: -- [x] `aiops.cli.*` 命名空间 100 个语义化键,zh/en 完全对齐(7481 = 7481) -- [x] 6 个文件全部 UI 硬编码中文清零(剩余 CJK 仅为注释) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 18.21s) -- [x] ESLint errors=0,any=0,unused=0,warnings=53(无新增) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过 -- [x] Docker 部署 healthy - diff --git a/TDD_PLAN.md b/TDD_PLAN.md index bf38be7..75f8f94 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -1088,449 +1088,3 @@ python3 ../tests/integration_test.py --host 127.0.0.1 --port 8080 - [x] U4: KubernetesClusterDetail 非注释 CJK = 0 - [x] U5: TopologyView 非注释 CJK = 0 - [x] U6: 17/17 集成回归测试通过 - ---- - -## P3-U13: 主机监控大屏挂载(HostMonitorDashboard 死代码复活) - -### 验证用例 - -```bash -# U1: 路由配置存在 -grep -c 'host-dashboard' /srv/aiops/frontend/src/App.tsx -# 期望: >= 2 (lazy import + Route) - -# U2: Sidebar 菜单项存在 -grep -c 'host-dashboard' /srv/aiops/frontend/src/components/layout/Sidebar.tsx -# 期望: >= 1 - -# U3: Breadcrumb 映射存在 -grep -c 'host-dashboard' /srv/aiops/frontend/src/components/layout/Breadcrumb.tsx -# 期望: >= 1 - -# U4: i18n 翻译键存在且对齐 -python3 -c " -import json -zh = json.load(open('/srv/aiops/frontend/src/locales/zh-CN.json', encoding='utf-8')) -en = json.load(open('/srv/aiops/frontend/src/locales/en-US.json', encoding='utf-8')) -assert zh['menu']['hostDashboard'] == '主机大屏' -assert en['menu']['hostDashboard'] == 'Host Dashboard' -assert zh['breadcrumb']['hostDashboard'] == '主机大屏' -assert en['breadcrumb']['hostDashboard'] == 'Host Dashboard' -print('✅ i18n keys aligned') -" - -# U5: TypeScript 编译零错误 -cd /srv/aiops/frontend && npx tsc -b 2>&1 | tail -3 -# 期望: 退出码 0 - -# U6: Vite 构建 -npm run build 2>&1 | tail -3 -# 期望: built in Ns - -# U7: ESLint 无新增 errors -npx eslint src/ --format json 2>/dev/null | python3 -c " -import json, sys -data = json.load(sys.stdin) -errors = sum(1 for r in data for m in r.get('messages', []) if m.get('severity') == 2) -assert errors == 0, f'{errors} errors' -print('✅ ESLint errors=0') -" - -# U8: 集成回归测试 -python3 /srv/aiops/tests/integration_test.py --host 127.0.0.1 --port 8080 | tail -3 -# 期望: 17/17 passed -``` - -### 通过标准 -- [x] U1: App.tsx 路由配置存在(lazy import + Route path="host-dashboard") -- [x] U2: Sidebar 监控菜单含「主机大屏」项 -- [x] U3: Breadcrumb 路由映射存在 -- [x] U4: zh/en 翻译键对齐(menu.hostDashboard + breadcrumb.hostDashboard) -- [x] U5: `tsc -b` 零错误 -- [x] U6: `npm run build` 零错误(✓ built in 17.50s) -- [x] U7: ESLint errors=0(无新增) -- [x] U8: 17/17 集成回归测试通过(零回归) - ---- - -## P0-Q8: 前端巨型组件拆分 — SystemSettingsTab - -### 验证用例 - -```bash -# U1: 主文件行数 -wc -l /srv/aiops/frontend/src/components/system/SystemSettingsTab.tsx -# 期望: < 200 行 - -# U2: 子 Tab 文件存在 -ls -la /srv/aiops/frontend/src/components/system/system-settings/ -# 期望: GeneralSettingsTab.tsx, SecuritySettingsTab.tsx, LdapSettingsTab.tsx - -# U3: TypeScript 编译零错误 -cd /srv/aiops/frontend && npx tsc -b 2>&1 | grep 'error TS' | wc -l -# 期望: 0 - -# U4: Vite 构建 -npm run build 2>&1 | tail -1 -# 期望: built in Ns - -# U5: ESLint 无新增 errors -npx eslint src/components/system/SystemSettingsTab.tsx src/components/system/system-settings/ --format json 2>/dev/null | python3 -c " -import json, sys -data = json.load(sys.stdin) -errors = sum(1 for r in data for m in r.get('messages', []) if m.get('severity') == 2) -assert errors == 0, f'{errors} errors' -print('✅ ESLint errors=0') -" - -# U6: go build -cd /srv/aiops/backend && go build ./... -# 期望: 零错误 - -# U7: 集成回归 -python3 /srv/aiops/tests/integration_test.py --host 127.0.0.1 --port 8080 | tail -3 -# 期望: 17/17 passed -``` - -### 通过标准 -- [x] U1: SystemSettingsTab.tsx 从 754 行降至 **173 行**(< 200 ✅) -- [x] U2: 3 个子 Tab 文件全部存在(GeneralSettingsTab/SecuritySettingsTab/LdapSettingsTab) -- [x] U3: `tsc -b` 零错误 -- [x] U4: `npm run build` 零错误(✓ built in 17.71s) -- [x] U5: ESLint errors 保持 **0**(无新增) -- [x] U6: `go build` 零错误 -- [x] U7: 17/17 集成回归测试通过(零回归) - - ---- - -## P0-Q10: 前端巨型组件拆分 — ApprovalWorkflowPage - -### 验证用例 - -```bash -# U1: 主文件行数 -wc -l /srv/aiops/frontend/src/pages/approval/ApprovalWorkflowPage.tsx -# 期望: < 200 行 - -# U2: 提取文件存在且行数合理 -wc -l /srv/aiops/frontend/src/pages/approval/approval-workflow/*.ts /srv/aiops/frontend/src/pages/approval/approval-workflow/*.tsx -# 期望: 4 个文件,每个 < 300 行 - -# U3: TypeScript 编译 -cd /srv/aiops/frontend && npx tsc -b -# 期望: 零错误 - -# U4: Vite 构建 -npm run build 2>&1 | tail -3 -# 期望: built in Ns - -# U5: ESLint -npx eslint src/pages/approval/ --format compact -# 期望: 0 errors, 0 warnings - -# U6: go build -cd /srv/aiops/backend && go build ./... -# 期望: 零错误 - -# U7: 集成回归 -python3 /srv/aiops/tests/integration_test.py --host 127.0.0.1 --port 8080 | tail -3 -# 期望: 17/17 passed -``` - -### 通过标准 -- [x] U1: ApprovalWorkflowPage.tsx 从 705 行降至 **167 行**(< 200 ✅,-76%) -- [x] U2: 4 个提取文件全部 < 275 行(types 93L + UserSelectInline 96L + WorkflowEditorModal 272L + WorkflowCard 134L) -- [x] U3: `tsc -b` 零错误 -- [x] U4: `npm run build` 零错误(✓ built in 16.99s) -- [x] U5: ESLint errors 保持 **0**(approval-workflow/ 目录零 warnings) -- [x] U6: `go build` 零错误 -- [x] U7: 17/17 集成回归测试通过(零回归) - ---- - -## P0-Q11: 前端巨型组件拆分 — PipelineRunDetail - -### 通过标准 -- [x] PipelineRunDetail.tsx 从 678 行降至 **273 行**(-60%) -- [x] 4 个提取的文件全部 < 185 行(types.ts 119L / StageProgressBar.tsx 89L / RunsTable.tsx 131L / LogConsole.tsx 183L) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.39s) -- [x] ESLint errors 保持 **0**(清理 1 个 unused var `stageStats` + 2 个冗余 `eslint-disable` 指令) -- [x] ESLint warnings 保持 **59**(无新增) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy -- [x] 流水线运行详情页功能不变(阶段进度条点击过滤 / 执行记录表多选+停止 / 实时日志 Console / 文件浏览器) - ---- - -## P0-Q12: 前端巨型组件拆分 — CloudServerImport - -### 通过标准 -- [x] CloudServerImport.tsx 从 699 行降至 **404 行**(-42%) -- [x] 6 个提取的文件全部 < 180 行(types.ts 57L / StepIndicator.tsx 34L / ProviderSelectStep.tsx 42L / AccountSelectStep.tsx 65L / RegionSelectStep.tsx 131L / DiscoverImportStep.tsx 176L) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.56s) -- [x] ESLint errors 保持 **0**(拆分文件零 warnings) -- [x] ESLint warnings 保持 **64**(无新增) -- [x] 全局 ESLint any 保持 **0**(移除文件级 `eslint-disable @typescript-eslint/no-explicit-any`,any[] 状态→具体接口) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] 云服务器导入向导功能不变(4 步骤切换 / 厂商选择 / 账号选择 / 区域选择搜索+全选 / 主机发现+导入) - ---- - -## P0-Q13: 前端巨型组件拆分 — UnifiedQueryPanel - -### 验证用例 - -```bash -# U1: 主文件行数 -wc -l /srv/aiops/frontend/src/components/monitor/log-center/UnifiedQueryPanel.tsx -# 期望: < 600 行 - -# U2: 提取文件存在且行数合理 -wc -l /srv/aiops/frontend/src/components/monitor/log-center/unified-query/*.ts /srv/aiops/frontend/src/components/monitor/log-center/unified-query/*.tsx -# 期望: 7 个文件,每个 < 120 行 - -# U3: TypeScript 编译 -cd /srv/aiops/frontend && npx tsc -b -# 期望: 零错误 - -# U4: Vite 构建 -npm run build 2>&1 | tail -3 -# 期望: built in Ns - -# U5: ESLint -npx eslint src/components/monitor/log-center/UnifiedQueryPanel.tsx src/components/monitor/log-center/unified-query/ --format compact -# 期望: 0 errors, 0 warnings - -# U6: go build -cd /srv/aiops/backend && go build ./... -# 期望: 零错误 - -# U7: 集成回归 -python3 /srv/aiops/tests/integration_test.py --host 127.0.0.1 --port 8080 | tail -3 -# 期望: 17/17 passed -``` - -### 通过标准 -- [x] UnifiedQueryPanel.tsx 从 682 行降至 **529 行**(< 600 ✅,-22%) -- [x] 7 个提取文件全部 < 115 行(constants 23L + entry-mappers 44L + LogVolumeChart 38L + QueryControlRow 68L + QueryEmptyStates 62L + ResultsTabs 103L + SecondaryToolbar 112L) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.57s) -- [x] ESLint errors 保持 **0**(UnifiedQueryPanel + unified-query/ 目录零 warnings) -- [x] ESLint warnings 保持 **64**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] 日志中心查询面板功能不变(Loki/ES 双引擎查询/直方图/标签过滤/Live Tail/分享链接/保存查询/告警创建全部正常) - ---- - -## Q14: 前端巨型组件拆分 — ServerManager - -### 验证用例 - -```bash -# U1: Go 编译 -cd /srv/aiops/backend && go build ./... -# 期望: 零错误 - -# U2: TypeScript 编译 -cd /srv/aiops/frontend && npx tsc -b -# 期望: 零错误 - -# U3: Vite 构建 -npm run build 2>&1 | tail -3 -# 期望: built in Ns - -# U4: 拆分后文件行数 -wc -l src/components/server/ServerManager.tsx src/components/server/ServerTableSection.tsx src/components/server/server-export-utils.ts -# 期望: ServerManager < 500L, 其余 < 270L - -# U5: ESLint 全局指标 -npx eslint src/ --format json | python3 -c " -import json, sys -d = json.load(sys.stdin) -print(f'errors={sum(1 for r in d for m in r[\"messages\"] if m[\"severity\"]==2)}') -print(f'any={sum(1 for r in d for m in r[\"messages\"] if m.get(\"ruleId\")==\"@typescript-eslint/no-explicit-any\")}') -" -# 期望: errors=0, any=0 - -# U6: 集成测试 -python3 tests/integration_test.py --host 127.0.0.1 --port 8080 -# 期望: 17/17 passed -``` - -### 通过标准 -- [x] ServerManager.tsx 从 652 行降至 **442 行**(< 500 ✅,-32%) -- [x] 2 个提取文件全部 < 270 行(server-export-utils 57L + ServerTableSection 265L) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 16.40s) -- [x] ESLint errors 保持 **0**(3 个文件零 errors/warnings) -- [x] ESLint warnings 保持 **64**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] 服务器管理页功能不变(列表/搜索/筛选/分页/选择/批量删除/CSV 导出/创建编辑/终端/RDP/文件浏览全部正常) - -## Q21: 前端巨型组件拆分 — DeploymentEditAdvanced - -### 验证用例 - -```bash -# U1: Go 编译 -cd /srv/aiops/backend && go build ./... -# 期望: 零错误 - -# U2: TypeScript 编译 -cd /srv/aiops/frontend && npx tsc -b -# 期望: 零错误 - -# U3: Vite 构建 -npm run build 2>&1 | tail -3 -# 期望: built in Ns - -# U4: 拆分后文件行数 -wc -l src/components/kubernetes/DeploymentEditAdvanced.tsx src/components/kubernetes/deployment-edit-advanced/types.ts src/components/kubernetes/deployment-edit-advanced/UpdateStrategyCard.tsx src/components/kubernetes/deployment-edit-advanced/TolerationsCard.tsx src/components/kubernetes/deployment-edit-advanced/PodMiscCard.tsx src/components/kubernetes/deployment-edit-advanced/PodSecurityCard.tsx src/components/kubernetes/deployment-edit-advanced/HostSettingsCard.tsx -# 期望: DeploymentEditAdvanced < 300L, Card 组件 < 100L, types < 280L - -# U5: ESLint 全局指标 -npx eslint src/ --format json | python3 -c " -import json, sys -d = json.load(sys.stdin) -print(f'errors={sum(1 for r in d for m in r[\"messages\"] if m[\"severity\"]==2)}') -print(f'any={sum(1 for r in d for m in r[\"messages\"] if m.get(\"ruleId\")==\"@typescript-eslint/no-explicit-any\")}') -" -# 期望: errors=0, any=0 - -# U6: 集成测试 -python3 tests/integration_test.py --host 127.0.0.1 --port 8080 -# 期望: 17/17 passed -``` - -### 通过标准 -- [x] DeploymentEditAdvanced.tsx 从 590 行降至 **289 行**(< 300 ✅,-51%) -- [x] 6 个提取文件全部 < 280 行(types.ts 279L 含纯函数,5 个 Card 组件全部 < 91 行) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.36s) -- [x] ESLint errors 保持 **0**(DeploymentEditAdvanced + deployment-edit-advanced/ 目录零 errors/warnings) -- [x] ESLint warnings 保持 **65**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] Deployment 高级设置 Tab 功能不变(更新策略/历史版本/容忍/调度/Pod 杂项/安全上下文/主机设置/DNS+hostAliases 全部正常) -- [x] manifest 解析和序列化提取为纯函数(parseAdvancedManifest + buildAdvancedManifest) - ---- - -## Q18: 前端巨型组件拆分 — TraceManager - -### 验证用例 - -```bash -# U1: Go 编译 -cd /srv/aiops/backend && go build ./... -# 期望: 零错误 - -# U2: TypeScript 编译 -cd /srv/aiops/frontend && npx tsc -b -# 期望: 零错误 - -# U3: Vite 构建 -npm run build 2>&1 | tail -3 -# 期望: built in Ns - -# U4: 拆分后文件行数 -wc -l src/components/monitor/trace/TraceManager.tsx src/components/monitor/trace/trace-manager/*.ts src/components/monitor/trace/trace-manager/*.tsx -# 期望: TraceManager < 340L, 其余 < 95L - -# U5: ESLint 全局指标 -npx eslint src/ --format json | python3 -c " -import json, sys -d = json.load(sys.stdin) -print(f'errors={sum(1 for r in d for m in r[\"messages\"] if m[\"severity\"]==2)}') -print(f'any={sum(1 for r in d for m in r[\"messages\"] if m.get(\"ruleId\")==\"@typescript-eslint/no-explicit-any\")}') -" -# 期望: errors=0, any=0 - -# U6: 集成测试 -python3 tests/integration_test.py --host 127.0.0.1 --port 8080 -# 期望: 17/17 passed - -# U7: traces API 可用(链路追踪页面后端代理) -TOKEN=$(curl -s http://127.0.0.1:8080/api/v1/auth/login -X POST -H 'Content-Type: application/json' -d '{"username":"admin","password":"123456"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])') -curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${TOKEN}" "http://127.0.0.1:8080/api/v1/traces?timeRange=1h" -# 期望: 200 -``` - -### 通过标准 -- [x] TraceManager.tsx 从 605 行降至 **331 行**(< 340 ✅,-45%) -- [x] 5 个提取文件全部 < 95 行(types 90L + trace-helpers 44L + SpanTreeNode 71L + TraceDetailDialog 80L + SpanDetailDialog 91L) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 18.23s) -- [x] ESLint errors 保持 **0**(trace-manager/ 目录零 warnings) -- [x] ESLint warnings 保持 **64**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200),traces API 返回 200 -- [x] 链路追踪管理页功能不变(Traces/Services/Metrics 三 Tab + Trace 详情 Span 树 + Span 详情标签/日志全部正常) -- [x] SpanTreeNode 内联定义反模式已修复(提取为模块级稳定组件引用) - ---- - -## Q26: 前端巨型组件拆分 — PodLogsViewer [done] - -### 通过标准 -- [x] PodLogsViewer.tsx 从 553 行降至 **331 行**(-40%) -- [x] 5 个提取的文件全部 < 180 行(types.tsx 57L, LogsHeader 66L, ControlBar 178L, LogContentArea 64L, BottomStatusBar 42L) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.34s) -- [x] ESLint errors 保持 **0**(PodLogsViewer + pod-logs-viewer/ 目录零 errors/warnings) -- [x] ESLint warnings 保持 **65**(无新增) -- [x] 全局 ESLint any 保持 **0** -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) -- [x] Pod 日志查看器功能不变(容器选择/行数限制/previous/sinceTime/关键字过滤+高亮/下载/刷新/实时流/暂停继续/全屏切换全部正常) - ---- - -## U20: ESLint static-components 清零 + CredDetailDialog 内联组件修复 + i18n 损坏修复 [done] - -### 通过标准 -- [x] `react-hooks/static-components` 警告从 23 降至 **0**(CredDetailDialog.tsx `Field` 内联组件提取为模块级 `CredField`) -- [x] ESLint warnings 从 66 降至 **43**(-35%,全部剩余为 react-refresh/only-export-components 架构性约束 + 1 个 @tanstack/react-virtual 兼容性) -- [x] ESLint errors 保持 **0** -- [x] 修复 8 处被批量正则损坏的 `i18n.t()` 调用(`i18n.t('>名称:')` 等 → 语义化键如 `common.name`、`common.createdAt`) -- [x] 6 处硬编码中文清零(凭据详情、密钥数据、无、创建时间、更新时间、安全提示) -- [x] 新增 `deploy.cred.sensitiveWarn` i18n 键(zh/en 对齐) -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 17.29s) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy - ---- - -## U46: i18n 残余 UI 硬编码中文清零 — 深度边角 - -### 通过标准 -- [x] 6 个修复文件中无残余硬编码中文(UserFormDialog/CredentialManager/RdpAuditViewer/LiveMonitorDialog/ServerFileBrowser/context-loaders) -- [x] context-loaders.ts 全部模板字符串使用 i18n.t() 插值 -- [x] `tsc -b` 零错误 -- [x] `npm run build` 零错误(✓ built in 18.05s) -- [x] ESLint errors=0, any=0 -- [x] zh/en 8102=8102 完全对齐(+17 新键) -- [x] `go build` 零错误 -- [x] 17/17 集成回归测试通过(零回归) -- [x] Docker 部署 healthy - diff --git a/agent/collector/health_probe.go b/agent/collector/health_probe.go index 16f5932..2441e55 100644 --- a/agent/collector/health_probe.go +++ b/agent/collector/health_probe.go @@ -13,6 +13,7 @@ import ( "net/http" "os/exec" "runtime" + "strconv" "strings" "time" @@ -161,7 +162,8 @@ func (h *HealthProbeCollector) execTCP(cfg model.ProbeConfig, start time.Time) m timeout = 10 * time.Second } - addr := fmt.Sprintf("%s:%d", cfg.TCPHost, cfg.TCPPort) + // JoinHostPort 兼容 IPv4、主机名与 IPv6 字面量。 // Logan.Li + addr := net.JoinHostPort(cfg.TCPHost, strconv.Itoa(cfg.TCPPort)) conn, err := net.DialTimeout("tcp", addr, timeout) latencyMs := int(time.Since(start).Milliseconds()) result.LatencyMs = latencyMs diff --git a/agent/deploy/install.sh b/agent/deploy/install.sh index 96d2562..6451c58 100644 --- a/agent/deploy/install.sh +++ b/agent/deploy/install.sh @@ -8,6 +8,7 @@ set -e # 默认参数 AIOPS_SERVER="" AGENT_TOKEN="" +AGENT_HOSTNAME="" INSTALL_DIR="/usr/local/bin" SERVICE_NAME="aiops-agent" PLIST_LABEL="com.ezops.aiops-agent" @@ -34,6 +35,7 @@ while [[ $# -gt 0 ]]; do case $1 in --server) AIOPS_SERVER="$2"; shift 2;; --token) AGENT_TOKEN="$2"; shift 2;; + --hostname) AGENT_HOSTNAME="$2"; shift 2;; --version) AGENT_VERSION="$2"; shift 2;; *) echo "未知参数: $1"; exit 1;; esac @@ -142,6 +144,8 @@ if [ "$OS_LOWER" = "darwin" ]; then 10 -token ${AGENT_TOKEN} + -hostname + ${AGENT_HOSTNAME} RunAtLoad @@ -193,7 +197,7 @@ Wants=network-online.target [Service] Type=simple -ExecStart=${INSTALL_DIR}/aiops-agent -server ${AIOPS_SERVER} -interval 10 -token ${AGENT_TOKEN} +ExecStart=${INSTALL_DIR}/aiops-agent -server ${AIOPS_SERVER} -interval 10 -token ${AGENT_TOKEN} -hostname ${AGENT_HOSTNAME} Restart=always RestartSec=5 LimitNOFILE=65536 diff --git a/agent/main.go b/agent/main.go index d72e1ee..9e569cf 100644 --- a/agent/main.go +++ b/agent/main.go @@ -116,12 +116,11 @@ func main() { sysInfo := collector.CollectSystem() logger.Printf("系统信息: OS=%q Kernel=%q", sysInfo.OSVersion, sysInfo.Kernel) + agentStartedAt := time.Now() localIP := detectLocalIP() logger.Printf("探测本机 IP: %s", localIP) - sysReportCount := 0 - taskExecutor := task.New(logger) taskClient := task.NewClient(httpCli, cfg.Hostname, logger) @@ -135,20 +134,17 @@ func main() { var probeConfigs []model.ProbeConfig collectAndReport := func() { + currentUptime := sysInfo.UptimeSeconds + int64(time.Since(agentStartedAt).Seconds()) report := &model.Report{ Hostname: cfg.Hostname, IP: localIP, Timestamp: time.Now().UTC().Format(time.RFC3339), AgentVersion: version, - } - - if sysReportCount < 3 && (sysInfo.OSVersion != "" || sysInfo.Kernel != "") { - report.System = &model.SystemStat{ + System: &model.SystemStat{ OSVersion: sysInfo.OSVersion, Kernel: sysInfo.Kernel, - Uptime: sysInfo.UptimeSeconds, - } - sysReportCount++ + Uptime: currentUptime, + }, } if stat, err := cpuCollector.Collect(); err != nil { @@ -211,7 +207,7 @@ func main() { runtime.ReadMemStats(&memStats) report.Heartbeat = model.AgentHeartbeat{ Version: version, - UptimeSec: sysInfo.UptimeSeconds, + UptimeSec: currentUptime, SelfCPU: 0, SelfRSSMB: float64(memStats.Alloc) / 1024.0 / 1024.0, ReportSeq: reportSeq, diff --git a/backend/cloud/aws_billing.go b/backend/cloud/aws_billing.go index 2535083..bf8dc8c 100644 --- a/backend/cloud/aws_billing.go +++ b/backend/cloud/aws_billing.go @@ -1,147 +1,123 @@ package cloud import ( + "bytes" "encoding/hex" "encoding/json" "fmt" "io" - "math" "net/http" - "sort" "strconv" - "strings" "time" ) -// FetchDailyBill 通过 Cost Explorer 拉取 AWS 单日服务和区域费用。 // Logan.Li +// FetchDailyBill 通过 AWS Cost Explorer 按服务和区域同步指定日的未摊销成本。 // Logan.Li func (p *AWSProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) { - start, err := time.Parse("2006-01-02", billingDate) + day, err := time.Parse("2006-01-02", billingDate) if err != nil { - return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD") + return nil, fmt.Errorf("账单日期格式错误: %w", err) } - type aggregationKey struct { - service string - region string - } - aggregation := map[aggregationKey]float64{} - nextToken := "" - currency := "USD" - for { - payload := map[string]interface{}{ - "TimePeriod": map[string]string{"Start": billingDate, "End": start.AddDate(0, 0, 1).Format("2006-01-02")}, - "Granularity": "DAILY", - "Metrics": []string{"UnblendedCost"}, - "GroupBy": []map[string]string{ - {"Type": "DIMENSION", "Key": "SERVICE"}, - {"Type": "DIMENSION", "Key": "REGION"}, - }, - } - if nextToken != "" { - payload["NextPageToken"] = nextToken - } - body, callErr := p.callCostExplorer(payload) - if callErr != nil { - return nil, callErr - } - var response struct { - NextPageToken string `json:"NextPageToken"` - ResultsByTime []struct { - Groups []struct { - Keys []string `json:"Keys"` - Metrics map[string]struct { - Amount string `json:"Amount"` - Unit string `json:"Unit"` - } `json:"Metrics"` - } `json:"Groups"` - } `json:"ResultsByTime"` - } - if err = json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("解析 AWS Cost Explorer 响应失败: %w", err) - } - for _, period := range response.ResultsByTime { - for _, group := range period.Groups { - metric := group.Metrics["UnblendedCost"] - cost, _ := strconv.ParseFloat(metric.Amount, 64) - if cost == 0 { - continue - } - if metric.Unit != "" { - currency = metric.Unit - } - service, region := "其他", "全局" - if len(group.Keys) > 0 && group.Keys[0] != "" { - service = group.Keys[0] - } - if len(group.Keys) > 1 && group.Keys[1] != "" && group.Keys[1] != "NoRegion" { - region = group.Keys[1] - } - aggregation[aggregationKey{service: service, region: region}] += cost - } - } - nextToken = response.NextPageToken - if nextToken == "" { - break - } - } - keys := make([]aggregationKey, 0, len(aggregation)) - for key := range aggregation { - keys = append(keys, key) - } - sort.Slice(keys, func(i, j int) bool { - if keys[i].service == keys[j].service { - return keys[i].region < keys[j].region - } - return keys[i].service < keys[j].service + payload, err := json.Marshal(map[string]interface{}{ + "TimePeriod": map[string]string{ + "Start": day.Format("2006-01-02"), + "End": day.AddDate(0, 0, 1).Format("2006-01-02"), + }, + "Granularity": "DAILY", + "Metrics": []string{"UnblendedCost"}, + "GroupBy": []map[string]string{ + {"Type": "DIMENSION", "Key": "SERVICE"}, + {"Type": "DIMENSION", "Key": "REGION"}, + }, }) - records := make([]CloudBillingRecord, 0, len(keys)) - for _, key := range keys { - records = append(records, CloudBillingRecord{ - BillingDate: billingDate, Provider: "aws", Cost: math.Round(aggregation[key]*1000000) / 1000000, - Currency: currency, Service: key.service, Region: key.region, - Description: "云 API 自动同步(AWS Cost Explorer)", - }) + if err != nil { + return nil, fmt.Errorf("构造 AWS Cost Explorer 请求失败: %w", err) } - return records, nil + body, err := p.callCostExplorer(payload) + if err != nil { + return nil, err + } + return parseAWSCostExplorerBill(billingDate, body) } -// callCostExplorer 使用 Signature V4 调用 AWS Cost Explorer JSON API。 // Logan.Li -func (p *AWSProvider) callCostExplorer(payload interface{}) ([]byte, error) { - body, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("编码 AWS 账单请求失败: %w", err) - } +// callCostExplorer 使用 SigV4 调用 Cost Explorer;该服务固定使用 us-east-1。 // Logan.Li +func (p *AWSProvider) callCostExplorer(payload []byte) ([]byte, error) { const host = "ce.us-east-1.amazonaws.com" - const target = "AWSInsightsIndexService.GetCostAndUsage" now := time.Now().UTC() - amzDate, date := now.Format("20060102T150405Z"), now.Format("20060102") - canonicalHeaders := "content-type:application/x-amz-json-1.1\nhost:" + host + "\nx-amz-date:" + amzDate + "\nx-amz-target:" + target + "\n" + date := now.Format("20060102") + amzDate := now.Format("20060102T150405Z") + payloadHash := sha256Hex(string(payload)) + canonicalHeaders := "content-type:application/x-amz-json-1.1\n" + + "host:" + host + "\n" + + "x-amz-date:" + amzDate + "\n" + + "x-amz-target:AWSInsightsIndexService.GetCostAndUsage\n" signedHeaders := "content-type;host;x-amz-date;x-amz-target" - canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex(string(body)) + canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payloadHash credentialScope := date + "/us-east-1/ce/aws4_request" stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest) kDate := hmacSHA256([]byte("AWS4"+p.secretKey), date) kRegion := hmacSHA256(kDate, "us-east-1") kService := hmacSHA256(kRegion, "ce") - signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "aws4_request"), stringToSign)) - req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", strings.NewReader(string(body))) + signingKey := hmacSHA256(kService, "aws4_request") + signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign)) + + req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", bytes.NewReader(payload)) if err != nil { - return nil, fmt.Errorf("创建 AWS 账单请求失败: %w", err) + return nil, fmt.Errorf("创建 AWS Cost Explorer 请求失败: %w", err) } req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Date", amzDate) - req.Header.Set("X-Amz-Target", target) + req.Header.Set("X-Amz-Target", "AWSInsightsIndexService.GetCostAndUsage") req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+p.accessKey+"/"+credentialScope+", SignedHeaders="+signedHeaders+", Signature="+signature) resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { return nil, fmt.Errorf("请求 AWS Cost Explorer 失败: %w", err) } defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取 AWS Cost Explorer 响应失败: %w", err) } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("AWS Cost Explorer 返回 HTTP %d: %s", resp.StatusCode, string(data)) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("AWS Cost Explorer 返回 HTTP %d: %s", resp.StatusCode, string(body)) } - return data, nil + return body, nil +} + +func parseAWSCostExplorerBill(billingDate string, body []byte) ([]CloudBillingRecord, error) { + var response struct { + ResultsByTime []struct { + Groups []struct { + Keys []string `json:"Keys"` + Metrics map[string]struct { + Amount string `json:"Amount"` + Unit string `json:"Unit"` + } `json:"Metrics"` + } `json:"Groups"` + } `json:"ResultsByTime"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("解析 AWS Cost Explorer 响应失败: %w", err) + } + records := make([]CloudBillingRecord, 0) + for _, result := range response.ResultsByTime { + for _, group := range result.Groups { + metric, ok := group.Metrics["UnblendedCost"] + if !ok { + continue + } + amount, err := strconv.ParseFloat(metric.Amount, 64) + if err != nil { + return nil, fmt.Errorf("解析 AWS 成本金额失败: %w", err) + } + service, region := "AWS", "global" + if len(group.Keys) > 0 && group.Keys[0] != "" { + service = group.Keys[0] + } + if len(group.Keys) > 1 && group.Keys[1] != "" { + region = group.Keys[1] + } + records = append(records, CloudBillingRecord{BillingDate: billingDate, Provider: "aws", Cost: amount, Currency: metric.Unit, Service: service, Region: region, Description: "AWS Cost Explorer UnblendedCost"}) + } + } + return records, nil } diff --git a/backend/cloud/azure.go b/backend/cloud/azure.go index 8bc0dd4..0177871 100644 --- a/backend/cloud/azure.go +++ b/backend/cloud/azure.go @@ -79,37 +79,6 @@ func (p *AzureProvider) get(path string) ([]byte, error) { return body, nil } -// post 调用 Azure Resource Manager JSON POST API。 // Logan.Li -func (p *AzureProvider) post(path string, payload interface{}) ([]byte, error) { - token, err := p.token() - if err != nil { - return nil, err - } - body, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("编码 Azure 请求失败: %w", err) - } - req, err := http.NewRequest(http.MethodPost, "https://management.azure.com"+path, strings.NewReader(string(body))) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) - if err != nil { - return nil, fmt.Errorf("请求 Azure API 失败: %w", err) - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("Azure API 返回 HTTP %d: %s", resp.StatusCode, string(data)) - } - return data, nil -} - // ListRegions 获取 Azure 订阅可用区域。 // Logan.Li func (p *AzureProvider) ListRegions() ([]CloudRegion, error) { body, err := p.get("/subscriptions/" + p.subscriptionID + "/locations?api-version=2022-12-01") diff --git a/backend/cloud/azure_billing.go b/backend/cloud/azure_billing.go deleted file mode 100644 index 5065610..0000000 --- a/backend/cloud/azure_billing.go +++ /dev/null @@ -1,105 +0,0 @@ -package cloud - -import ( - "encoding/json" - "fmt" - "math" - "sort" - "strconv" - "strings" - "time" -) - -// FetchDailyBill 通过 Azure Cost Management Query 拉取订阅单日成本。 // Logan.Li -func (p *AzureProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) { - start, err := time.Parse("2006-01-02", billingDate) - if err != nil { - return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD") - } - if p.subscriptionID == "" { - return nil, fmt.Errorf("Azure AccessKey 应为 租户ID:订阅ID:客户端ID") - } - payload := map[string]interface{}{ - "type": "ActualCost", - "timeframe": "Custom", - "timePeriod": map[string]string{ - "from": start.Format("2006-01-02T00:00:00Z"), - "to": start.AddDate(0, 0, 1).Format("2006-01-02T00:00:00Z"), - }, - "dataset": map[string]interface{}{ - "granularity": "Daily", - "aggregation": map[string]interface{}{ - "totalCost": map[string]string{"name": "PreTaxCost", "function": "Sum"}, - }, - "grouping": []map[string]string{ - {"type": "Dimension", "name": "ServiceName"}, - {"type": "Dimension", "name": "ResourceLocation"}, - }, - }, - } - path := "/subscriptions/" + p.subscriptionID + "/providers/Microsoft.CostManagement/query?api-version=2023-11-01" - body, err := p.post(path, payload) - if err != nil { - return nil, err - } - var response struct { - Properties struct { - Columns []struct { - Name string `json:"name"` - } `json:"columns"` - Rows [][]interface{} `json:"rows"` - } `json:"properties"` - } - if err = json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("解析 Azure Cost Management 响应失败: %w", err) - } - columnIndex := map[string]int{} - for index, column := range response.Properties.Columns { - columnIndex[strings.ToLower(column.Name)] = index - } - valueAt := func(row []interface{}, names ...string) interface{} { - for _, name := range names { - if index, ok := columnIndex[strings.ToLower(name)]; ok && index < len(row) { - return row[index] - } - } - return nil - } - type aggregationKey struct { - service string - region string - } - aggregation := map[aggregationKey]float64{} - currency := "USD" - for _, row := range response.Properties.Rows { - cost, _ := strconv.ParseFloat(fmt.Sprint(valueAt(row, "PreTaxCost", "Cost", "totalCost")), 64) - if cost == 0 { - continue - } - service := firstNonEmpty(fmt.Sprint(valueAt(row, "ServiceName")), "其他") - region := firstNonEmpty(fmt.Sprint(valueAt(row, "ResourceLocation")), "全局") - if value := fmt.Sprint(valueAt(row, "Currency")); value != "" && value != "" { - currency = value - } - aggregation[aggregationKey{service: service, region: region}] += cost - } - keys := make([]aggregationKey, 0, len(aggregation)) - for key := range aggregation { - keys = append(keys, key) - } - sort.Slice(keys, func(i, j int) bool { - if keys[i].service == keys[j].service { - return keys[i].region < keys[j].region - } - return keys[i].service < keys[j].service - }) - records := make([]CloudBillingRecord, 0, len(keys)) - for _, key := range keys { - records = append(records, CloudBillingRecord{ - BillingDate: billingDate, Provider: "azure", Cost: math.Round(aggregation[key]*1000000) / 1000000, - Currency: currency, Service: key.service, Region: key.region, - Description: "云 API 自动同步(Azure Cost Management)", - }) - } - return records, nil -} diff --git a/backend/cloud/billing.go b/backend/cloud/billing.go index 7f70da4..92c18e3 100644 --- a/backend/cloud/billing.go +++ b/backend/cloud/billing.go @@ -17,7 +17,7 @@ type BillingFetcher interface { } // SupportedBillingProviders 已接入账单 API 的云厂商列表。 // Logan.Li -var SupportedBillingProviders = []string{"aliyun", "tencent", "aws", "huawei", "azure"} +var SupportedBillingProviders = []string{"aliyun", "tencent", "aws"} // NewBillingFetcher 根据云厂商创建账单拉取器 func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher { @@ -28,10 +28,6 @@ func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher { return NewTencentProvider(accessKey, secretKey) case "aws": return NewAWSProvider(accessKey, secretKey) - case "huawei": - return NewHuaweiProvider(accessKey, secretKey) - case "azure": - return NewAzureProvider(accessKey, secretKey) } return nil } diff --git a/backend/cloud/huawei.go b/backend/cloud/huawei.go index baae560..15b5315 100644 --- a/backend/cloud/huawei.go +++ b/backend/cloud/huawei.go @@ -8,42 +8,37 @@ import ( "net/http" "sort" "strings" - "sync" "time" ) // HuaweiProvider 通过华为云 AK/SK 签名 API 同步项目区域与 ECS 实例。 // Logan.Li -type HuaweiProvider struct { - accessKey, secretKey string - regionMu sync.RWMutex - regionByProject map[string]string -} +type HuaweiProvider struct{ accessKey, secretKey string } // NewHuaweiProvider 创建华为云 Provider。 // Logan.Li func NewHuaweiProvider(accessKey, secretKey string) *HuaweiProvider { - return &HuaweiProvider{accessKey: accessKey, secretKey: secretKey, regionByProject: make(map[string]string)} + return &HuaweiProvider{accessKey, secretKey} } // Name 返回华为云厂商标识。 // Logan.Li func (p *HuaweiProvider) Name() string { return "huawei" } // signedGet 使用 SDK-HMAC-SHA256 调用华为云 GET API。 // Logan.Li -func (p *HuaweiProvider) signedGet(rawURL, _ string) ([]byte, error) { +func (p *HuaweiProvider) signedGet(rawURL, service string) ([]byte, error) { u, err := http.NewRequest(http.MethodGet, rawURL, nil) if err != nil { return nil, fmt.Errorf("创建华为云请求失败: %w", err) } now := time.Now().UTC().Format("20060102T150405Z") + date := now[:8] host := u.URL.Host canonicalHeaders := "host:" + host + "\n" + "x-sdk-date:" + now + "\n" signedHeaders := "host;x-sdk-date" - canonicalURI := u.URL.EscapedPath() - if !strings.HasSuffix(canonicalURI, "/") { - canonicalURI += "/" - } - canonicalRequest := "GET\n" + canonicalURI + "\n" + u.URL.Query().Encode() + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex("") - stringToSign := "SDK-HMAC-SHA256\n" + now + "\n" + sha256Hex(canonicalRequest) - signature := hex.EncodeToString(hmacSHA256([]byte(p.secretKey), stringToSign)) + canonicalRequest := "GET\n" + u.URL.EscapedPath() + "\n" + u.URL.Query().Encode() + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex("") + credentialScope := date + "/" + service + "/sdk_request" + stringToSign := "SDK-HMAC-SHA256\n" + now + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest) + kDate := hmacSHA256([]byte(p.secretKey), date) + kService := hmacSHA256(kDate, service) + signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "sdk_request"), stringToSign)) u.Header.Set("X-Sdk-Date", now) u.Header.Set("Authorization", "SDK-HMAC-SHA256 Access="+p.accessKey+", SignedHeaders="+signedHeaders+", Signature="+signature) resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(u) @@ -77,33 +72,28 @@ func (p *HuaweiProvider) ListRegions() ([]CloudRegion, error) { return nil, fmt.Errorf("解析华为云区域响应失败: %w", err) } regions := []CloudRegion{} - regionByProject := make(map[string]string, len(result.Projects)) for _, project := range result.Projects { if project.ID == "" || project.Name == "" { continue } regions = append(regions, CloudRegion{RegionID: project.ID, LocalName: project.Name, Endpoint: "https://ecs." + project.Name + ".myhuaweicloud.com", Status: "active"}) - regionByProject[project.ID] = project.Name } - p.regionMu.Lock() - p.regionByProject = regionByProject - p.regionMu.Unlock() sort.Slice(regions, func(i, j int) bool { return regions[i].LocalName < regions[j].LocalName }) return regions, nil } // ListInstances 获取指定华为云项目中的 ECS 实例。 // Logan.Li func (p *HuaweiProvider) ListInstances(projectID string) ([]CloudInstance, error) { - p.regionMu.RLock() - regionName := p.regionByProject[projectID] - p.regionMu.RUnlock() - if regionName == "" { - if _, err := p.ListRegions(); err != nil { - return nil, err + regions, err := p.ListRegions() + if err != nil { + return nil, err + } + regionName := "" + for _, region := range regions { + if region.RegionID == projectID { + regionName = region.LocalName + break } - p.regionMu.RLock() - regionName = p.regionByProject[projectID] - p.regionMu.RUnlock() } if regionName == "" { return nil, fmt.Errorf("未找到华为云项目 %s", projectID) diff --git a/backend/cloud/huawei_billing.go b/backend/cloud/huawei_billing.go deleted file mode 100644 index d86302f..0000000 --- a/backend/cloud/huawei_billing.go +++ /dev/null @@ -1,133 +0,0 @@ -package cloud - -import ( - "encoding/json" - "fmt" - "math" - "net/url" - "sort" - "strconv" - "strings" - "time" -) - -type huaweiBillRecord struct { - ServiceTypeCode string `json:"service_type_code"` - ServiceTypeName string `json:"service_type_name"` - CloudServiceType string `json:"cloud_service_type"` - CloudServiceTypeName string `json:"cloud_service_type_name"` - ResourceTypeName string `json:"resource_type_name"` - RegionCode string `json:"region_code"` - Region string `json:"region"` - RegionName string `json:"region_name"` - Amount json.RawMessage `json:"amount"` - ConsumeAmount json.RawMessage `json:"consume_amount"` - OfficialAmount json.RawMessage `json:"official_amount"` - DiscountAmount json.RawMessage `json:"discount_amount"` -} - -type huaweiBillPage struct { - TotalCount int `json:"total_count"` - Currency string `json:"currency"` - Records []huaweiBillRecord `json:"records"` - FeeRecords []huaweiBillRecord `json:"fee_records"` -} - -// FetchDailyBill 通过华为云 BSS 费用流水接口拉取单日账单。 // Logan.Li -func (p *HuaweiProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) { - if _, err := time.Parse("2006-01-02", billingDate); err != nil { - return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD") - } - type aggregationKey struct { - service string - region string - } - aggregation := map[aggregationKey]float64{} - currency := "CNY" - const pageSize = 1000 - for offset := 0; ; offset += pageSize { - query := url.Values{ - "bill_cycle": {billingDate[:7]}, - "bill_date_begin": {billingDate}, - "bill_date_end": {billingDate}, - "offset": {strconv.Itoa(offset)}, - "limit": {strconv.Itoa(pageSize)}, - } - body, err := p.signedGet("https://bss.myhuaweicloud.com/v2/bills/customer-bills/fee-records?"+query.Encode(), "bss") - if err != nil { - return nil, err - } - page, err := decodeHuaweiBillPage(body) - if err != nil { - return nil, err - } - if page.Currency != "" { - currency = page.Currency - } - records := page.Records - if len(records) == 0 { - records = page.FeeRecords - } - for _, item := range records { - cost := huaweiBillRecordCost(item) - if cost == 0 { - continue - } - service := firstNonEmpty(item.ServiceTypeName, item.CloudServiceTypeName, item.ResourceTypeName, item.ServiceTypeCode, item.CloudServiceType, "其他") - region := firstNonEmpty(item.RegionName, item.RegionCode, item.Region, "全局") - aggregation[aggregationKey{service: service, region: region}] += cost - } - if len(records) < pageSize || (page.TotalCount > 0 && offset+pageSize >= page.TotalCount) { - break - } - } - keys := make([]aggregationKey, 0, len(aggregation)) - for key := range aggregation { - keys = append(keys, key) - } - sort.Slice(keys, func(i, j int) bool { - if keys[i].service == keys[j].service { - return keys[i].region < keys[j].region - } - return keys[i].service < keys[j].service - }) - records := make([]CloudBillingRecord, 0, len(keys)) - for _, key := range keys { - records = append(records, CloudBillingRecord{ - BillingDate: billingDate, Provider: "huawei", Cost: math.Round(aggregation[key]*100) / 100, - Currency: currency, Service: key.service, Region: key.region, - Description: "云 API 自动同步(华为云 BSS)", - }) - } - return records, nil -} - -// decodeHuaweiBillPage 兼容华为云 records 与旧版 fee_records 响应。 // Logan.Li -func decodeHuaweiBillPage(body []byte) (huaweiBillPage, error) { - var page huaweiBillPage - if err := json.Unmarshal(body, &page); err != nil { - return page, fmt.Errorf("解析华为云 BSS 响应失败: %w", err) - } - return page, nil -} - -// huaweiBillRecordCost 依次读取实际消费、流水金额和官网价减优惠。 // Logan.Li -func huaweiBillRecordCost(item huaweiBillRecord) float64 { - if cost := huaweiBillAmount(item.ConsumeAmount); cost != 0 { - return cost - } - if cost := huaweiBillAmount(item.Amount); cost != 0 { - return cost - } - return huaweiBillAmount(item.OfficialAmount) - huaweiBillAmount(item.DiscountAmount) -} - -// huaweiBillAmount 兼容华为云金额字段的字符串和数字格式。 // Logan.Li -func huaweiBillAmount(raw json.RawMessage) float64 { - value := strings.Trim(strings.TrimSpace(string(raw)), "\"") - amount, err := strconv.ParseFloat(value, 64) - if err != nil { - return 0 - } - return amount -} diff --git a/backend/cloud/huawei_billing_test.go b/backend/cloud/huawei_billing_test.go deleted file mode 100644 index 01ba5ce..0000000 --- a/backend/cloud/huawei_billing_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package cloud - -import ( - "testing" -) - -func TestDecodeHuaweiBillPageRecords(t *testing.T) { - body := []byte(`{ - "currency":"CNY", - "total_count":1, - "records":[{ - "service_type_name":"弹性云服务器", - "region_name":"华南-广州", - "consume_amount":"12.34" - }] - }`) - page, err := decodeHuaweiBillPage(body) - if err != nil { - t.Fatalf("解析华为云账单失败: %v", err) - } - if page.TotalCount != 1 || page.Currency != "CNY" || len(page.Records) != 1 { - t.Fatalf("账单分页字段错误: %+v", page) - } - record := page.Records[0] - if record.ServiceTypeName != "弹性云服务器" || record.RegionName != "华南-广州" { - t.Fatalf("账单维度字段错误: %+v", record) - } - if cost := huaweiBillRecordCost(record); cost != 12.34 { - t.Fatalf("实际消费金额错误: %v", cost) - } -} - -func TestDecodeHuaweiBillPageFeeRecordsCompatibility(t *testing.T) { - body := []byte(`{"total_count":1,"fee_records":[{"official_amount":20,"discount_amount":3}]}`) - page, err := decodeHuaweiBillPage(body) - if err != nil { - t.Fatalf("解析旧版华为云账单失败: %v", err) - } - if len(page.FeeRecords) != 1 { - t.Fatalf("旧版 fee_records 未解析: %+v", page) - } - if cost := huaweiBillRecordCost(page.FeeRecords[0]); cost != 17 { - t.Fatalf("官网价减优惠金额错误: %v", cost) - } -} diff --git a/backend/cloud/provider.go b/backend/cloud/provider.go index 3b15d05..7edb8de 100644 --- a/backend/cloud/provider.go +++ b/backend/cloud/provider.go @@ -43,13 +43,13 @@ func NewProvider(provider, accessKey, secretKey string) Provider { case "aliyun": return NewAliyunProvider(accessKey, secretKey) case "aws": - return NewAWSProvider(accessKey, secretKey) + return NewAWSProvider(accessKey, secretKey) // 启用 AWS EC2 区域与实例同步。 // Logan.Li case "tencent": return NewTencentProvider(accessKey, secretKey) // 启用腾讯云 CVM 区域与实例同步。 // Logan.Li case "huawei": - return NewHuaweiProvider(accessKey, secretKey) + return NewHuaweiProvider(accessKey, secretKey) // 启用华为云 ECS 区域与实例同步。 // Logan.Li case "azure": - return NewAzureProvider(accessKey, secretKey) + return NewAzureProvider(accessKey, secretKey) // 启用 Azure VM 区域与实例同步。 // Logan.Li default: return nil } diff --git a/backend/cloud/tencent.go b/backend/cloud/tencent.go index 8c5584e..fb23c48 100644 --- a/backend/cloud/tencent.go +++ b/backend/cloud/tencent.go @@ -1,8 +1,6 @@ package cloud import ( - "crypto/hmac" - "crypto/sha256" "encoding/hex" "encoding/json" "errors" @@ -126,12 +124,12 @@ func (p *TencentProvider) callServiceOnce(service, host, version, action, region timestamp, date := now.Unix(), now.UTC().Format("2006-01-02") canonicalHeaders := "content-type:application/json; charset=utf-8\nhost:" + host + "\n" signedHeaders := "content-type;host" - canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + tencentSHA256Hex(string(body)) + canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex(string(body)) credentialScope := date + "/" + service + "/tc3_request" - stringToSign := "TC3-HMAC-SHA256\n" + fmt.Sprint(timestamp) + "\n" + credentialScope + "\n" + tencentSHA256Hex(canonicalRequest) - secretDate := tencentHMACSHA256([]byte("TC3"+p.secretKey), date) - secretService := tencentHMACSHA256(secretDate, service) - signature := hex.EncodeToString(tencentHMACSHA256(tencentHMACSHA256(secretService, "tc3_request"), stringToSign)) + stringToSign := "TC3-HMAC-SHA256\n" + fmt.Sprint(timestamp) + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest) + secretDate := hmacSHA256([]byte("TC3"+p.secretKey), date) + secretService := hmacSHA256(secretDate, service) + signature := hex.EncodeToString(hmacSHA256(hmacSHA256(secretService, "tc3_request"), stringToSign)) req, err := http.NewRequest(http.MethodPost, "https://"+host, strings.NewReader(string(body))) if err != nil { return nil, fmt.Errorf("创建腾讯云请求失败: %w", err) @@ -217,7 +215,8 @@ func (p *TencentProvider) ListInstances(regionID string) ([]CloudInstance, error return nil, fmt.Errorf("解析腾讯云实例响应失败: %w", err) } for _, item := range result.Response.InstanceSet { - instances = append(instances, CloudInstance{InstanceID: item.ID, InstanceName: item.Name, Hostname: item.Name, OSType: item.OS, Status: tencentState(item.State), RegionID: regionID, ZoneID: item.Placement.Zone, PrivateIP: tencentFirstString(item.Private), PublicIP: tencentFirstString(item.Public), InstanceType: item.Type, CPU: item.CPU, Memory: item.Memory * 1024, CreationTime: item.Created}) + privateIP, publicIP := firstString(item.Private), firstString(item.Public) + instances = append(instances, CloudInstance{InstanceID: item.ID, InstanceName: item.Name, Hostname: item.Name, OSType: item.OS, Status: tencentState(item.State), RegionID: regionID, ZoneID: item.Placement.Zone, PrivateIP: privateIP, PublicIP: publicIP, InstanceType: item.Type, CPU: item.CPU, Memory: item.Memory * 1024, CreationTime: item.Created}) } if len(result.Response.InstanceSet) == 0 || len(instances) >= result.Response.TotalCount { break @@ -226,8 +225,8 @@ func (p *TencentProvider) ListInstances(regionID string) ([]CloudInstance, error return instances, nil } -// tencentFirstString 获取 API 列表中的首个 IP。 // Logan.Li -func tencentFirstString(values []string) string { +// firstString 获取 API 列表中的首个 IP。 // Logan.Li +func firstString(values []string) string { if len(values) == 0 { return "" } @@ -241,16 +240,3 @@ func tencentState(state string) string { } return state } - -// tencentSHA256Hex 计算腾讯云 TC3 签名的 SHA-256 摘要。 // Logan.Li -func tencentSHA256Hex(value string) string { - sum := sha256.Sum256([]byte(value)) - return hex.EncodeToString(sum[:]) -} - -// tencentHMACSHA256 计算腾讯云 TC3 签名的 HMAC-SHA256。 // Logan.Li -func tencentHMACSHA256(key []byte, value string) []byte { - mac := hmac.New(sha256.New, key) - _, _ = mac.Write([]byte(value)) - return mac.Sum(nil) -} diff --git a/backend/cloud/tencent_billing.go b/backend/cloud/tencent_billing.go index 32ba970..c0293c9 100644 --- a/backend/cloud/tencent_billing.go +++ b/backend/cloud/tencent_billing.go @@ -23,11 +23,9 @@ func (p *TencentProvider) FetchDailyBill(billingDate string) ([]CloudBillingReco const pageSize = 100 for offset := 0; ; offset += pageSize { - // DescribeBillDetail 不支持 Day 参数,使用账期和起止时间限定单日范围。 // Logan.Li + // 腾讯云账单明细仅按账期查询,不支持按日参数。 // Logan.Li body, err := p.callService("billing", "billing.tencentcloudapi.com", "2018-07-09", "DescribeBillDetail", "", map[string]interface{}{ "Month": billingDate[:7], - "BeginTime": billingDate + " 00:00:00", - "EndTime": billingDate + " 23:59:59", "Offset": offset, "Limit": pageSize, "NeedRecordNum": 1, diff --git a/backend/cloud/tencent_test.go b/backend/cloud/tencent_test.go index 49d9dc5..dea1f25 100644 --- a/backend/cloud/tencent_test.go +++ b/backend/cloud/tencent_test.go @@ -26,12 +26,3 @@ func TestTencentRetryDelay(t *testing.T) { t.Fatal("非限频错误不应重试") } } - -// TestBillingFetcherProviders 验证五家云厂商均已注册账单拉取器。 // Logan.Li -func TestBillingFetcherProviders(t *testing.T) { - for _, provider := range SupportedBillingProviders { - if fetcher := NewBillingFetcher(provider, "ak", "sk"); fetcher == nil { - t.Fatalf("云厂商 %s 未注册账单拉取器", provider) - } - } -} diff --git a/backend/config/config.go b/backend/config/config.go index 71305d4..8882606 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -9,15 +9,24 @@ import ( // Config 应用配置 type Config struct { - Mode string `mapstructure:"mode"` - Port string `mapstructure:"port"` - LogLevel string `mapstructure:"log_level"` - Database DatabaseConfig `mapstructure:"database"` - JWT JWTConfig `mapstructure:"jwt"` - Redis RedisConfig `mapstructure:"redis"` + Mode string `mapstructure:"mode"` + Port string `mapstructure:"port"` + LogLevel string `mapstructure:"log_level"` + Database DatabaseConfig `mapstructure:"database"` + JWT JWTConfig `mapstructure:"jwt"` + Agent AgentConfig `mapstructure:"agent"` + Retention RetentionConfig `mapstructure:"retention"` + Billing BillingConfig `mapstructure:"billing"` + Redis RedisConfig `mapstructure:"redis"` Websocket WebsocketConfig `mapstructure:"websocket"` } +// BillingConfig 定义云账单报告本位币及各原币种折算到 CNY 的汇率。 // Logan.Li +type BillingConfig struct { + BaseCurrency string `mapstructure:"base_currency"` + ExchangeRates map[string]float64 `mapstructure:"exchange_rates"` +} + // DatabaseConfig 数据库配置 type DatabaseConfig struct { Driver string `mapstructure:"driver"` @@ -37,9 +46,25 @@ type JWTConfig struct { ExpireHour int `mapstructure:"expire_hour"` } +// AgentConfig Agent 接入安全配置。 // Logan.Li +type AgentConfig struct { + EnforceAuth bool `mapstructure:"enforce_auth"` +} + +// RetentionConfig 高频数据保留期配置,单位为天。 // Logan.Li +type RetentionConfig struct { + AgentReportsDays int `mapstructure:"agent_reports_days"` + MetricHistoryDays int `mapstructure:"metric_history_days"` + MetricAggregateDays int `mapstructure:"metric_aggregate_days"` + SNMPSamplesDays int `mapstructure:"snmp_samples_days"` + SiteResultsDays int `mapstructure:"site_results_days"` + LogEntriesDays int `mapstructure:"log_entries_days"` + CleanupBatchSize int `mapstructure:"cleanup_batch_size"` +} + // RedisConfig Redis配置 type RedisConfig struct { - Driver string `mapstructure:"driver"` // redka (内嵌) / redis (外部) + Driver string `mapstructure:"driver"` // redka (内嵌) / redis (外部) Host string `mapstructure:"host"` Port int `mapstructure:"port"` Password string `mapstructure:"password"` @@ -71,6 +96,17 @@ func Load() *Config { viper.SetDefault("database.max_conn", 100) viper.SetDefault("database.max_idle", 10) viper.SetDefault("jwt.expire_hour", 24) + viper.SetDefault("agent.enforce_auth", false) + viper.SetDefault("retention.agent_reports_days", 7) + viper.SetDefault("retention.metric_history_days", 30) + viper.SetDefault("retention.metric_aggregate_days", 365) + viper.SetDefault("retention.snmp_samples_days", 30) + viper.SetDefault("retention.site_results_days", 30) + viper.SetDefault("retention.log_entries_days", 30) + viper.SetDefault("retention.cleanup_batch_size", 10000) + viper.SetDefault("billing.base_currency", "CNY") + viper.SetDefault("billing.exchange_rates.CNY", 1.0) + viper.SetDefault("billing.exchange_rates.USD", 7.2) viper.SetDefault("redis.host", "localhost") viper.SetDefault("redis.port", 6379) viper.SetDefault("redis.driver", "redka") diff --git a/backend/controller/agent.go b/backend/controller/agent.go index 4cfed01..615e307 100644 --- a/backend/controller/agent.go +++ b/backend/controller/agent.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "ezops/middleware" "ezops/model" ezredis "ezops/pkg/redis" @@ -30,6 +31,24 @@ func NewAgentHandler(db *gorm.DB, cache ezredis.Cache, hub *MetricsHub) *AgentHa return &AgentHandler{db: db, cache: cache, hubMetrics: hub} } +// resolveAgentAsset 优先按上报主机名或内网 IP 关联现有资产,避免云实例名与系统 hostname 不一致。 // Logan.Li +func (h *AgentHandler) resolveAgentAsset(hostname, reportIP, clientIP string) (model.Server, string, bool) { + matchIP := strings.TrimSpace(reportIP) + if matchIP == "" || matchIP == "unknown" { + matchIP = strings.TrimSpace(clientIP) + } + + var server model.Server + query := h.db.Where("hostname = ?", hostname) + if matchIP != "" { + query = h.db.Where("hostname = ? OR ip = ?", hostname, matchIP) + } + if err := query.Take(&server).Error; err == nil { + return server, matchIP, true + } + return server, matchIP, false +} + // ReceiveReport 接收 Agent 上报的指标数据 // POST /api/v1/agent/report func (h *AgentHandler) ReceiveReport(c *gin.Context) { @@ -38,6 +57,9 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求格式错误: " + err.Error()}) return } + if !middleware.RequireAgentHostname(c, req.Hostname) { + return + } // 解析上报时间 reportTime := time.Now() @@ -46,6 +68,14 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { reportTime = t } } + // Agent 上报时间统一存储为 UTC,避免 SQLite 按带时区文本比较时误判。 // Logan.Li + reportTime = reportTime.UTC() + + matchedServer, matchIP, assetMatched := h.resolveAgentAsset(req.Hostname, req.IP, c.ClientIP()) + canonicalHostname := req.Hostname + if assetMatched { + canonicalHostname = matchedServer.Hostname + } // 序列化磁盘和端口信息 diskJSON, _ := json.Marshal(req.Disks) @@ -61,8 +91,7 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { // 存储到数据库 report := model.AgentReport{ - Hostname: req.Hostname, - IP: strings.TrimSpace(req.IP), // 使用 Agent 自报 IP,避免反向代理地址误关联。 // Logan.Li + Hostname: canonicalHostname, CPUUsage: req.CPU.UsagePercent, CPUIOwait: req.CPU.IOWait, CPUCores: req.CPU.CoreCount, @@ -104,86 +133,71 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { memMB := int64(memGB * 1024) // GB → MB diskGB := int64(totalDiskGB + 0.5) // 39.99 → 40 - clientIP := c.ClientIP() - - // 三级匹配:优先用 Agent 上报的 IP,其次用请求源 IP,最后用报文的 hostname - matchIP := req.IP - if matchIP == "" { - matchIP = clientIP - } - - // 直接 SQL 更新 asset_servers 监控字段 - // 若 AgentVersion 非空则同步写入,否则保留数据库中已有版本号(防止旧版Agent空字段覆盖已设置的版本) - updateSQL := `UPDATE asset_servers SET - cpu_usage = ?, cpu_iowait = ?, memory_usage = ?, disk_usage = ?, - cpu = ?, memory = ?, disk = ?, - status = ?, agent_status = ?` + func() string { - if req.AgentVersion != "" { - return `, agent_version = ?` - } - return `` - }() + `, - hostname = CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END, - updated_at = ?` - args := []interface{}{ - req.CPU.UsagePercent, req.CPU.IOWait, req.Memory.UsagePercent, diskAvg, - req.CPU.CoreCount, memMB, diskGB, - "running", "online", + // 使用 map 按主键更新,避免可选系统字段导致原始 SQL 占位参数错位。 // Logan.Li + updates := map[string]interface{}{ + "cpu_usage": req.CPU.UsagePercent, "memory_usage": req.Memory.UsagePercent, + "disk_usage": diskAvg, + "cpu": req.CPU.CoreCount, "memory": memMB, "disk": diskGB, + "status": "running", "agent_status": "online", "updated_at": time.Now(), } if req.AgentVersion != "" { - args = append(args, req.AgentVersion) + updates["agent_version"] = req.AgentVersion } - args = append(args, - // CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END - req.Hostname, matchIP, req.Hostname, - ) if req.System != nil { if req.System.OSVersion != "" { - updateSQL += ", os = ?" - args = append(args, req.System.OSVersion) + updates["os"] = req.System.OSVersion } if req.System.Kernel != "" { - updateSQL += ", kernel = ?" - args = append(args, req.System.Kernel) + updates["kernel"] = req.System.Kernel } if req.System.Uptime > 0 { - updateSQL += ", uptime = ?" - args = append(args, req.System.Uptime) + updates["uptime"] = req.System.Uptime } } - args = append(args, time.Now()) - updateSQL += " WHERE hostname = ? OR ip = ?" - args = append(args, req.Hostname, matchIP) - - result := h.db.Exec(updateSQL, args...) - - // 如果 hostname 和 IP 都没匹配到,尝试用 hostname 反向匹配 IP 字段 - if result.RowsAffected == 0 && req.Hostname != matchIP { - updateSQL2 := updateSQL + " OR ip = ?" - args2 := append(args, req.Hostname) - result = h.db.Exec(updateSQL2, args2...) + // 兼容旧版 Agent:系统信息缺席时使用心跳中的运行时长补齐资产元数据。 // Logan.Li + if _, ok := updates["uptime"]; !ok && req.Heartbeat.UptimeSec > 0 { + updates["uptime"] = req.Heartbeat.UptimeSec + } + var result *gorm.DB + if assetMatched { + result = h.db.Model(&model.Server{}).Where("id = ?", matchedServer.ID).Updates(updates) + } else { + result = h.db.Model(&model.Server{}).Where("hostname = ? OR ip = ?", req.Hostname, matchIP).Updates(updates) } // 如果还是没匹配到 —— 按 hostname 显式查重后再注册 // Logan.Li // asset_servers.hostname 无 UNIQUE 索引,OnConflict 不可靠,故先 First 查重, // 避免 Agent 每次上报(10s)都新建记录导致资产越来越多。 - if result.RowsAffected == 0 { + if result.Error == nil && result.RowsAffected == 0 { osName := "" + kernel := "" + uptime := req.Heartbeat.UptimeSec if req.System != nil { osName = req.System.OSVersion + kernel = req.System.Kernel + if req.System.Uptime > 0 { + uptime = req.System.Uptime + } } var existing model.Server - if h.db.Where("hostname = ?", req.Hostname).First(&existing).Error == nil { + if h.db.Where("hostname = ?", req.Hostname).Take(&existing).Error == nil { // 已自动注册过 → 仅更新实时指标,不重复创建 - h.db.Model(&existing).Updates(map[string]interface{}{ + existingUpdates := map[string]interface{}{ "ip": matchIP, "os": osName, "cpu": req.CPU.CoreCount, "memory": memMB, "disk": diskGB, "status": "running", "agent_status": "online", "agent_version": req.AgentVersion, "cpu_usage": req.CPU.UsagePercent, "memory_usage": req.Memory.UsagePercent, "disk_usage": diskAvg, - }) + } + if kernel != "" { + existingUpdates["kernel"] = kernel + } + if uptime > 0 { + existingUpdates["uptime"] = uptime + } + h.db.Model(&existing).Updates(existingUpdates) } else { - // 真正的新主机 → 注册(OnConflict 作为并发双保险) + // 真正的新主机 → 注册,hostname 无唯一索引时不使用 OnConflict。 // Logan.Li newServer := model.Server{ Hostname: req.Hostname, IP: matchIP, @@ -197,19 +211,13 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { Status: "running", AgentStatus: "online", AgentVersion: req.AgentVersion, + Kernel: kernel, + Uptime: uptime, CPUUsage: req.CPU.UsagePercent, MemoryUsage: req.Memory.UsagePercent, DiskUsage: diskAvg, } - if err := h.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "hostname"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "ip", "os", "cpu", "memory", "disk", - "status", "agent_status", - "cpu_usage", "memory_usage", "disk_usage", - "updated_at", - }), - }).Create(&newServer).Error; err != nil { + if err := h.db.Create(&newServer).Error; err != nil { fmt.Printf("[agent] 自动注册主机 %q (IP: %s) 失败: %v\n", req.Hostname, matchIP, err) } else { fmt.Printf("[agent] 自动注册新主机: %q (IP: %s, CPU=%d核, 内存=%dMB, 磁盘=%dGB, OS=%q)\n", @@ -222,7 +230,7 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { if h.hubMetrics != nil && result.RowsAffected > 0 { h.hubMetrics.Broadcast(MetricsUpdate{ Type: "metrics_update", - Hostname: req.Hostname, + Hostname: canonicalHostname, CPU: req.CPU.UsagePercent, Memory: req.Memory.UsagePercent, Disk: diskAvg, @@ -240,13 +248,13 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { if h.cache != nil && h.cache.Capabilities().SortedSet { timestamp := float64(reportTime.Unix()) ttl := 7 * 24 * time.Hour - h.writeHotMetric(c, "metrics:cpu:"+req.Hostname, req.CPU.UsagePercent, timestamp, ttl, reportTime) - h.writeHotMetric(c, "metrics:mem:"+req.Hostname, req.Memory.UsagePercent, timestamp, ttl, reportTime) - h.writeHotMetric(c, "metrics:load1:"+req.Hostname, req.CPU.Load1, timestamp, ttl, reportTime) + h.writeHotMetric(c, "metrics:cpu:"+canonicalHostname, req.CPU.UsagePercent, timestamp, ttl, reportTime) + h.writeHotMetric(c, "metrics:mem:"+canonicalHostname, req.Memory.UsagePercent, timestamp, ttl, reportTime) + h.writeHotMetric(c, "metrics:load1:"+canonicalHostname, req.CPU.Load1, timestamp, ttl, reportTime) } // 保存成功 — 响应中携带探针配置给 Agent - resp := h.makeProbeConfigResponse(req.Hostname, 200, "ok", req.ProbeResults) + resp := h.makeProbeConfigResponse(canonicalHostname, 200, "ok", req.ProbeResults) c.JSON(http.StatusOK, resp) } @@ -277,8 +285,9 @@ func (h *AgentHandler) writeHotMetric(c *gin.Context, key string, value float64, // GET /api/v1/agent/latest func (h *AgentHandler) GetLatestReports(c *gin.Context) { var results []model.AgentLatestResponse - threeMinAgo := time.Now().UTC().Add(-3 * time.Minute) // SQLite 上报时间统一以 UTC 持久化。 // Logan.Li - // 以 servers 表为主,按资产名或 Agent 自报 IP 关联最近 3 分钟内的上报。 // Logan.Li + // 查询边界与 Agent 上报的 UTC 时间保持一致。 // Logan.Li + threeMinAgo := time.Now().UTC().Add(-3 * time.Minute) + // 以 servers 表为主,LEFT JOIN 最近 3 分钟内的 Agent 上报 // 未装 Agent 的主机也会显示(agent_status='not_installed'),打破"不显示→无法安装"的循环 h.db.Raw(` SELECT @@ -303,8 +312,7 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) { a.report_time, COALESCE(s.agent_version, '') as agent_version, CASE - WHEN a.id IS NOT NULL THEN 'online' - WHEN COALESCE(s.agent_version, '') <> '' OR COALESCE(s.agent_status, '') = 'online' THEN 'offline' + WHEN a.hostname IS NOT NULL THEN COALESCE(s.agent_status, 'online') ELSE 'not_installed' END as agent_status, COALESCE(s.os, '') as os, @@ -314,13 +322,14 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) { COALESCE(r.name, '') as region, CASE WHEN s.password != '' OR s.ssh_key != '' THEN true ELSE false END as has_auth FROM asset_servers s - LEFT JOIN monitor_agent_reports a ON a.id = ( - SELECT ar.id FROM monitor_agent_reports ar - WHERE ar.report_time >= ? - AND (ar.hostname = s.hostname OR (ar.ip <> '' AND ar.ip = s.ip)) - ORDER BY ar.report_time DESC, ar.id DESC - LIMIT 1 - ) + LEFT JOIN ( + SELECT * FROM monitor_agent_reports + WHERE id IN ( + SELECT MAX(id) FROM monitor_agent_reports + WHERE report_time >= ? + GROUP BY hostname + ) + ) a ON a.hostname = s.hostname LEFT JOIN asset_regions r ON r.id = s.region_id ORDER BY a.report_time DESC, s.id ASC `, threeMinAgo).Scan(&results) @@ -348,7 +357,7 @@ func parseFlexibleTime(s string) (time.Time, error) { } for _, layout := range layouts { if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { - return t.UTC(), nil // 历史报告使用 UTC 存储,避免自定义时间范围跨时区漏数。 // Logan.Li + return t, nil } } return time.Time{}, fmt.Errorf("无法解析时间: %s", s) @@ -356,7 +365,7 @@ func parseFlexibleTime(s string) (time.Time, error) { // resolveHostHistoryRange 解析历史查询时间范围(minutes / hours / start+end) // Logan.Li func resolveHostHistoryRange(c *gin.Context) (since, until time.Time, err error) { - until = time.Now().UTC() // 历史报告使用 UTC 存储,避免本地时区造成范围比较失效。 // Logan.Li + until = time.Now() if startStr := strings.TrimSpace(c.Query("start")); startStr != "" { endStr := strings.TrimSpace(c.Query("end")) if endStr == "" { @@ -410,16 +419,9 @@ func (h *AgentHandler) GetHostHistory(c *gin.Context) { return } - var server model.Server - serverIP := "" - if h.db.Where("hostname = ?", hostname).Take(&server).Error == nil { - serverIP = strings.TrimSpace(server.IP) - } - var reports []model.AgentReport - // 资产名可能与操作系统主机名不同,优先兼容 Agent 自报 IP。 // Logan.Li - h.db.Where("hostname = ? OR (ip <> '' AND ip = ?)", hostname, serverIP). - Where("report_time >= ? AND report_time <= ?", since, until). + // SQLite 会按时间文本比较,查询边界需统一转换为 UTC。 // Logan.Li + h.db.Where("hostname = ? AND report_time >= ? AND report_time <= ?", hostname, since.UTC(), until.UTC()). Order("report_time ASC"). Find(&reports) @@ -431,7 +433,7 @@ func (h *AgentHandler) GetHostHistory(c *gin.Context) { } // AggregateMetrics 聚合指标到 monitor_metrics_aggregate(Redis ZSet 或 DB 直读)// Logan.Li -// POST /api/v1/agent/aggregate +// POST /api/v1/agent/aggregate(需要控制台 JWT) // Logan.Li func (h *AgentHandler) AggregateMetrics(c *gin.Context) { var req model.AggregateReq if err := c.ShouldBindJSON(&req); err != nil { @@ -504,7 +506,8 @@ func (h *AgentHandler) aggregateFromReports(req model.AggregateReq) (int64, erro for _, iv := range intervals { lookback := iv.duration * 200 - since := time.Now().Add(-lookback) + // 本地 SQLite 模式下使用 UTC 边界查询 Agent 报告。 // Logan.Li + since := time.Now().UTC().Add(-lookback) var reports []model.AgentReport if err := h.db.Where("hostname = ? AND report_time >= ?", req.Hostname, since). Order("report_time ASC").Find(&reports).Error; err != nil { @@ -695,6 +698,9 @@ func avgDiskUsage(disks []model.DiskMetric) float64 { // GET /api/v1/agent/port-policy?hostname=xxx func (h *AgentHandler) GetPortPolicyForAgent(c *gin.Context) { hostname := c.Query("hostname") + if !middleware.RequireAgentHostname(c, hostname) { + return + } if hostname == "" { c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: model.AgentPortPolicyDTO{ PortFilterMode: "whitelist", diff --git a/backend/controller/agent_cli.go b/backend/controller/agent_cli.go index 10b0a99..1b65e49 100644 --- a/backend/controller/agent_cli.go +++ b/backend/controller/agent_cli.go @@ -19,6 +19,7 @@ import ( "ezops/model" "ezops/pkg/agentcli" "ezops/pkg/agentcli/core" + "ezops/pkg/agentcli/guardrail" "ezops/pkg/agentcli/llm" "ezops/pkg/agentcli/safety" "ezops/pkg/agentcli/tools" @@ -36,21 +37,24 @@ func NewAgentCLIHandler(db *gorm.DB) *AgentCLIHandler { return &AgentCLIHandler{ // wsEvent WebSocket 事件信封(后端→前端) // Logan.Li type wsEvent struct { - Type string `json:"type"` - 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"` - Command string `json:"command,omitempty"` + Type string `json:"type"` + Phase string `json:"phase,omitempty"` + Summary string `json:"summary,omitempty"` + 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"` + OutputSize int `json:"output_size,omitempty"` + Tokens int `json:"tokens,omitempty"` + Answer string `json:"answer,omitempty"` + Message string `json:"message,omitempty"` + ConfirmID string `json:"confirm_id,omitempty"` + Command string `json:"command,omitempty"` } // wsControl 前端→后端控制消息 // Logan.Li @@ -83,7 +87,7 @@ type agentConn struct { server model.Server sshCfg remoteexec.SSHConfig kubernetesCtx *kubernetesAgentContext - session *model.AgentCLISession + session *model.AIRun taskCtx context.Context cancel context.CancelFunc totalTokens int @@ -94,6 +98,9 @@ type agentConn struct { convHistory []llm.Message running bool runningMu sync.Mutex + pendingSpans map[string]string + seq int + seqMu sync.Mutex } // Run WebSocket 入口 // Logan.Li @@ -141,7 +148,7 @@ func (h *AgentCLIHandler) runHostWS(c *gin.Context) { ac := &agentConn{ conn: conn, db: h.db, mode: agentcli.RunModeHost, userID: middleware.GetUserID(c), server: server, sshCfg: sshCfg, - taskCtx: context.Background(), confirms: make(map[string]chan bool), + taskCtx: context.Background(), confirms: make(map[string]chan bool), pendingSpans: make(map[string]string), } ac.write(wsEvent{Type: "session", SessionID: 0, Message: fmt.Sprintf("已连接主机 %s(%s),LLM: %s。请描述要执行的任务。", server.Hostname, server.IP, llmCfg.Model)}) @@ -178,7 +185,7 @@ func (h *AgentCLIHandler) runKubernetesWS(c *gin.Context) { ac := &agentConn{ conn: conn, db: h.db, mode: agentcli.RunModeKubernetes, userID: middleware.GetUserID(c), kubernetesCtx: kCtx, - taskCtx: context.Background(), confirms: make(map[string]chan bool), + taskCtx: context.Background(), confirms: make(map[string]chan bool), pendingSpans: make(map[string]string), } target := buildKubernetesTargetInfo(kCtx) ac.write(wsEvent{Type: "session", SessionID: 0, @@ -191,7 +198,9 @@ func (ac *agentConn) wsReadLoop() { for { _, data, err := ac.conn.ReadMessage() if err != nil { - ac.abort() + if ac.isRunning() { + ac.abort() + } return } var ctrl wsControl @@ -253,20 +262,27 @@ func (ac *agentConn) startTask(query, safetyMode string) { if r := []rune(title); len(r) > 60 { title = string(r[:60]) + "..." } - sess := &model.AgentCLISession{ - UserID: ac.userID, Mode: ac.mode, Title: title, Status: "running", + sess := &model.AIRun{ + RunID: guardrail.NewTraceID(), UserID: ac.userID, Feature: "agent_cli", + Mode: ac.mode, Title: title, Status: "running", Provider: llmCfg.Provider, Model: llmCfg.Model, + SafetyMode: safetyMode, StartedAt: time.Now(), } if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil { - sess.ClusterID = ac.kubernetesCtx.cluster.ID - sess.ClusterName = ac.kubernetesCtx.cluster.Name - sess.Namespace = ac.kubernetesCtx.namespace - sess.PodName = ac.kubernetesCtx.podName - sess.Container = ac.kubernetesCtx.container + sess.SubjectType = "k8s_cluster" + if ac.kubernetesCtx.podName != "" { + sess.SubjectType = "pod" + } else if ac.kubernetesCtx.namespace != "" { + sess.SubjectType = "namespace" + } + sess.SubjectID = fmt.Sprintf("%d", ac.kubernetesCtx.cluster.ID) + sess.SubjectName = ac.kubernetesCtx.cluster.Name + sess.SubjectScope = ac.kubernetesCtx.namespace } else { - sess.ServerID = ac.server.ID - sess.ServerName = ac.server.Hostname - sess.ServerHost = ac.server.IP + sess.SubjectType = "host" + sess.SubjectID = fmt.Sprintf("%d", ac.server.ID) + sess.SubjectName = ac.server.Hostname + sess.SubjectScope = ac.server.IP } if err := ac.db.Create(sess).Error; err != nil { ac.write(wsEvent{Type: "error", Message: "创建会话失败: " + err.Error()}) @@ -284,15 +300,15 @@ func (ac *agentConn) resumeSession(sessionID uint) { ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"}) return } - var sess model.AgentCLISession + var sess model.AIRun q := ac.db.Where("user_id = ?", ac.userID) if ac.mode == agentcli.RunModeKubernetes { q = q.Where("mode IN ?", []string{agentcli.RunModeKubernetes, "k8s"}) if ac.kubernetesCtx != nil { - q = q.Where("cluster_id = ? AND namespace = ?", ac.kubernetesCtx.cluster.ID, ac.kubernetesCtx.namespace) + q = q.Where("subject_id = ? AND subject_scope = ?", fmt.Sprintf("%d", ac.kubernetesCtx.cluster.ID), ac.kubernetesCtx.namespace) } } else { - q = q.Where("mode = ? AND server_id = ?", ac.mode, ac.server.ID) + q = q.Where("mode = ? AND subject_type = ? AND subject_id = ?", ac.mode, "host", fmt.Sprintf("%d", ac.server.ID)) } if err := q.First(&sess, sessionID).Error; err != nil { ac.write(wsEvent{Type: "error", Message: "会话不存在或无权访问"}) @@ -302,8 +318,8 @@ func (ac *agentConn) resumeSession(sessionID uint) { ac.write(wsEvent{Type: "error", Message: "该会话仍在执行中,无法恢复"}) return } - var msgs []model.AgentCLIMessage - ac.db.Where("session_id = ?", sessionID).Order("id ASC").Find(&msgs) + var msgs []model.AIRunMessage + ac.db.Where("run_id = ?", sess.RunID).Order("seq ASC, id ASC").Find(&msgs) ac.session = &sess ac.convHistory = buildConvHistoryFromMessages(msgs) ac.setTotalTokens(sess.TotalTokens) @@ -337,7 +353,7 @@ func (ac *agentConn) continueTask(query, safetyMode string) { } // runTask 执行一轮 ReAct(新建或追问共用) // Logan.Li -func (ac *agentConn) runTask(sess *model.AgentCLISession, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) { +func (ac *agentConn) runTask(sess *model.AIRun, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) { if ac.isRunning() { ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"}) return @@ -390,9 +406,9 @@ func (ac *agentConn) runTask(sess *model.AgentCLISession, query, safetyMode stri ac.taskCtx = ctx ac.cancel = cancel ac.setRunning(true) - ac.db.Model(sess).Update("status", "running") + ac.db.Model(sess).Updates(map[string]interface{}{"status": "running", "safety_mode": safetyMode}) - ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0) + ac.persistMsg(sess.RunID, 0, "user", "user", query, "", "", "", "", false, "", 0) targetInfo := ac.buildTargetInfo() @@ -454,11 +470,15 @@ func (ac *agentConn) onEvent(e core.Event) { if e.Type == core.EventProgress && e.Tokens > 0 { ac.setTotalTokens(e.Tokens) } + if e.Type == core.EventDone || e.Type == core.EventError { + ac.setRunning(false) + } ev := wsEvent{ - Type: string(e.Type), Round: e.Round, Thought: e.Thought, + Type: string(e.Type), Phase: e.Phase, Summary: e.Summary, + Round: e.Round, Thought: e.Thought, ToolName: e.ToolName, ToolArgs: e.ToolArgs, Risk: e.Risk, - Output: e.Output, Success: e.Success, Duration: e.Duration, + Output: e.Output, Success: e.Success, Duration: e.Duration, OutputSize: e.OutputSize, Tokens: e.Tokens, Answer: e.Answer, Message: e.Message, } if ac.session != nil { @@ -470,20 +490,25 @@ func (ac *agentConn) onEvent(e core.Event) { if ac.session == nil { return } - sid := ac.session.ID + runID := ac.session.RunID switch e.Type { + case core.EventStatus: + // status 事件不改 DB schema:duration 字段在 type=status 时复用为 phase。 + ac.persistMsg(runID, e.Round, "status", "system", e.Summary, e.ToolName, "", "", e.Risk, e.Phase != core.PhaseError && e.Phase != core.PhaseBlocked, e.Phase, e.Tokens) case core.EventThought: - ac.persistMsg(sid, e.Round, "thought", "assistant", e.Thought, "", "", "", "", false, "", 0) + ac.persistMsg(runID, e.Round, "thought", "assistant", e.Thought, "", "", "", "", false, "", 0) case core.EventToolCall: argsJSON, _ := json.Marshal(e.ToolArgs) - ac.persistMsg(sid, e.Round, "tool_call", "tool", "", e.ToolName, string(argsJSON), "", e.Risk, false, "", 0) + ac.persistMsg(runID, e.Round, "tool_call", "tool", string(argsJSON), e.ToolName, string(argsJSON), "", e.Risk, false, "", 0) + ac.startToolSpan(e, string(argsJSON)) case core.EventToolResult: - ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0) + ac.persistMsg(runID, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0) + ac.finishToolSpan(e) case core.EventDone: ac.setTotalTokens(e.Tokens) - ac.persistMsg(sid, e.Round, "done", "assistant", e.Answer, "", "", "", "", true, "", e.Tokens) + ac.persistMsg(runID, e.Round, "done", "assistant", e.Answer, "", "", "", "", true, "", e.Tokens) case core.EventError: - ac.persistMsg(sid, e.Round, "error", "assistant", e.Message, "", "", "", "", false, "", 0) + ac.persistMsg(runID, e.Round, "error", "assistant", e.Message, "", "", "", "", false, "", 0) } } @@ -503,7 +528,7 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris if cmd == "" && (toolName == "kubernetes_action" || toolName == "k8s_action") { cmd = fmt.Sprintf("%v %v", args["action"], args["name"]) } - ac.write(wsEvent{Type: "confirm", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args}) + ac.write(wsEvent{Type: "confirm", Phase: core.PhaseApproval, Summary: "等待用户确认", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args}) ch := make(chan bool, 1) ac.cfMu.Lock() @@ -512,6 +537,7 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris select { case approved := <-ch: + ac.recordConfirmSpan(toolName, args, risk, approved) return approved, nil case <-ac.taskCtx.Done(): ac.delConfirm(confirmID) @@ -522,6 +548,25 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris } } +func (ac *agentConn) recordConfirmSpan(toolName string, args map[string]interface{}, risk tools.RiskLevel, approved bool) { + if ac.session == nil { + return + } + decision := "rejected" + status := "skipped" + if approved { + decision = "approved" + status = "success" + } + argsJSON, _ := json.Marshal(args) + span := &model.AIRunSpan{ + RunID: ac.session.RunID, SpanID: guardrail.NewSpanID(), Kind: "confirm", Name: toolName, + ToolSource: ac.toolSource(toolName), Input: guardrail.SanitizeJSON(string(argsJSON)), + Risk: risk.String(), Decision: decision, Status: status, StartedAt: time.Now(), CreatedAt: time.Now(), + } + _ = ac.db.Create(span).Error +} + // resolveConfirm 前端回传确认结果 // Logan.Li func (ac *agentConn) resolveConfirm(confirmID string, approved bool) { ac.cfMu.Lock() @@ -564,13 +609,13 @@ func (ac *agentConn) lastRound() int { return 0 } var maxRound int - ac.db.Model(&model.AgentCLIMessage{}).Where("session_id = ?", ac.session.ID). + ac.db.Model(&model.AIRunMessage{}).Where("run_id = ?", ac.session.RunID). Select("COALESCE(MAX(round),0)").Scan(&maxRound) return maxRound } // finish 结束本轮执行并更新会话 DB // Logan.Li -func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) { +func (ac *agentConn) finish(sess *model.AIRun, status, errMsg string, rounds int, dur time.Duration) { ac.setRunning(false) tokens := ac.getTotalTokens() // done/error 已由引擎 onEvent 推送;此处仅 aborted 需补推 WS(引擎初始化失败在 runTask 内单独推送) // Logan.Li @@ -581,6 +626,10 @@ func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, "status": status, "rounds": rounds, "duration": dur.Milliseconds(), "total_tokens": tokens, } + if status == "done" || status == "error" || status == "aborted" { + now := time.Now() + updates["finished_at"] = &now + } if errMsg != "" { updates["error"] = errMsg } @@ -595,15 +644,145 @@ func (ac *agentConn) write(ev wsEvent) { _ = ac.conn.WriteMessage(websocket.TextMessage, b) } -// persistMsg 持久化一条消息 // Logan.Li -func (ac *agentConn) persistMsg(sid uint, round int, typ, role, content, toolName, args, output, risk string, success bool, dur string, tokens int) { - msg := &model.AgentCLIMessage{ - SessionID: sid, Round: round, Type: typ, Role: role, Content: content, - ToolName: toolName, ToolArgs: args, ToolOutput: output, Risk: risk, - Success: success, Duration: dur, Tokens: tokens, +// persistMsg 持久化一条 AI Run 回放消息。 // Logan.Li +func (ac *agentConn) persistMsg(runID string, round int, typ, role, content, toolName, args, output, risk string, success bool, dur string, tokens int) { + content = guardrail.SanitizeText(content) + if typ == "tool_call" { + content = guardrail.SanitizeJSON(content) + } + msg := &model.AIRunMessage{ + RunID: runID, Round: round, Seq: ac.nextSeq(), + Type: typ, Role: role, Content: content, + ToolName: toolName, Tokens: tokens, } if err := ac.db.Create(msg).Error; err != nil { // 持久化失败不阻断主流程 _ = err } } + +func (ac *agentConn) nextSeq() int { + ac.seqMu.Lock() + defer ac.seqMu.Unlock() + ac.seq++ + return ac.seq +} + +func (ac *agentConn) startToolSpan(e core.Event, argsJSON string) { + if ac.session == nil { + return + } + spanID := guardrail.NewSpanID() + key := ac.pendingSpanKey(e.Round, e.ToolName) + ac.runningMu.Lock() + ac.pendingSpans[key] = spanID + ac.runningMu.Unlock() + span := &model.AIRunSpan{ + RunID: ac.session.RunID, SpanID: spanID, Kind: "tool", Name: e.ToolName, + ToolSource: ac.toolSource(e.ToolName), Input: guardrail.SanitizeJSON(argsJSON), + Risk: e.Risk, Decision: "auto", Status: "running", + StartedAt: time.Now(), + } + _ = ac.db.Create(span).Error +} + +func (ac *agentConn) finishToolSpan(e core.Event) { + if ac.session == nil { + return + } + key := ac.pendingSpanKey(e.Round, e.ToolName) + ac.runningMu.Lock() + spanID := ac.pendingSpans[key] + delete(ac.pendingSpans, key) + ac.runningMu.Unlock() + if spanID == "" { + return + } + status := "success" + if !e.Success { + status = "error" + } + now := time.Now() + ac.db.Model(&model.AIRunSpan{}).Where("span_id = ?", spanID).Updates(map[string]interface{}{ + "output": guardrail.SanitizeText(e.Output), "output_size": e.OutputSize, + "duration": parseDurationMillis(e.Duration), "status": status, "ended_at": &now, + }) + ac.recordOpsCommand(e, spanID, status) +} + +func (ac *agentConn) pendingSpanKey(round int, toolName string) string { + return fmt.Sprintf("%d:%s", round, toolName) +} + +func (ac *agentConn) toolSource(toolName string) string { + switch toolName { + case "ssh_execute": + return "shell" + case "kubernetes_diagnose", "kubernetes_get", "pod_logs", "pod_execute", "kubernetes_action": + return "kubernetes" + case "aiops_asset_search", "aiops_alert_list", "aiops_metric_summary": + return "aiops" + default: + return "builtin" + } +} + +func (ac *agentConn) recordOpsCommand(e core.Event, spanID, status string) { + if ac.session == nil { + return + } + command := e.ToolName + if e.ToolArgs != nil { + if cmd, ok := e.ToolArgs["command"].(string); ok && cmd != "" { + command = cmd + } else if action, ok := e.ToolArgs["action"]; ok { + command = fmt.Sprintf("%v %v", action, e.ToolArgs["name"]) + } + } + targetType, targetID := ac.currentTarget() + cmd := &model.OpsCommand{ + CommandID: guardrail.NewSpanID(), RunID: ac.session.RunID, SpanID: spanID, + UserID: ac.userID, ActorType: "ai", TargetType: targetType, TargetID: targetID, + CommandType: ac.commandType(e.ToolName), Command: guardrail.SanitizeText(command), + Risk: e.Risk, Decision: "auto", Status: status, OutputSize: e.OutputSize, + Duration: parseDurationMillis(e.Duration), ExecutedAt: time.Now(), + } + if !e.Success { + cmd.Error = guardrail.SanitizeText(e.Output) + } + _ = ac.db.Create(cmd).Error +} + +func (ac *agentConn) currentTarget() (string, string) { + if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil { + if ac.kubernetesCtx.podName != "" { + return "pod", fmt.Sprintf("%d/%s/%s", ac.kubernetesCtx.cluster.ID, ac.kubernetesCtx.namespace, ac.kubernetesCtx.podName) + } + return "k8s_cluster", fmt.Sprintf("%d", ac.kubernetesCtx.cluster.ID) + } + return "host", fmt.Sprintf("%d", ac.server.ID) +} + +func (ac *agentConn) commandType(toolName string) string { + switch toolName { + case "ssh_execute", "pod_execute": + return "shell" + case "kubernetes_diagnose", "kubernetes_action", "kubernetes_get", "pod_logs": + return "kubernetes" + case "aiops_asset_search", "aiops_alert_list", "aiops_metric_summary": + return "api" + default: + return "tool" + } +} + +func parseDurationMillis(raw string) int64 { + if raw == "" { + return 0 + } + d, err := time.ParseDuration(raw) + if err != nil { + return 0 + } + return d.Milliseconds() +} diff --git a/backend/controller/agent_cli_api.go b/backend/controller/agent_cli_api.go index ee578fb..9a71340 100644 --- a/backend/controller/agent_cli_api.go +++ b/backend/controller/agent_cli_api.go @@ -26,7 +26,7 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) { pageSize = 20 } - q := h.db.Model(&model.AgentCLISession{}).Where("user_id = ?", uid) + q := h.db.Model(&model.AIRun{}).Where("user_id = ? AND feature = ?", uid, "agent_cli") if mode := c.Query("mode"); mode != "" { mode = agentcli.NormalizeRunMode(mode) if mode == agentcli.RunModeKubernetes { @@ -36,18 +36,18 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) { } } if sid := c.Query("server_id"); sid != "" { - q = q.Where("server_id = ?", sid) + q = q.Where("subject_type = ? AND subject_id = ?", "host", sid) } if cid := c.Query("cluster_id"); cid != "" { - q = q.Where("cluster_id = ?", cid) + q = q.Where("subject_id = ?", cid) } if ns := c.Query("namespace"); ns != "" { - q = q.Where("namespace = ?", ns) + q = q.Where("subject_scope = ?", ns) } var total int64 q.Count(&total) - var sessions []model.AgentCLISession + var sessions []model.AIRun if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 500, Message: "查询失败: " + err.Error()}) return @@ -60,13 +60,13 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) { func (h *AgentCLIHandler) GetSession(c *gin.Context) { uid := middleware.GetUserID(c) id := c.Param("id") - var sess model.AgentCLISession + var sess model.AIRun if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"}) return } - var msgs []model.AgentCLIMessage - h.db.Where("session_id = ?", id).Order("id ASC").Find(&msgs) + var msgs []model.AIRunMessage + h.db.Where("run_id = ?", sess.RunID).Order("seq ASC, id ASC").Find(&msgs) c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"session": sess, "messages": msgs}}) } @@ -75,16 +75,18 @@ func (h *AgentCLIHandler) GetSession(c *gin.Context) { func (h *AgentCLIHandler) DeleteSession(c *gin.Context) { uid := middleware.GetUserID(c) id := c.Param("id") - var sess model.AgentCLISession + var sess model.AIRun if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"}) return } - if err := h.db.Where("session_id = ?", id).Delete(&model.AgentCLIMessage{}).Error; err != nil { + if err := h.db.Where("run_id = ?", sess.RunID).Delete(&model.AIRunMessage{}).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除消息失败: " + err.Error()}) return } - if err := h.db.Delete(&model.AgentCLISession{}, id).Error; err != nil { + _ = h.db.Where("run_id = ?", sess.RunID).Delete(&model.AIRunSpan{}).Error + _ = h.db.Where("run_id = ?", sess.RunID).Delete(&model.OpsCommand{}).Error + if err := h.db.Delete(&model.AIRun{}, id).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除会话失败: " + err.Error()}) return } diff --git a/backend/controller/agent_cli_history.go b/backend/controller/agent_cli_history.go index 550ed3f..75e39c7 100644 --- a/backend/controller/agent_cli_history.go +++ b/backend/controller/agent_cli_history.go @@ -10,7 +10,7 @@ import ( ) // buildConvHistoryFromMessages 将 DB 消息重建为追问用的 prior 历史(不含 system) // Logan.Li -func buildConvHistoryFromMessages(msgs []model.AgentCLIMessage) []llm.Message { +func buildConvHistoryFromMessages(msgs []model.AIRunMessage) []llm.Message { var out []llm.Message var pendingThought string var pendingToolCalls []llm.ToolCall @@ -40,7 +40,7 @@ func buildConvHistoryFromMessages(msgs []model.AgentCLIMessage) []llm.Message { case "thought": pendingThought = m.Content case "tool_call": - args := validateToolArgsJSON(m.ToolArgs) + args := validateToolArgsJSON(m.Content) tcID := fmt.Sprintf("hist-%d", m.ID) pendingToolCalls = append(pendingToolCalls, llm.ToolCall{ ID: tcID, Type: "function", @@ -56,9 +56,6 @@ func buildConvHistoryFromMessages(msgs []model.AgentCLIMessage) []llm.Message { pendingToolCallIDs = pendingToolCallIDs[1:] } content := m.Content - if content == "" { - content = m.ToolOutput - } out = append(out, llm.Message{ Role: "tool", Name: m.ToolName, Content: content, ToolCallID: toolID, }) @@ -86,4 +83,4 @@ func validateToolArgsJSON(raw string) string { return "{}" } return raw -} \ No newline at end of file +} diff --git a/backend/controller/agent_cli_kubernetes.go b/backend/controller/agent_cli_kubernetes.go index d14b275..65fe626 100644 --- a/backend/controller/agent_cli_kubernetes.go +++ b/backend/controller/agent_cli_kubernetes.go @@ -108,4 +108,4 @@ func kubernetesEvolveHostKey(ctx *kubernetesAgentContext) string { return base + "/" + ctx.podName } return base -} \ No newline at end of file +} diff --git a/backend/controller/agent_install.go b/backend/controller/agent_install.go index 5d61559..bb330d7 100644 --- a/backend/controller/agent_install.go +++ b/backend/controller/agent_install.go @@ -6,11 +6,13 @@ import ( "log" "net" "net/http" + "net/url" "strconv" "strings" "time" "ezops/model" + "ezops/pkg/agentauth" "github.com/gin-gonic/gin" "golang.org/x/crypto/ssh" @@ -142,28 +144,70 @@ func runSSHCommandWithTimeout(client *ssh.Client, cmd string, timeout time.Durat } } -// installServerURL 根据请求构造后端可访问地址,兼容 Nginx 等反向代理 // Logan.Li -func installServerURL(c *gin.Context) string { +// installServerURL 解析 Agent 对外服务地址,优先使用前端提交的浏览器 origin。 // Logan.Li +func installServerURL(c *gin.Context, preferred string) (string, error) { + if strings.TrimSpace(preferred) != "" { + return validateInstallServerURL(preferred) + } scheme := "http" if c.Request.TLS != nil { scheme = "https" } if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { - scheme = proto + scheme = strings.TrimSpace(strings.Split(proto, ",")[0]) } // 优先使用 X-Forwarded-Host(Nginx 暴露给客户端的真实域名),其次用 Host // Logan.Li host := c.Request.Host if fwd := c.GetHeader("X-Forwarded-Host"); fwd != "" { - host = fwd + host = strings.TrimSpace(strings.Split(fwd, ",")[0]) } - // 去掉 scheme 的默认端口,避免生成 http://example.com:80 这类无效地址 // Logan.Li - if scheme == "http" && strings.HasSuffix(host, ":80") { - host = strings.TrimSuffix(host, ":80") + return validateInstallServerURL(fmt.Sprintf("%s://%s", scheme, host)) +} + +// validateInstallServerURL 只允许无路径的 HTTP(S) origin,防止命令参数注入。 // Logan.Li +func validateInstallServerURL(rawURL string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" { + return "", fmt.Errorf("Agent 服务地址必须是有效的 HTTP(S) 地址") } - if scheme == "https" && strings.HasSuffix(host, ":443") { - host = strings.TrimSuffix(host, ":443") + if parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("Agent 服务地址只能填写域名 origin,不允许包含路径、认证信息或查询参数") + } + return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host), nil +} + +// shellQuote 对 SSH 远程命令参数做单引号转义。 // Logan.Li +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +// agentInstallCommand 将资产主机名显式传给 Agent,避免系统主机名与资产别名不一致。 // Logan.Li +func agentInstallCommand(serverURL, token, hostname string) string { + scriptURL := shellQuote(serverURL + "/agent/install.sh") + serverArg := shellQuote(serverURL) + tokenArg := shellQuote(token) + hostnameArg := shellQuote(hostname) + return fmt.Sprintf( + `tmp=$(mktemp /tmp/aiops-agent-install.XXXXXX) && trap 'rm -f "$tmp"' EXIT && curl -fsSL -o "$tmp" %s && if [ "$(id -u)" -eq 0 ]; then bash "$tmp" --server %s --token %s --hostname %s; else sudo -n bash "$tmp" --server %s --token %s --hostname %s; fi`, + scriptURL, serverArg, tokenArg, hostnameArg, serverArg, tokenArg, hostnameArg, + ) +} + +// agentInstallErrorMessage 将常见安装失败转换为可操作的中文提示。 // Logan.Li +func agentInstallErrorMessage(serverURL, output string) string { + lowerOutput := strings.ToLower(output) + switch { + case strings.Contains(lowerOutput, "403") || strings.Contains(lowerOutput, "forbidden"): + return "域名代理或 WAF 拒绝 Agent 下载(HTTP 403),请为 /agent/* 关闭人机验证或加入机器访问白名单" + case strings.Contains(lowerOutput, "sudo: a password is required") || strings.Contains(lowerOutput, "需要密码"): + return "目标主机 SSH 用户没有免密 sudo 权限,请使用 root 账号或配置 passwordless sudo" + case strings.Contains(lowerOutput, "curl") && (strings.Contains(lowerOutput, "not found") || strings.Contains(lowerOutput, "未找到")): + return "目标主机未安装 curl" + case strings.Contains(lowerOutput, "下载失败") || strings.Contains(lowerOutput, "failed to connect") || strings.Contains(lowerOutput, "connection refused"): + return "目标主机无法下载安装脚本,请检查后端地址 " + serverURL + " 是否可达" + default: + return "Agent 安装命令执行失败" } - return fmt.Sprintf("%s://%s", scheme, host) } // detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li @@ -187,11 +231,12 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var req struct { - Username string `json:"username"` - Password string `json:"password"` - SSHKey string `json:"ssh_key"` - AuthType string `json:"auth_type"` - Token string `json:"token"` + Username string `json:"username"` + Password string `json:"password"` + SSHKey string `json:"ssh_key"` + AuthType string `json:"auth_type"` + Token string `json:"token"` + ServerURL string `json:"server_url"` } // 兼容旧调用方式:token 在 query,新方式在 body c.ShouldBindJSON(&req) @@ -273,31 +318,24 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { } // 3. 构造安装命令(使用 Agent API 服务地址) - serverURL := installServerURL(c) - token := req.Token - if token == "" { - token = c.Query("token") + serverURL, urlErr := installServerURL(c, req.ServerURL) + if urlErr != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()}) + return + } + token, err := agentauth.Issue(h.db, server.Hostname, ac.username) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "签发 Agent 凭证失败"}) + return } - // 4. 构造安装命令:root 用户直接 bash,非 root 用 sudo -n(无密码才执行,避免挂死)// Logan.Li - installCmd := fmt.Sprintf( - `if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`, - serverURL, serverURL, token, - serverURL, serverURL, token, - ) + // 4. 先下载脚本再执行,curl/WAF 失败时不会被管道末端 bash 的退出码掩盖。 // Logan.Li + installCmd := agentInstallCommand(serverURL, token, server.Hostname) // 5. 执行安装 installOut, err := runSSHCommand(client, installCmd) if err != nil { - msg := "安装命令执行失败" - lowerOut := strings.ToLower(installOut) - if strings.Contains(lowerOut, "sudo: a password is required") || strings.Contains(lowerOut, "需要密码") { - msg = "目标主机 SSH 用户没有免密 sudo 权限,请使用 root 账号或配置 passwordless sudo" - } else if strings.Contains(lowerOut, "curl") && (strings.Contains(lowerOut, "not found") || strings.Contains(lowerOut, "未找到")) { - msg = "目标主机未安装 curl" - } else if strings.Contains(lowerOut, "下载失败") || strings.Contains(lowerOut, "failed to connect") || strings.Contains(lowerOut, "connection refused") { - msg = "目标主机无法下载安装脚本,请检查后端地址 " + serverURL + " 是否可达" - } + msg := agentInstallErrorMessage(serverURL, installOut) h.recordAgentAudit(ac, "install", resource, resourceID, 0, fmt.Sprintf("server=%s arch=%s", serverURL, arch), installOut, msg) c.JSON(http.StatusOK, model.Response{ @@ -311,6 +349,13 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { // 5. 验证安装是否成功 verifyOut, _ := runSSHCommand(client, "systemctl is-active aiops-agent 2>/dev/null || echo 'not_found'") installed := contains(verifyOut, "active") || contains(verifyOut, "running") + if !installed { + msg := "安装脚本已执行,但 Agent 服务未运行" + h.recordAgentAudit(ac, "install", resource, resourceID, 0, + fmt.Sprintf("server=%s arch=%s", serverURL, arch), installOut+"\n"+verifyOut, msg) + c.JSON(http.StatusOK, model.Response{Code: 500, Message: msg, Data: gin.H{"output": installOut, "verify": verifyOut}}) + return + } var agentVer string if installed { @@ -339,7 +384,7 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) { } c.JSON(http.StatusOK, model.Response{ - Code: 200, + Code: 200, Message: func() string { if installed { return "Agent 安装成功,已自动启动并设置开机自启" @@ -365,11 +410,7 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s // 不强制 server.Status==running:只要 SSH 可达即可安装(DB 状态可能滞后于实际开机状态) // Logan.Li - installCmd := fmt.Sprintf( - `if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`, - serverURL, serverURL, token, - serverURL, serverURL, token, - ) + installCmd := agentInstallCommand(serverURL, token, server.Hostname) installOut, err := runSSHCommand(client, installCmd) if err != nil { @@ -378,6 +419,9 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s verifyOut, _ := runSSHCommand(client, "systemctl is-active aiops-agent 2>/dev/null || echo 'not_found'") installed := contains(verifyOut, "active") || contains(verifyOut, "running") + if !installed { + return false, installOut, verifyOut, fmt.Errorf("安装脚本已执行,但 Agent 服务未运行") + } if installed { // 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本 // Logan.Li @@ -400,6 +444,7 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) { var req struct { ServerIDs []uint `json:"server_ids"` + ServerURL string `json:"server_url"` Credentials map[string]struct { Username string `json:"username"` Password string `json:"password"` @@ -412,8 +457,11 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) { return } - serverURL := installServerURL(c) - token := c.Query("token") + serverURL, urlErr := installServerURL(c, req.ServerURL) + if urlErr != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()}) + return + } ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li // 异步执行:立即返回,后台 goroutine 逐台安装 @@ -444,13 +492,23 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) { } } + token, tokenErr := agentauth.Issue(h.db, server.Hostname, ac.username) + if tokenErr != nil { + log.Printf("[BatchInstall] %s 签发 Agent 凭证失败: %v", server.Hostname, tokenErr) + continue + } installed, installOut, verifyOut, err := h.installOne(effectiveServer, serverURL, token) res := server.Hostname + " (" + server.IP + ")" rid := strconv.Itoa(int(server.ID)) reqDetail := fmt.Sprintf("server=%s host=%s", serverURL, res) if err != nil { - log.Printf("[BatchInstall] %s (%s) 安装失败: %v", server.Hostname, server.IP, err) - h.recordAgentAudit(ac, "install", res, rid, 0, reqDetail, installOut, truncateAuditText(err.Error(), 500)) + errMessage := agentInstallErrorMessage(serverURL, installOut) + if errMessage == "Agent 安装命令执行失败" { + errMessage = err.Error() + } + log.Printf("[BatchInstall] %s (%s) 安装失败: %s", server.Hostname, server.IP, errMessage) + respDetail := fmt.Sprintf("=== 安装输出 ===\n%s\n=== 服务状态 ===\n%s", installOut, verifyOut) + h.recordAgentAudit(ac, "install", res, rid, 0, reqDetail, respDetail, truncateAuditText(errMessage, 500)) } else { successCount++ respDetail := fmt.Sprintf("=== 安装输出 ===\n%s\n=== 服务状态 ===\n%s", installOut, verifyOut) @@ -592,13 +650,18 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) { func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { var req struct { ServerIDs []uint `json:"server_ids"` + ServerURL string `json:"server_url"` } if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要升级的服务器 ID 列表"}) return } - serverURL := installServerURL(c) + serverURL, urlErr := installServerURL(c, req.ServerURL) + if urlErr != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()}) + return + } ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li // 异步执行:立即返回,后台逐台升级 @@ -634,8 +697,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) { // 下载新二进制 + 获取版本号 + 重启服务 upgradeScript := fmt.Sprintf( - `curl -fsSL -o /tmp/aiops-agent %s/api/agent/bin/%s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent version 2>/dev/null | awk '/AIOps Agent/{print $3}'); echo "UPGRADE_OK ver=$ver"`, - serverURL, binaryFile, + `curl -fsSL -o /tmp/aiops-agent %s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent version 2>/dev/null | awk '/AIOps Agent/{print $3}'); echo "UPGRADE_OK ver=$ver"`, + shellQuote(serverURL+"/api/agent/bin/"+binaryFile), ) out, err := runSSHCommand(client, upgradeScript) diff --git a/backend/controller/agent_install_test.go b/backend/controller/agent_install_test.go new file mode 100644 index 0000000..fe27cf8 --- /dev/null +++ b/backend/controller/agent_install_test.go @@ -0,0 +1,63 @@ +package controller + +import ( + "strings" + "testing" +) + +// TestValidateInstallServerURL 验证 Agent 公网地址仅接受安全的 HTTP(S) origin。 // Logan.Li +func TestValidateInstallServerURL(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "HTTP 域名", input: "http://aiops.example.com", want: "http://aiops.example.com"}, + {name: "HTTPS 域名", input: "https://aiops.example.com/", want: "https://aiops.example.com"}, + {name: "自定义端口", input: "https://aiops.example.com:8443", want: "https://aiops.example.com:8443"}, + {name: "拒绝路径", input: "https://aiops.example.com/api", wantErr: true}, + {name: "拒绝查询参数", input: "https://aiops.example.com?x=1", wantErr: true}, + {name: "拒绝非 HTTP 协议", input: "file:///tmp/agent", wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := validateInstallServerURL(test.input) + if test.wantErr { + if err == nil { + t.Fatalf("期望校验失败,实际返回 %q", got) + } + return + } + if err != nil { + t.Fatalf("地址校验失败: %v", err) + } + if got != test.want { + t.Fatalf("期望 %q,实际 %q", test.want, got) + } + }) + } +} + +// TestAgentInstallCommand 验证下载失败不会被管道末端命令掩盖。 // Logan.Li +func TestAgentInstallCommand(t *testing.T) { + command := agentInstallCommand("https://aiops.example.com", "token-value", "asset-host") + if strings.Contains(command, "| bash") { + t.Fatalf("安装命令不应继续使用 curl 管道: %s", command) + } + if !strings.Contains(command, "curl -fsSL -o") || !strings.Contains(command, "https://aiops.example.com/agent/install.sh") { + t.Fatalf("安装命令缺少脚本下载步骤: %s", command) + } + if !strings.Contains(command, "--hostname 'asset-host'") { + t.Fatalf("安装命令必须固定上报资产主机名: %s", command) + } +} + +// TestAgentInstallErrorMessage 验证 WAF 拒绝能返回明确提示。 // Logan.Li +func TestAgentInstallErrorMessage(t *testing.T) { + message := agentInstallErrorMessage("https://aiops.example.com", "curl: (22) The requested URL returned error: 403") + if !strings.Contains(message, "WAF") || !strings.Contains(message, "/agent/*") { + t.Fatalf("WAF 错误提示不完整: %s", message) + } +} diff --git a/backend/controller/agent_report_test.go b/backend/controller/agent_report_test.go new file mode 100644 index 0000000..a3a5793 --- /dev/null +++ b/backend/controller/agent_report_test.go @@ -0,0 +1,190 @@ +package controller + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "ezops/model" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +func TestReceiveReportMatchesExistingAssetByIP(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open("file:agent_report_match?mode=memory&cache=shared"), &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormlogger.Silent), + }) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := db.AutoMigrate(&model.Server{}, &model.AgentReport{}, &model.HealthProbe{}); err != nil { + t.Fatalf("迁移测试表失败: %v", err) + } + + server := model.Server{Hostname: "c-redis-cluster-3", IP: "172.19.16.59", Status: "running"} + if err := db.Create(&server).Error; err != nil { + t.Fatalf("创建测试资产失败: %v", err) + } + + body := []byte(`{ + "hostname":"VM-16-59-tencentos", + "ip":"172.19.16.59", + "agent_version":"v2.1.0", + "cpu":{"usage_percent":12.5,"iowait":0.5,"core_count":4}, + "memory":{"total_gb":7.8,"used_gb":3.1,"available_gb":4.7,"usage_percent":39.7}, + "disks":[{"mount":"/","total_gb":100,"used_gb":25,"usage_percent":25}], + "system":{"os_version":"TencentOS Server 4","kernel":"6.6.0","uptime":3600} + }`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = req + + NewAgentHandler(db, nil, nil).ReceiveReport(c) + if recorder.Code != http.StatusOK { + t.Fatalf("上报返回码 = %d, body=%s", recorder.Code, recorder.Body.String()) + } + + var gotServer model.Server + if err := db.Take(&gotServer, server.ID).Error; err != nil { + t.Fatalf("读取资产失败: %v", err) + } + if gotServer.Hostname != server.Hostname || gotServer.AgentStatus != "online" { + t.Fatalf("资产关联错误: hostname=%q status=%q", gotServer.Hostname, gotServer.AgentStatus) + } + if gotServer.OS != "TencentOS Server 4" || gotServer.Kernel != "6.6.0" || gotServer.Uptime != 3600 { + t.Fatalf("系统字段更新错误: os=%q kernel=%q uptime=%d", gotServer.OS, gotServer.Kernel, gotServer.Uptime) + } + + var report model.AgentReport + if err := db.Order("id DESC").Take(&report).Error; err != nil { + t.Fatalf("读取 Agent 报告失败: %v", err) + } + if report.Hostname != server.Hostname { + t.Fatalf("报告主机名未归一: got=%q want=%q", report.Hostname, server.Hostname) + } + + var serverCount int64 + db.Model(&model.Server{}).Count(&serverCount) + if serverCount != 1 { + t.Fatalf("上报后产生重复资产: count=%d", serverCount) + } +} + +func TestReceiveReportUsesHeartbeatUptimeWithoutSystemPayload(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open("file:agent_report_heartbeat_uptime?mode=memory&cache=shared"), &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormlogger.Silent), + }) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := db.AutoMigrate(&model.Server{}, &model.AgentReport{}, &model.HealthProbe{}); err != nil { + t.Fatalf("迁移测试表失败: %v", err) + } + + server := model.Server{Hostname: "c-monitor", IP: "172.19.16.113", Status: "running"} + if err := db.Create(&server).Error; err != nil { + t.Fatalf("创建测试资产失败: %v", err) + } + + body := []byte(`{ + "hostname":"c-monitor", + "ip":"172.19.16.113", + "agent_version":"v2.1.0", + "cpu":{"usage_percent":1.5,"core_count":4}, + "memory":{"total_gb":15.1,"used_gb":1.8,"available_gb":13.3,"usage_percent":11.9}, + "heartbeat":{"uptime_sec":7200} + }`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = req + + NewAgentHandler(db, nil, nil).ReceiveReport(c) + if recorder.Code != http.StatusOK { + t.Fatalf("上报返回码 = %d, body=%s", recorder.Code, recorder.Body.String()) + } + + var gotServer model.Server + if err := db.Take(&gotServer, server.ID).Error; err != nil { + t.Fatalf("读取资产失败: %v", err) + } + if gotServer.Uptime != 7200 { + t.Fatalf("心跳运行时长未写入资产: got=%d want=7200", gotServer.Uptime) + } +} + +func TestGetLatestReportsUsesUTCTimeBoundary(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open("file:agent_latest_utc?mode=memory&cache=shared"), &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormlogger.Silent), + }) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := db.AutoMigrate(&model.Server{}, &model.AgentReport{}); err != nil { + t.Fatalf("迁移测试表失败: %v", err) + } + if err := db.Exec("CREATE TABLE asset_regions (id INTEGER PRIMARY KEY, name TEXT)").Error; err != nil { + t.Fatalf("创建区域测试表失败: %v", err) + } + + server := model.Server{ + Hostname: "c-redis-cluster-3", + IP: "172.19.16.59", + AgentVersion: "v2.1.0", + AgentStatus: "online", + DiskUsage: 5.56, + } + if err := db.Create(&server).Error; err != nil { + t.Fatalf("创建测试资产失败: %v", err) + } + // 模拟 Agent 以 UTC 上报,同时应用运行在 UTC+8 时区。 // Logan.Li + report := model.AgentReport{ + Hostname: server.Hostname, + CPUUsage: 12.5, + MemUsage: 39.7, + CPUCores: 4, + ReportTime: time.Now().UTC().Add(-time.Minute), + } + if err := db.Create(&report).Error; err != nil { + t.Fatalf("创建 Agent 报告失败: %v", err) + } + + originalLocal := time.Local + time.Local = time.FixedZone("UTC+8", 8*60*60) + defer func() { time.Local = originalLocal }() + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/agent/latest", nil) + NewAgentHandler(db, nil, nil).GetLatestReports(c) + if recorder.Code != http.StatusOK { + t.Fatalf("查询返回码 = %d, body=%s", recorder.Code, recorder.Body.String()) + } + + var response struct { + Code int `json:"code"` + Data []model.AgentLatestResponse `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if len(response.Data) != 1 { + t.Fatalf("主机数量 = %d, body=%s", len(response.Data), recorder.Body.String()) + } + got := response.Data[0] + if got.AgentStatus != "online" || got.CPUUsage != 12.5 || got.MemUsage != 39.7 { + t.Fatalf("最新指标未命中: status=%q cpu=%.1f mem=%.1f", got.AgentStatus, got.CPUUsage, got.MemUsage) + } +} diff --git a/backend/controller/agent_task.go b/backend/controller/agent_task.go index 4b0c062..ccf83db 100644 --- a/backend/controller/agent_task.go +++ b/backend/controller/agent_task.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "ezops/middleware" "ezops/model" "github.com/gin-gonic/gin" @@ -25,7 +26,7 @@ func NewAgentTaskHandler(db *gorm.DB) *AgentTaskHandler { func (h *AgentTaskHandler) CreateTask(c *gin.Context) { var req struct { Hostname string `json:"hostname"` - Type string `json:"type"` // command / script + Type string `json:"type"` // command / script Command string `json:"command"` Timeout int `json:"timeout"` } @@ -47,12 +48,12 @@ func (h *AgentTaskHandler) CreateTask(c *gin.Context) { username, _ := c.Get("username") task := model.AgentTask{ - TaskID: uuid.New().String(), - Hostname: req.Hostname, - Type: req.Type, - Command: req.Command, - Timeout: req.Timeout, - Status: "pending", + TaskID: uuid.New().String(), + Hostname: req.Hostname, + Type: req.Type, + Command: req.Command, + Timeout: req.Timeout, + Status: "pending", CreatedBy: fmt.Sprintf("%v", username), } @@ -72,6 +73,9 @@ func (h *AgentTaskHandler) PollTasks(c *gin.Context) { c.JSON(400, model.Response{Code: 400, Message: "hostname 不能为空"}) return } + if !middleware.RequireAgentHostname(c, hostname) { + return + } // 查找该主机 pending 的任务,按时间排序,最多取 5 个 var tasks []model.AgentTask @@ -94,6 +98,14 @@ func (h *AgentTaskHandler) PollTasks(c *gin.Context) { // POST /api/v1/agent/tasks/:task_id/result func (h *AgentTaskHandler) SubmitResult(c *gin.Context) { taskID := c.Param("task_id") + var task model.AgentTask + if err := h.db.Where("task_id = ?", taskID).Take(&task).Error; err != nil { + c.JSON(404, model.Response{Code: 404, Message: "任务不存在"}) + return + } + if !middleware.RequireAgentHostname(c, task.Hostname) { + return + } var req struct { ExitCode int `json:"exit_code"` @@ -167,9 +179,9 @@ func (h *AgentTaskHandler) QuickSetupRules(c *gin.Context) { Pluck("hostname", &onlineHosts) if len(onlineHosts) == 0 { - // 如果没有在线主机,尝试从 agent_reports 中获取最近 3 分钟活跃的主机名 + // 如果没有在线主机,使用 UTC 边界从 Agent 报告中查找最近 3 分钟活跃主机。 // Logan.Li h.db.Model(&model.AgentReport{}). - Where("report_time >= ?", time.Now().Add(-3*time.Minute)). + Where("report_time >= ?", time.Now().UTC().Add(-3*time.Minute)). Distinct("hostname"). Pluck("hostname", &onlineHosts) } @@ -180,11 +192,11 @@ func (h *AgentTaskHandler) QuickSetupRules(c *gin.Context) { } type ruleDef struct { - Name string - Metric string - Operator string + Name string + Metric string + Operator string Threshold float64 - Duration int + Duration int } defaultRules := []ruleDef{ diff --git a/backend/controller/aiops_alert_analysis.go b/backend/controller/aiops_alert_analysis.go index f076a5a..c1c65fd 100644 --- a/backend/controller/aiops_alert_analysis.go +++ b/backend/controller/aiops_alert_analysis.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log" "net/http" "strconv" "strings" @@ -399,9 +398,7 @@ func (h *AIOpsAlertAnalysisHandler) saveAnalysisTrace(ctx context.Context, in an Duration: in.Duration, Error: in.Error, } - if err := h.db.WithContext(ctx).Create(&row).Error; err != nil { - log.Printf("[AIOpsAnalysis] 保存告警分析结果失败 (alert_id=%d, provider=%s): %v", in.AlertID, in.Provider, err) - } + _ = h.db.WithContext(ctx).Create(&row).Error } func marshalTraceJSON(v interface{}) string { diff --git a/backend/controller/alert_channels.go b/backend/controller/alert_channels.go index 6785ba9..3516910 100644 --- a/backend/controller/alert_channels.go +++ b/backend/controller/alert_channels.go @@ -473,5 +473,3 @@ func (h *AlertHandler) NotifyRecovery(ruleName, hostname, metric, message string } } } - - diff --git a/backend/controller/alert_contacts.go b/backend/controller/alert_contacts.go index a0b79fd..a65fd12 100644 --- a/backend/controller/alert_contacts.go +++ b/backend/controller/alert_contacts.go @@ -60,11 +60,21 @@ func (h *AlertHandler) UpdateContact(c *gin.Context) { return } updates := map[string]interface{}{} - if req.Name != "" { updates["name"] = req.Name } - if req.Phone != "" { updates["phone"] = req.Phone } - if req.Email != "" { updates["email"] = req.Email } - if req.ChannelType != "" { updates["channel_type"] = req.ChannelType } - if req.WebhookURL != "" { updates["webhook_url"] = req.WebhookURL } + if req.Name != "" { + updates["name"] = req.Name + } + if req.Phone != "" { + updates["phone"] = req.Phone + } + if req.Email != "" { + updates["email"] = req.Email + } + if req.ChannelType != "" { + updates["channel_type"] = req.ChannelType + } + if req.WebhookURL != "" { + updates["webhook_url"] = req.WebhookURL + } h.db.Model(&contact).Updates(updates) h.db.First(&contact, id) c.JSON(http.StatusOK, model.Response{Code: 200, Message: "更新成功", Data: contact}) @@ -127,8 +137,12 @@ func (h *AlertHandler) UpdateContactGroup(c *gin.Context) { return } updates := map[string]interface{}{} - if req.GroupName != "" { updates["group_name"] = req.GroupName } - if req.Notes != "" { updates["notes"] = req.Notes } + if req.GroupName != "" { + updates["group_name"] = req.GroupName + } + if req.Notes != "" { + updates["notes"] = req.Notes + } if len(req.ContactIDs) > 0 { idsJSON, _ := json.Marshal(req.ContactIDs) updates["contact_ids"] = string(idsJSON) diff --git a/backend/controller/alert_engine.go b/backend/controller/alert_engine.go index 57866ec..6b71c81 100644 --- a/backend/controller/alert_engine.go +++ b/backend/controller/alert_engine.go @@ -227,7 +227,8 @@ func (e *AlertEngine) checkOfflineAlerts() { e.db.Select("ip, hostname").Group("ip").Find(&servers) // 收集所有 agent_reports 中活跃上报的主机名(最近 3 分钟内有上报) - threeMinAgo := time.Now().Add(-offlineThreshold) + // Agent 报告统一使用 UTC,避免 SQLite 时区文本比较导致误报离线。 // Logan.Li + threeMinAgo := time.Now().UTC().Add(-offlineThreshold) var activeHostnames []string e.db.Model(&model.AgentReport{}). diff --git a/backend/controller/alert_engine_monitor.go b/backend/controller/alert_engine_monitor.go index c8df9eb..de1b4d2 100644 --- a/backend/controller/alert_engine_monitor.go +++ b/backend/controller/alert_engine_monitor.go @@ -384,4 +384,4 @@ func isMiddlewareMetric(metric string) bool { } } return false -} \ No newline at end of file +} diff --git a/backend/controller/alert_engine_snmp.go b/backend/controller/alert_engine_snmp.go index 246b393..e1b0f00 100644 --- a/backend/controller/alert_engine_snmp.go +++ b/backend/controller/alert_engine_snmp.go @@ -181,4 +181,4 @@ func (e *AlertEngine) fireSNMPAlert(rule model.MonitorAlertRule, device model.SN // isSNMPMetric 判断 AgentAlert 是否属于 SNMP // Logan.Li func isSNMPMetric(metric string) bool { return len(metric) >= 5 && metric[:5] == "snmp_" -} \ No newline at end of file +} diff --git a/backend/controller/alert_metric_catalog.go b/backend/controller/alert_metric_catalog.go index 5192806..d4d500c 100644 --- a/backend/controller/alert_metric_catalog.go +++ b/backend/controller/alert_metric_catalog.go @@ -104,4 +104,4 @@ var metricCatalog = []MetricCatalogCategory{ // GET /api/v1/alerts/metric-catalog func (h *AlertHandler) GetMetricCatalog(c *gin.Context) { c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"categories": metricCatalog}}) -} \ No newline at end of file +} diff --git a/backend/controller/alert_notify_dispatch.go b/backend/controller/alert_notify_dispatch.go index 1ba16cf..c066f2a 100644 --- a/backend/controller/alert_notify_dispatch.go +++ b/backend/controller/alert_notify_dispatch.go @@ -178,4 +178,4 @@ func fallbackNotifyRecovery(db *gorm.DB, ruleName, hostname, metric, message str fmt.Printf("[AlertNotify] 飞书恢复回退失败 (渠道:%s): %v\n", ch.Name, err) } } -} \ No newline at end of file +} diff --git a/backend/controller/alert_notify_message.go b/backend/controller/alert_notify_message.go index be331dc..3b590fd 100644 --- a/backend/controller/alert_notify_message.go +++ b/backend/controller/alert_notify_message.go @@ -79,16 +79,16 @@ func notifyTemplateVars(ruleName, hostname, metric, severity, message string, va platform = defaultNotifyPlatformName } return map[string]string{ - "time": time.Now().Format("2006-01-02 15:04:05"), - "platform": platform, - "rule_name": ruleName, - "hostname": hostname, - "metric": metric, - "severity": severity, - "severity_label": sevLabel, - "message": message, - "value": fmt.Sprintf("%.2f", value), - "threshold": fmt.Sprintf("%.2f", threshold), + "time": time.Now().Format("2006-01-02 15:04:05"), + "platform": platform, + "rule_name": ruleName, + "hostname": hostname, + "metric": metric, + "severity": severity, + "severity_label": sevLabel, + "message": message, + "value": fmt.Sprintf("%.2f", value), + "threshold": fmt.Sprintf("%.2f", threshold), } } @@ -134,4 +134,4 @@ func buildAlertContent(ruleName, hostname, metric, severity, message string, val "**规则名称:** %s\\n**目标主机:** %s\\n**指标类型:** %s\\n**告警级别:** %s\\n**当前值:** %.2f\\n**阈值:** %.2f\\n**详细信息:** %s\\n**触发时间:** %s", ruleName, hostname, metric, severity, value, threshold, message, time.Now().Format("2006-01-02 15:04:05"), ) -} \ No newline at end of file +} diff --git a/backend/controller/alert_notify_policy.go b/backend/controller/alert_notify_policy.go index 3d119fd..d58970d 100644 --- a/backend/controller/alert_notify_policy.go +++ b/backend/controller/alert_notify_policy.go @@ -126,4 +126,4 @@ func isValidNotifyCategory(cat model.AlertNotifyCategory) bool { } } return false -} \ No newline at end of file +} diff --git a/backend/controller/alert_silence.go b/backend/controller/alert_silence.go index 58b8d88..b6aecf5 100644 --- a/backend/controller/alert_silence.go +++ b/backend/controller/alert_silence.go @@ -15,30 +15,30 @@ import ( // CreateSilenceRequest 创建静默规则请求 type CreateSilenceRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - RuleIDs []uint `json:"rule_ids"` - TargetIDs []uint `json:"target_ids"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + RuleIDs []uint `json:"rule_ids"` + TargetIDs []uint `json:"target_ids"` Matchers []model.AlertSilenceMatcher `json:"matchers"` - SilenceType string `json:"silence_type"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time" binding:"required"` - Weekdays []int `json:"weekdays"` - Status *int `json:"status"` + SilenceType string `json:"silence_type"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time" binding:"required"` + Weekdays []int `json:"weekdays"` + Status *int `json:"status"` } // UpdateSilenceRequest 更新静默规则请求 type UpdateSilenceRequest struct { - Name string `json:"name"` - Description string `json:"description"` - RuleIDs []uint `json:"rule_ids"` - TargetIDs []uint `json:"target_ids"` + Name string `json:"name"` + Description string `json:"description"` + RuleIDs []uint `json:"rule_ids"` + TargetIDs []uint `json:"target_ids"` Matchers []model.AlertSilenceMatcher `json:"matchers"` - SilenceType string `json:"silence_type"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - Weekdays []int `json:"weekdays"` - Status *int `json:"status"` + SilenceType string `json:"silence_type"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Weekdays []int `json:"weekdays"` + Status *int `json:"status"` } // ListSilences GET /alerts/silences @@ -60,11 +60,11 @@ func (h *AlertHandler) ListSilences(c *gin.Context) { // 解析 JSON 字段为前端友好的格式 type SilenceWithParsed struct { model.AlertSilence - RuleIDList []uint `json:"rule_id_list"` - TargetIDList []uint `json:"target_id_list"` - MatcherList []model.AlertSilenceMatcher `json:"matcher_list"` - WeekdayList []int `json:"weekday_list"` - IsActive bool `json:"is_active"` + RuleIDList []uint `json:"rule_id_list"` + TargetIDList []uint `json:"target_id_list"` + MatcherList []model.AlertSilenceMatcher `json:"matcher_list"` + WeekdayList []int `json:"weekday_list"` + IsActive bool `json:"is_active"` } var result []SilenceWithParsed diff --git a/backend/controller/alert_unified.go b/backend/controller/alert_unified.go index f205988..86af4bf 100644 --- a/backend/controller/alert_unified.go +++ b/backend/controller/alert_unified.go @@ -13,19 +13,19 @@ import ( // UnifiedAlertItem 统一告警事件视图 // Logan.Li type UnifiedAlertItem struct { - ID string `json:"id"` - SourceType string `json:"source_type"` - RuleName string `json:"rule_name"` - Title string `json:"title"` - Message string `json:"message"` - Severity string `json:"severity"` - Status string `json:"status"` - Target string `json:"target"` - Metric string `json:"metric"` - Value float64 `json:"value"` - Threshold float64 `json:"threshold"` - StartedAt string `json:"started_at"` - ResolvedAt *string `json:"resolved_at,omitempty"` + ID string `json:"id"` + SourceType string `json:"source_type"` + RuleName string `json:"rule_name"` + Title string `json:"title"` + Message string `json:"message"` + Severity string `json:"severity"` + Status string `json:"status"` + Target string `json:"target"` + Metric string `json:"metric"` + Value float64 `json:"value"` + Threshold float64 `json:"threshold"` + StartedAt string `json:"started_at"` + ResolvedAt *string `json:"resolved_at,omitempty"` } // ListUnifiedAlerts 统一告警列表(主机 + 中间件 + 站点 + 日志) @@ -212,4 +212,4 @@ func countSeverityStatus(items []UnifiedAlertItem, sev, status string) int { } } return n -} \ No newline at end of file +} diff --git a/backend/controller/approval_process.go b/backend/controller/approval_process.go index 0606339..04d067a 100644 --- a/backend/controller/approval_process.go +++ b/backend/controller/approval_process.go @@ -114,8 +114,8 @@ func (h *ApprovalHandler) CreateProcess(c *gin.Context) { var workflowSteps []model.ApprovalWorkflowStep stepCount := 1 var approvalSteps []struct { - name string - stepType string + name string + stepType string approverIDs []uint } if workflow.ID > 0 { @@ -126,8 +126,8 @@ func (h *ApprovalHandler) CreateProcess(c *gin.Context) { } stepCount++ approvalSteps = append(approvalSteps, struct { - name string - stepType string + name string + stepType string approverIDs []uint }{ws.Name, ws.StepType, ParseApproverIDs(ws.ApproverIDs)}) } diff --git a/backend/controller/approval_workflow.go b/backend/controller/approval_workflow.go index 6c905c4..1ca38ec 100644 --- a/backend/controller/approval_workflow.go +++ b/backend/controller/approval_workflow.go @@ -111,14 +111,14 @@ func (h *WorkflowHandler) CreateWorkflow(c *gin.Context) { for _, s := range req.Steps { step := model.ApprovalWorkflowStep{ - WorkflowID: workflow.ID, - StepNumber: s.StepNumber, - Name: s.Name, - StepType: s.StepType, - ApproverIDs: s.ApproverIDs, - IsRequired: s.IsRequired, - TimeoutHours: s.TimeoutHours, - AutoApprove: s.AutoApproveTimeout, + WorkflowID: workflow.ID, + StepNumber: s.StepNumber, + Name: s.Name, + StepType: s.StepType, + ApproverIDs: s.ApproverIDs, + IsRequired: s.IsRequired, + TimeoutHours: s.TimeoutHours, + AutoApprove: s.AutoApproveTimeout, } if step.TimeoutHours == 0 { step.TimeoutHours = 24 @@ -195,14 +195,14 @@ func (h *WorkflowHandler) UpdateWorkflow(c *gin.Context) { tx.Where("workflow_id = ?", workflow.ID).Delete(&model.ApprovalWorkflowStep{}) for _, s := range req.Steps { step := model.ApprovalWorkflowStep{ - WorkflowID: workflow.ID, - StepNumber: s.StepNumber, - Name: s.Name, - StepType: s.StepType, - ApproverIDs: s.ApproverIDs, - IsRequired: s.IsRequired, - TimeoutHours: s.TimeoutHours, - AutoApprove: s.AutoApproveTimeout, + WorkflowID: workflow.ID, + StepNumber: s.StepNumber, + Name: s.Name, + StepType: s.StepType, + ApproverIDs: s.ApproverIDs, + IsRequired: s.IsRequired, + TimeoutHours: s.TimeoutHours, + AutoApprove: s.AutoApproveTimeout, } if step.TimeoutHours == 0 { step.TimeoutHours = 24 diff --git a/backend/controller/billing.go b/backend/controller/billing.go index e60be72..20f6009 100644 --- a/backend/controller/billing.go +++ b/backend/controller/billing.go @@ -4,8 +4,12 @@ import ( "fmt" "net/http" "sort" + "strings" + "sync" "time" + "ezops/config" + "ezops/middleware" "ezops/model" "github.com/gin-gonic/gin" @@ -14,11 +18,126 @@ import ( // BillingHandler 云账单处理器 type BillingHandler struct { - db *gorm.DB + db *gorm.DB + billing *config.BillingConfig + rateMu sync.RWMutex } -func NewBillingHandler(db *gorm.DB) *BillingHandler { - return &BillingHandler{db: db} +func NewBillingHandler(db *gorm.DB, configs ...*config.BillingConfig) *BillingHandler { + billingConfig := &config.BillingConfig{BaseCurrency: "CNY", ExchangeRates: map[string]float64{"CNY": 1, "USD": 7.2}} + if len(configs) > 0 && configs[0] != nil { + billingConfig = &config.BillingConfig{BaseCurrency: strings.ToUpper(configs[0].BaseCurrency), ExchangeRates: map[string]float64{}} + for currency, rate := range configs[0].ExchangeRates { + billingConfig.ExchangeRates[strings.ToUpper(currency)] = rate + } + } + if billingConfig.BaseCurrency == "" { + billingConfig.BaseCurrency = "CNY" + } + if len(billingConfig.ExchangeRates) == 0 { + billingConfig.ExchangeRates = map[string]float64{"CNY": 1, "USD": 7.2} + } + // 已完成迁移的环境优先使用页面保存的汇率。 // Logan.Li + if db != nil && db.Migrator().HasTable(&model.BillingSettings{}) { + var settings model.BillingSettings + if db.Take(&settings).Error == nil && settings.USDCNYRate > 0 { + billingConfig.ExchangeRates["USD"] = settings.USDCNYRate + } + } + return &BillingHandler{db: db, billing: billingConfig} +} + +// GetBillingSettings 获取云账单换算设置。 // Logan.Li +func (h *BillingHandler) GetBillingSettings(c *gin.Context) { + h.rateMu.RLock() + rate := h.billing.ExchangeRates["USD"] + h.rateMu.RUnlock() + var settings model.BillingSettings + if h.db.Migrator().HasTable(&model.BillingSettings{}) && h.db.Take(&settings).Error == nil && settings.USDCNYRate > 0 { + rate = settings.USDCNYRate + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: gin.H{ + "base_currency": "CNY", "usd_cny_rate": rate, "updated_at": settings.UpdatedAt, "updated_by": settings.UpdatedBy, + }}) +} + +// SaveBillingSettings 保存 USD/CNY 汇率并立即应用。 // Logan.Li +func (h *BillingHandler) SaveBillingSettings(c *gin.Context) { + var req struct { + USDCNYRate float64 `json:"usd_cny_rate" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.USDCNYRate < 0.1 || req.USDCNYRate > 100 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "USD/CNY 汇率必须在 0.1 到 100 之间"}) + return + } + if !h.db.Migrator().HasTable(&model.BillingSettings{}) { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "云账单设置表尚未迁移"}) + return + } + var settings model.BillingSettings + err := h.db.Take(&settings).Error + settings.USDCNYRate = mathRound(req.USDCNYRate, 6) + settings.UpdatedBy = middleware.GetUsername(c) + if err == gorm.ErrRecordNotFound { + err = h.db.Create(&settings).Error + } else if err == nil { + err = h.db.Save(&settings).Error + } + if err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存汇率失败"}) + return + } + h.rateMu.Lock() + h.billing.ExchangeRates["CNY"] = 1 + h.billing.ExchangeRates["USD"] = settings.USDCNYRate + h.rateMu.Unlock() + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "汇率已保存", Data: gin.H{ + "base_currency": "CNY", "usd_cny_rate": settings.USDCNYRate, "updated_at": settings.UpdatedAt, "updated_by": settings.UpdatedBy, + }}) +} + +// currencyExpression 返回将原始费用折算为目标报告币种的 SQL 表达式及参数。 // Logan.Li +func (h *BillingHandler) currencyExpression(target string) (string, []interface{}, bool) { + h.rateMu.RLock() + defer h.rateMu.RUnlock() + target = strings.ToUpper(target) + targetRate := h.billing.ExchangeRates[target] + if targetRate <= 0 { + return "", nil, false + } + parts := make([]string, 0, len(h.billing.ExchangeRates)) + args := make([]interface{}, 0, len(h.billing.ExchangeRates)) + for currency, rate := range h.billing.ExchangeRates { + if rate <= 0 { + continue + } + parts = append(parts, "WHEN '"+strings.ToUpper(currency)+"' THEN cost * ?") + args = append(args, rate/targetRate) + } + return "CASE UPPER(currency) " + strings.Join(parts, " ") + " ELSE 0 END", args, true +} + +// reportCurrencyMetadata 返回前端展示和审计换算口径所需的元数据。 // Logan.Li +func (h *BillingHandler) reportCurrencyMetadata(target string) gin.H { + h.rateMu.RLock() + defer h.rateMu.RUnlock() + rates := map[string]float64{} + targetRate := h.billing.ExchangeRates[strings.ToUpper(target)] + for currency, rate := range h.billing.ExchangeRates { + if rate > 0 && targetRate > 0 { + rates[strings.ToUpper(currency)] = mathRound(rate/targetRate, 6) + } + } + return gin.H{"currency": strings.ToUpper(target), "exchange_rates": rates, "rate_source": "configured"} +} + +// mathRound 按指定小数位返回稳定的汇率展示值。 // Logan.Li +func mathRound(value float64, precision int) float64 { + factor := 1.0 + for i := 0; i < precision; i++ { + factor *= 10 + } + return float64(int64(value*factor+0.5)) / factor } // ListBillings 获取账单列表 @@ -37,9 +156,6 @@ func (h *BillingHandler) ListBillings(c *gin.Context) { if accountID := c.Query("cloud_account_id"); accountID != "" { query = query.Where("cloud_account_id = ?", accountID) } - if currency := c.Query("currency"); currency != "" { - query = query.Where("currency = ?", currency) - } if startDate := c.Query("start_date"); startDate != "" { query = query.Where("billing_date >= ?", startDate) } @@ -102,6 +218,7 @@ func (h *BillingHandler) CreateBilling(c *gin.Context) { if billing.Currency == "" { billing.Currency = "CNY" } + billing.Currency = strings.ToUpper(billing.Currency) if err := h.db.Create(&billing).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败: " + err.Error()}) @@ -141,6 +258,7 @@ func (h *BillingHandler) BatchCreateBillings(c *gin.Context) { if billing.Currency == "" { billing.Currency = "CNY" } + billing.Currency = strings.ToUpper(billing.Currency) if err := h.db.Create(&billing).Error; err == nil { created++ } @@ -166,50 +284,62 @@ func (h *BillingHandler) DeleteBilling(c *gin.Context) { // GetBillingStats 获取账单统计 func (h *BillingHandler) GetBillingStats(c *gin.Context) { now := time.Now() + reportCurrency := strings.ToUpper(c.DefaultQuery("currency", h.billing.BaseCurrency)) + costExpression, costArgs, ok := h.currencyExpression(reportCurrency) + if !ok { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "不支持的报告币种"}) + return + } today := now.Format("2006-01-02") yesterday := now.AddDate(0, 0, -1).Format("2006-01-02") monthStart := now.Format("2006-01") + "-01" yearStart := fmt.Sprintf("%d-01-01", now.Year()) - currency := c.DefaultQuery("currency", "CNY") stats := model.BillingStats{ - Currency: currency, - MonthTrend: []model.DailyCost{}, - ProviderDist: []model.ProviderCost{}, - Alerts: []model.BillingAlert{}, + MonthTrend: []model.DailyCost{}, + ProviderDist: []model.ProviderCost{}, + ProviderTrend: []model.ProviderDailyCost{}, + TopServices: []model.ServiceCost{}, + Alerts: []model.BillingAlert{}, + Currency: reportCurrency, + ExchangeRates: h.reportCurrencyMetadata(reportCurrency)["exchange_rates"].(map[string]float64), + RateSource: "configured", } - // 今日成本 - h.db.Model(&model.CloudBilling{}).Where("billing_date = ? AND currency = ?", today, currency). - Select("COALESCE(SUM(cost), 0)").Scan(&stats.TodayCost) + // 所有统计统一按报告币种折算,避免不同原币种直接相加。 // Logan.Li + h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", today). + Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.TodayCost) // 昨日成本 - h.db.Model(&model.CloudBilling{}).Where("billing_date = ? AND currency = ?", yesterday, currency). - Select("COALESCE(SUM(cost), 0)").Scan(&stats.YesterdayCost) + h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", yesterday). + Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.YesterdayCost) // 本月成本 - h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", monthStart, currency). - Select("COALESCE(SUM(cost), 0)").Scan(&stats.MonthCost) + h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", monthStart). + Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.MonthCost) // 本年成本 - h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", yearStart, currency). - Select("COALESCE(SUM(cost), 0)").Scan(&stats.YearCost) + h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", yearStart). + Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.YearCost) - // 近30天趋势 + // 本月日趋势与厂商日趋势来自同一聚合口径,供成本预测直接复用。 // Logan.Li type dayCost struct { BillingDate string Total float64 } var trends []dayCost h.db.Model(&model.CloudBilling{}). - Select("billing_date, SUM(cost) as total"). - Where("billing_date >= ? AND currency = ?", now.AddDate(0, 0, -29).Format("2006-01-02"), currency). + Select("billing_date, SUM("+costExpression+") as total", costArgs...). + Where("billing_date >= ?", monthStart). Group("billing_date"). Order("billing_date ASC"). Scan(&trends) for _, t := range trends { stats.MonthTrend = append(stats.MonthTrend, model.DailyCost{Date: t.BillingDate, Cost: t.Total}) + if t.BillingDate > stats.DataThrough { + stats.DataThrough = t.BillingDate + } } // 本月厂商成本分布 @@ -219,8 +349,8 @@ func (h *BillingHandler) GetBillingStats(c *gin.Context) { } var provCosts []provCost h.db.Model(&model.CloudBilling{}). - Select("provider, SUM(cost) as total"). - Where("billing_date >= ? AND currency = ?", monthStart, currency). + Select("provider, SUM("+costExpression+") as total", costArgs...). + Where("billing_date >= ?", monthStart). Group("provider"). Scan(&provCosts) @@ -243,8 +373,39 @@ func (h *BillingHandler) GetBillingStats(c *gin.Context) { return stats.ProviderDist[i].Cost > stats.ProviderDist[j].Cost }) + type providerDayCost struct { + Provider string + BillingDate string + Total float64 + } + var providerTrends []providerDayCost + h.db.Model(&model.CloudBilling{}). + Select("provider, billing_date, SUM("+costExpression+") as total", costArgs...). + Where("billing_date >= ?", monthStart). + Group("provider, billing_date").Order("billing_date ASC").Scan(&providerTrends) + for _, item := range providerTrends { + stats.ProviderTrend = append(stats.ProviderTrend, model.ProviderDailyCost{Provider: item.Provider, Date: item.BillingDate, Cost: item.Total}) + } + + type serviceCost struct { + Service string + Total float64 + } + var services []serviceCost + h.db.Model(&model.CloudBilling{}). + Select("service, SUM("+costExpression+") as total", costArgs...). + Where("billing_date >= ?", monthStart). + Group("service").Order("total DESC").Limit(5).Scan(&services) + for _, item := range services { + name := item.Service + if name == "" { + name = "未分类" + } + stats.TopServices = append(stats.TopServices, model.ServiceCost{Service: name, Cost: item.Total}) + } + // 检查预警规则 - h.checkBillingAlerts(&stats, now) + h.checkBillingAlerts(&stats, now, costExpression, costArgs) c.JSON(http.StatusOK, model.Response{ Code: 200, @@ -254,7 +415,7 @@ func (h *BillingHandler) GetBillingStats(c *gin.Context) { } // checkBillingAlerts 检查预警规则是否触发 -func (h *BillingHandler) checkBillingAlerts(stats *model.BillingStats, now time.Time) { +func (h *BillingHandler) checkBillingAlerts(stats *model.BillingStats, now time.Time, costExpression string, costArgs []interface{}) { var rules []model.BillingAlertRule h.db.Where("enabled = ?", true).Find(&rules) @@ -263,24 +424,24 @@ func (h *BillingHandler) checkBillingAlerts(stats *model.BillingStats, now time. if rule.Metric == "daily" { // 查今日该厂商/账号的成本 - q := h.db.Model(&model.CloudBilling{}).Where("billing_date = ? AND currency = ?", now.Format("2006-01-02"), stats.Currency) + q := h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", now.Format("2006-01-02")) if rule.Provider != "" { q = q.Where("provider = ?", rule.Provider) } if rule.CloudAccountID != nil { q = q.Where("cloud_account_id = ?", *rule.CloudAccountID) } - q.Select("COALESCE(SUM(cost), 0)").Scan(¤t) + q.Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(¤t) } else { // monthly - 本月累计 - q := h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", now.Format("2006-01")+"-01", stats.Currency) + q := h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", now.Format("2006-01")+"-01") if rule.Provider != "" { q = q.Where("provider = ?", rule.Provider) } if rule.CloudAccountID != nil { q = q.Where("cloud_account_id = ?", *rule.CloudAccountID) } - q.Select("COALESCE(SUM(cost), 0)").Scan(¤t) + q.Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(¤t) } // 检查阈值 diff --git a/backend/controller/billing_analysis.go b/backend/controller/billing_analysis.go index 86fd759..f4e62b2 100644 --- a/backend/controller/billing_analysis.go +++ b/backend/controller/billing_analysis.go @@ -1,10 +1,10 @@ package controller import ( - "fmt" "math" "net/http" "sort" + "strings" "time" "ezops/model" @@ -12,133 +12,208 @@ import ( "github.com/gin-gonic/gin" ) -// billingMonthCost 表示月度费用汇总。 // Logan.Li -type billingMonthCost struct { - Month string `json:"month"` - Cost float64 `json:"cost"` +// billingAnalysisItem 表示单月一个费用分类的汇总值。 // Logan.Li +type billingAnalysisItem struct { + Name string `json:"name"` + Cost float64 `json:"cost"` + Percent float64 `json:"percent"` } -// billingAttribution 表示可复核的费用变化归因。 // Logan.Li +// billingAnalysisMonth 表示堆叠图和明细矩阵共用的月度数据。 // Logan.Li +type billingAnalysisMonth struct { + Month string `json:"month"` + Total float64 `json:"total"` + Items []billingAnalysisItem `json:"items"` +} + +// billingAttribution 表示最近两个月可复核的费用变化归因。 // Logan.Li type billingAttribution struct { - Dimension string `json:"dimension"` Name string `json:"name"` - BaselineCost float64 `json:"baseline_cost"` + PreviousCost float64 `json:"previous_cost"` CurrentCost float64 `json:"current_cost"` Delta float64 `json:"delta"` Contribution float64 `json:"contribution"` } -// GetBillingCostAnalysis 返回月度趋势和同期费用归因,AI 只负责解释确定性结果。 // Logan.Li +// GetBillingCostAnalysis 返回多云费用堆叠趋势及同期归因。 // Logan.Li func (h *BillingHandler) GetBillingCostAnalysis(c *gin.Context) { now := time.Now() - currentStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - baselineStart := currentStart.AddDate(0, -1, 0) - baselineEndDay := now.Day() - baselineLastDay := baselineStart.AddDate(0, 1, -1).Day() - if baselineEndDay > baselineLastDay { - baselineEndDay = baselineLastDay + endMonth := c.DefaultQuery("end_month", now.Format("2006-01")) + startMonth := c.DefaultQuery("start_month", now.AddDate(0, -5, 0).Format("2006-01")) + currency := strings.ToUpper(c.DefaultQuery("currency", h.billing.BaseCurrency)) + costExpression, costArgs, currencyOK := h.currencyExpression(currency) + if !currencyOK { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "不支持的报告币种"}) + return + } + dimension := c.DefaultQuery("dimension", "service") + dimensionColumns := map[string]string{ + "service": "service", "provider": "provider", "account": "account_name", "region": "region", + } + column, ok := dimensionColumns[dimension] + if !ok { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "不支持的分析维度"}) + return + } + start, startErr := time.ParseInLocation("2006-01", startMonth, now.Location()) + end, endErr := time.ParseInLocation("2006-01", endMonth, now.Location()) + if startErr != nil || endErr != nil || start.After(end) || end.After(start.AddDate(0, 23, 0)) { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "月份范围无效,最多查询 24 个月"}) + return } - baselineEnd := time.Date(baselineStart.Year(), baselineStart.Month(), baselineEndDay, 23, 59, 59, 0, now.Location()) - analysisStart := currentStart.AddDate(0, -5, 0) - currency := c.DefaultQuery("currency", "CNY") - query := h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", analysisStart.Format("2006-01-02"), currency) - type monthRow struct { + type aggregateRow struct { Month string + Name string Cost float64 } - var monthRows []monthRow - query.Select("SUBSTR(billing_date, 1, 7) AS month, COALESCE(SUM(cost), 0) AS cost"). - Group("SUBSTR(billing_date, 1, 7)").Order("month ASC").Scan(&monthRows) - months := make([]billingMonthCost, 0, len(monthRows)) - for _, row := range monthRows { - months = append(months, billingMonthCost{Month: row.Month, Cost: roundBilling(row.Cost)}) + var rows []aggregateRow + queryEnd := end.AddDate(0, 1, 0) + query := h.db.Model(&model.CloudBilling{}). + Select("SUBSTR(billing_date, 1, 7) AS month, "+column+" AS name, COALESCE(SUM("+costExpression+"), 0) AS cost", costArgs...). + Where("billing_date >= ? AND billing_date < ?", start.Format("2006-01-02"), queryEnd.Format("2006-01-02")) + if provider := c.Query("provider"); provider != "" { + query = query.Where("provider = ?", provider) + } + if accountID := c.Query("cloud_account_id"); accountID != "" { + query = query.Where("cloud_account_id = ?", accountID) + } + if err := query.Group("SUBSTR(billing_date, 1, 7), " + column).Scan(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "费用分析查询失败"}) + return } - baselineStartDate := baselineStart.Format("2006-01-02") - baselineEndDate := baselineEnd.Format("2006-01-02") - currentStartDate := currentStart.Format("2006-01-02") - currentEndDate := now.Format("2006-01-02") - baselineCost := h.sumBillingPeriod(baselineStartDate, baselineEndDate, currency) - currentCost := h.sumBillingPeriod(currentStartDate, currentEndDate, currency) - totalDelta := currentCost - baselineCost + monthCosts := map[string]map[string]float64{} + categoryTotals := map[string]float64{} + for _, row := range rows { + name := row.Name + if name == "" { + name = "未分类" + } + if monthCosts[row.Month] == nil { + monthCosts[row.Month] = map[string]float64{} + } + monthCosts[row.Month][name] += row.Cost + categoryTotals[name] += row.Cost + } + categories := topBillingCategories(categoryTotals, 8) + months := buildBillingAnalysisMonths(start, end, monthCosts, categories) - attributions := make([]billingAttribution, 0, 24) - for _, dimension := range []string{"provider", "account_name", "service", "region"} { - attributions = append(attributions, h.billingDimensionAttribution( - dimension, baselineStartDate, baselineEndDate, currentStartDate, currentEndDate, currency, totalDelta, - )...) + totalCost := 0.0 + peakCost := 0.0 + peakMonth := "" + for _, month := range months { + totalCost += month.Total + if month.Total > peakCost { + peakCost = month.Total + peakMonth = month.Month + } } - sort.Slice(attributions, func(i, j int) bool { - return math.Abs(attributions[i].Delta) > math.Abs(attributions[j].Delta) - }) - if len(attributions) > 20 { - attributions = attributions[:20] + averageCost := 0.0 + if len(months) > 0 { + averageCost = totalCost / float64(len(months)) } + previousCost, currentCost := 0.0, 0.0 + if len(months) >= 2 { + previousCost = months[len(months)-2].Total + currentCost = months[len(months)-1].Total + } else if len(months) == 1 { + currentCost = months[0].Total + } + momPercent := 0.0 + if previousCost != 0 { + momPercent = (currentCost - previousCost) / previousCost * 100 + } + attributions := buildBillingAttributions(monthCosts[end.AddDate(0, -1, 0).Format("2006-01")], monthCosts[endMonth]) var dataThrough string - h.db.Model(&model.CloudBilling{}).Where("currency = ?", currency).Select("COALESCE(MAX(billing_date), '')").Scan(&dataThrough) + dataThroughQuery := h.db.Model(&model.CloudBilling{}) + if provider := c.Query("provider"); provider != "" { + dataThroughQuery = dataThroughQuery.Where("provider = ?", provider) + } + if accountID := c.Query("cloud_account_id"); accountID != "" { + dataThroughQuery = dataThroughQuery.Where("cloud_account_id = ?", accountID) + } + dataThroughQuery.Select("COALESCE(MAX(billing_date), '')").Scan(&dataThrough) + incompletePeriod := endMonth == now.Format("2006-01") && dataThrough < end.AddDate(0, 1, -1).Format("2006-01-02") + metadata := h.reportCurrencyMetadata(currency) c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: gin.H{ - "analysis_start": analysisStart.Format("2006-01-02"), - "analysis_end": currentEndDate, - "data_through": dataThrough, - "currency": currency, - "incomplete_period": true, - "months": months, - "baseline_period": fmt.Sprintf("%s - %s", baselineStartDate, baselineEndDate), - "comparison_period": fmt.Sprintf("%s - %s", currentStartDate, currentEndDate), - "baseline_cost": roundBilling(baselineCost), - "comparison_cost": roundBilling(currentCost), - "total_delta": roundBilling(totalDelta), - "attributions": attributions, - "contribution_formula": "(当前期费用 - 基准期费用) / (当前期总费用 - 基准期总费用)", + "start_month": startMonth, "end_month": endMonth, "currency": currency, "dimension": dimension, + "exchange_rates": metadata["exchange_rates"], "rate_source": metadata["rate_source"], + "data_through": dataThrough, "incomplete_period": incompletePeriod, "categories": categories, "months": months, + "summary": gin.H{"total_cost": roundBilling(totalCost), "average_cost": roundBilling(averageCost), "peak_cost": roundBilling(peakCost), "peak_month": peakMonth, "mom_percent": roundBilling(momPercent)}, + "attributions": attributions, }}) } -// sumBillingPeriod 汇总指定币种和日期范围费用。 // Logan.Li -func (h *BillingHandler) sumBillingPeriod(startDate, endDate, currency string) float64 { - var total float64 - h.db.Model(&model.CloudBilling{}). - Where("billing_date BETWEEN ? AND ? AND currency = ?", startDate, endDate, currency). - Select("COALESCE(SUM(cost), 0)").Scan(&total) - return total +// topBillingCategories 按总费用选择 Top N 分类,其余合并为“其他”。 // Logan.Li +func topBillingCategories(totals map[string]float64, limit int) []string { + names := make([]string, 0, len(totals)) + for name := range totals { + names = append(names, name) + } + sort.Slice(names, func(i, j int) bool { return totals[names[i]] > totals[names[j]] }) + if len(names) > limit { + return append(names[:limit], "其他") + } + return names } -// billingDimensionAttribution 计算单一维度的同期费用变化贡献。 // Logan.Li -func (h *BillingHandler) billingDimensionAttribution(dimension, baselineStart, baselineEnd, currentStart, currentEnd, currency string, totalDelta float64) []billingAttribution { - type dimensionRow struct { - Name string - Cost float64 - } - load := func(startDate, endDate string) []dimensionRow { - var rows []dimensionRow - h.db.Model(&model.CloudBilling{}). - Select(dimension+" AS name, COALESCE(SUM(cost), 0) AS cost"). - Where("billing_date BETWEEN ? AND ? AND currency = ?", startDate, endDate, currency). - Group(dimension).Scan(&rows) - return rows - } - baseline := map[string]float64{} - current := map[string]float64{} - for _, row := range load(baselineStart, baselineEnd) { - baseline[row.Name] = row.Cost - } - for _, row := range load(currentStart, currentEnd) { - current[row.Name] = row.Cost - } - for name := range baseline { - if _, ok := current[name]; !ok { - current[name] = 0 +// buildBillingAnalysisMonths 补齐空月份并将非 Top 分类合并。 // Logan.Li +func buildBillingAnalysisMonths(start, end time.Time, costs map[string]map[string]float64, categories []string) []billingAnalysisMonth { + top := map[string]bool{} + for _, name := range categories { + if name != "其他" { + top[name] = true } } - result := make([]billingAttribution, 0, len(current)) - for rawName, currentCost := range current { + months := make([]billingAnalysisMonth, 0) + for cursor := start; !cursor.After(end); cursor = cursor.AddDate(0, 1, 0) { + month := cursor.Format("2006-01") + grouped := map[string]float64{} + total := 0.0 + for name, cost := range costs[month] { + key := name + if !top[name] { + key = "其他" + } + grouped[key] += cost + total += cost + } + items := make([]billingAnalysisItem, 0, len(categories)) + for _, name := range categories { + cost := grouped[name] + percent := 0.0 + if total != 0 { + percent = cost / total * 100 + } + items = append(items, billingAnalysisItem{Name: name, Cost: roundBilling(cost), Percent: roundBilling(percent)}) + } + months = append(months, billingAnalysisMonth{Month: month, Total: roundBilling(total), Items: items}) + } + return months +} + +// buildBillingAttributions 计算最近两个月各分类对总变化的贡献度。 // Logan.Li +func buildBillingAttributions(previous, current map[string]float64) []billingAttribution { + names := map[string]bool{} + previousTotal, currentTotal := 0.0, 0.0 + for name, cost := range previous { + names[name] = true + previousTotal += cost + } + for name, cost := range current { + names[name] = true + currentTotal += cost + } + totalDelta := currentTotal - previousTotal + items := make([]billingAttribution, 0, len(names)) + for rawName := range names { name := rawName - if rawName == "" { + if name == "" { name = "未分类" } - baselineCost := baseline[rawName] - delta := currentCost - baselineCost + delta := current[rawName] - previous[rawName] if math.Abs(delta) < 0.01 { continue } @@ -146,12 +221,13 @@ func (h *BillingHandler) billingDimensionAttribution(dimension, baselineStart, b if math.Abs(totalDelta) >= 0.01 { contribution = delta / totalDelta * 100 } - result = append(result, billingAttribution{ - Dimension: dimension, Name: name, BaselineCost: roundBilling(baselineCost), - CurrentCost: roundBilling(currentCost), Delta: roundBilling(delta), Contribution: roundBilling(contribution), - }) + items = append(items, billingAttribution{Name: name, PreviousCost: roundBilling(previous[rawName]), CurrentCost: roundBilling(current[rawName]), Delta: roundBilling(delta), Contribution: roundBilling(contribution)}) } - return result + sort.Slice(items, func(i, j int) bool { return math.Abs(items[i].Delta) > math.Abs(items[j].Delta) }) + if len(items) > 10 { + items = items[:10] + } + return items } // roundBilling 统一费用和百分比精度。 // Logan.Li diff --git a/backend/controller/billing_analysis_test.go b/backend/controller/billing_analysis_test.go index f441798..054c04a 100644 --- a/backend/controller/billing_analysis_test.go +++ b/backend/controller/billing_analysis_test.go @@ -1,45 +1,102 @@ package controller import ( + "encoding/json" + "net/http/httptest" "testing" + "time" + "ezops/config" "ezops/model" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "gorm.io/gorm" ) -// TestBillingDimensionAttribution 验证费用变化额和贡献度使用数据库完整数据计算。 // Logan.Li -func TestBillingDimensionAttribution(t *testing.T) { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("打开测试数据库失败: %v", err) +// TestCurrencyExpression 验证 CNY 与 USD 报告币种使用同一组可逆汇率。 // Logan.Li +func TestCurrencyExpression(t *testing.T) { + handler := NewBillingHandler(nil, &config.BillingConfig{ + BaseCurrency: "CNY", + ExchangeRates: map[string]float64{"cny": 1, "usd": 7.2}, + }) + _, cnyArgs, ok := handler.currencyExpression("CNY") + if !ok || len(cnyArgs) != 2 { + t.Fatalf("CNY 汇率表达式无效: ok=%v args=%v", ok, cnyArgs) } - if err = db.AutoMigrate(&model.CloudBilling{}); err != nil { - t.Fatalf("迁移账单表失败: %v", err) + metadata := handler.reportCurrencyMetadata("USD") + rates := metadata["exchange_rates"].(map[string]float64) + if rates["USD"] != 1 || rates["CNY"] != 0.138889 { + t.Fatalf("USD 折算汇率错误: %+v", rates) } + if _, _, supported := handler.currencyExpression("EUR"); supported { + t.Fatal("未配置币种不应被接受") + } +} + +// TestBillingCurrencyConversionAPI 验证统计接口不会直接混加不同原币金额。 // Logan.Li +func TestBillingCurrencyConversionAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.CloudBilling{}, &model.BillingAlertRule{}); err != nil { + t.Fatal(err) + } + today := time.Now().Format("2006-01-02") records := []model.CloudBilling{ - {BillingDate: "2026-06-01", Provider: "tencent", Service: "CVM", Region: "广州", AccountName: "预发", Currency: "CNY", Cost: 100}, - {BillingDate: "2026-07-01", Provider: "tencent", Service: "CVM", Region: "广州", AccountName: "预发", Currency: "CNY", Cost: 180}, - {BillingDate: "2026-07-01", Provider: "tencent", Service: "COS", Region: "广州", AccountName: "预发", Currency: "CNY", Cost: 20}, + {Provider: "tencent", BillingDate: today, Cost: 100, Currency: "CNY", Service: "CVM"}, + {Provider: "aws", BillingDate: today, Cost: 10, Currency: "USD", Service: "EC2"}, } - if err = db.Create(&records).Error; err != nil { - t.Fatalf("写入测试账单失败: %v", err) + if err := db.Create(&records).Error; err != nil { + t.Fatal(err) } + handler := NewBillingHandler(db, &config.BillingConfig{BaseCurrency: "CNY", ExchangeRates: map[string]float64{"CNY": 1, "USD": 7.2}}) - handler := NewBillingHandler(db) - items := handler.billingDimensionAttribution("service", "2026-06-01", "2026-06-30", "2026-07-01", "2026-07-31", "CNY", 100) - if len(items) != 2 { - t.Fatalf("归因项数量错误: got=%d want=2", len(items)) + assertMonthCost := func(currency string, expected float64) { + t.Helper() + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest("GET", "/?currency="+currency, nil) + handler.GetBillingStats(context) + var response struct { + Code int `json:"code"` + Data struct { + MonthCost float64 `json:"month_cost"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Code != 200 || mathRound(response.Data.MonthCost, 2) != expected { + t.Fatalf("%s 月费用错误: code=%d cost=%f", currency, response.Code, response.Data.MonthCost) + } } - result := map[string]billingAttribution{} - for _, item := range items { - result[item.Name] = item + assertMonthCost("CNY", 172) + assertMonthCost("USD", 23.89) +} + +// TestBuildBillingAnalysisMonths 验证空月份补齐和非 Top 分类合并。 // Logan.Li +func TestBuildBillingAnalysisMonths(t *testing.T) { + start, _ := time.Parse("2006-01", "2026-03") + end, _ := time.Parse("2006-01", "2026-04") + costs := map[string]map[string]float64{ + "2026-03": {"CVM": 100, "TCR": 20}, + } + months := buildBillingAnalysisMonths(start, end, costs, []string{"CVM", "其他"}) + if len(months) != 2 || months[0].Total != 120 || months[0].Items[1].Cost != 20 { + t.Fatalf("月度聚合错误: %+v", months) } - if result["CVM"].Delta != 80 || result["CVM"].Contribution != 80 { - t.Fatalf("CVM 归因错误: %+v", result["CVM"]) + if months[1].Month != "2026-04" || months[1].Total != 0 { + t.Fatalf("空月份未正确补齐: %+v", months[1]) } - if result["COS"].Delta != 20 || result["COS"].Contribution != 20 { - t.Fatalf("COS 归因错误: %+v", result["COS"]) +} + +// TestBuildBillingAttributions 验证归因变化额与贡献度。 // Logan.Li +func TestBuildBillingAttributions(t *testing.T) { + items := buildBillingAttributions(map[string]float64{"CVM": 100}, map[string]float64{"CVM": 180, "TCR": 20}) + if len(items) != 2 || items[0].Name != "CVM" || items[0].Delta != 80 || items[0].Contribution != 80 { + t.Fatalf("费用归因错误: %+v", items) } } diff --git a/backend/controller/billing_sync.go b/backend/controller/billing_sync.go index 2a8ad74..3385e17 100644 --- a/backend/controller/billing_sync.go +++ b/backend/controller/billing_sync.go @@ -82,4 +82,4 @@ func (h *BillingHandler) SyncAccountBilling(c *gin.Context) { } c.JSON(http.StatusOK, model.Response{Code: 200, Message: msg, Data: result}) -} \ No newline at end of file +} diff --git a/backend/controller/build_node.go b/backend/controller/build_node.go index 247c3e8..0e02ad6 100644 --- a/backend/controller/build_node.go +++ b/backend/controller/build_node.go @@ -34,7 +34,7 @@ func (h *PipelineHandler) ListBuildNodes(c *gin.Context) { c.JSON(http.StatusOK, model.Response{ Code: 200, Data: map[string]interface{}{ - "list": nodes, + "list": nodes, "total": len(nodes), "master": map[string]interface{}{ "name": "Master", @@ -221,4 +221,4 @@ func (h *PipelineHandler) UpdateBuildMaster(c *gin.Context) { "name": "Master", "max_executors": cfg.MaxExecutors, "running_count": running, }, }) -} \ No newline at end of file +} diff --git a/backend/controller/cloud_accounts.go b/backend/controller/cloud_accounts.go index 69bc8d7..9008321 100644 --- a/backend/controller/cloud_accounts.go +++ b/backend/controller/cloud_accounts.go @@ -1,7 +1,6 @@ package controller import ( - "fmt" "net/http" "strconv" "strings" @@ -81,10 +80,6 @@ func (h *CloudAccountHandler) CreateCloudAccount(c *gin.Context) { }) return } - if err := validateCloudAccountCredentials(req.Provider, req.AccessKey, req.SecretKey); err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) - return - } status := strings.TrimSpace(req.Status) if status == "" { @@ -163,10 +158,6 @@ func (h *CloudAccountHandler) UpdateCloudAccount(c *gin.Context) { account.Status = strings.TrimSpace(req.Status) } account.MonthlyCost = req.MonthlyCost - if err := validateCloudAccountCredentials(account.Provider, account.AccessKey, account.SecretKey); err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) - return - } if err := h.db.Save(&account).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{ @@ -183,20 +174,6 @@ func (h *CloudAccountHandler) UpdateCloudAccount(c *gin.Context) { }) } -// validateCloudAccountCredentials 校验各云厂商调用 API 所需的凭据格式。 // Logan.Li -func validateCloudAccountCredentials(provider, accessKey, secretKey string) error { - if strings.TrimSpace(accessKey) == "" || strings.TrimSpace(secretKey) == "" { - return fmt.Errorf("AccessKey 和 SecretKey 不能为空") - } - if provider == "azure" { - parts := strings.Split(accessKey, ":") - if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" { - return fmt.Errorf("Azure AccessKey 格式应为 租户ID:订阅ID:客户端ID") - } - } - return nil -} - // DeleteCloudAccount 删除云账号 // @Summary 删除云账号 // @Description 删除指定云账号 diff --git a/backend/controller/cloud_domain.go b/backend/controller/cloud_domain.go index 9084537..c245fe9 100644 --- a/backend/controller/cloud_domain.go +++ b/backend/controller/cloud_domain.go @@ -7,6 +7,7 @@ import ( "time" "ezops/model" + "ezops/pkg/siteprobe" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -228,3 +229,39 @@ func (h *DomainHandler) UpdateDomain(c *gin.Context) { }) } +// CheckDomainSSL 复用站点拨测 TLS 能力检测并回写域名证书到期日。 // Logan.Li +func (h *DomainHandler) CheckDomainSSL(c *gin.Context) { + var domain model.Domain + if err := h.db.Take(&domain, c.Param("id")).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "域名不存在"}) + return + } + outcome := siteprobe.Execute(c.Request.Context(), model.SiteTask{ + Name: "域名证书检测", + URL: domain.Name, + TaskType: model.SiteTaskTypeSSL, + Timeout: 10, + VerifySSL: false, + }) + if outcome.Status != model.SiteResultSuccess || outcome.SSLCertExpiry == nil { + message := outcome.ErrorMessage + if message == "" { + message = "未获取到证书" + } + c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: message}) + return + } + if err := h.db.Model(&domain).Updates(map[string]interface{}{ + "ssl_enabled": true, + "ssl_expire_date": outcome.SSLCertExpiry, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存证书信息失败"}) + return + } + domain.SSLEnabled = true + domain.SSLExpireDate = outcome.SSLCertExpiry + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "证书检测完成", Data: gin.H{ + "domain": domain, "ssl_expire_date": outcome.SSLCertExpiry, "ssl_days_remaining": outcome.SSLDaysRemaining, + "ssl_issuer": outcome.SSLCertIssuer, "ssl_subject": outcome.SSLCertSubject, + }}) +} diff --git a/backend/controller/cloud_platform.go b/backend/controller/cloud_platform.go index 712a5b3..a428e5f 100644 --- a/backend/controller/cloud_platform.go +++ b/backend/controller/cloud_platform.go @@ -129,4 +129,3 @@ func (h *PlatformHandler) DeletePlatform(c *gin.Context) { } c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"}) } - diff --git a/backend/controller/cloud_sync.go b/backend/controller/cloud_sync.go index 071a63a..03d38c8 100644 --- a/backend/controller/cloud_sync.go +++ b/backend/controller/cloud_sync.go @@ -3,7 +3,6 @@ package controller import ( "net/http" "strconv" - "sync" "time" "ezops/cloud" @@ -74,60 +73,22 @@ func (h *CloudSyncHandler) SyncRegions(c *gin.Context) { } } - // 并发统计所有可用区域实例数,卡片展示云账号真实资源量。 // Logan.Li - serverCount, countedRegions := countCloudInstances(provider, cloudRegions) - updates := map[string]interface{}{ + // 更新账号的 region_count 和 last_sync_at + h.db.Model(&account).Updates(map[string]interface{}{ "region_count": len(cloudRegions), "last_sync_at": time.Now(), - } - if countedRegions > 0 { - updates["server_count"] = serverCount - } - // 更新账号区域、实例数量和同步时间。 // Logan.Li - h.db.Model(&account).Updates(updates) + }) c.JSON(http.StatusOK, model.Response{ Code: 200, Message: "同步成功", Data: gin.H{ - "synced": synced, - "total": len(cloudRegions), - "server_count": serverCount, + "synced": synced, + "total": len(cloudRegions), }, }) } -// countCloudInstances 以固定并发统计各区域实例数量,避免串行请求过慢。 // Logan.Li -func countCloudInstances(provider cloud.Provider, regions []cloud.CloudRegion) (total int, succeeded int) { - type countResult struct { - count int - ok bool - } - results := make(chan countResult, len(regions)) - semaphore := make(chan struct{}, 4) - var waitGroup sync.WaitGroup - for _, region := range regions { - regionID := region.RegionID - waitGroup.Add(1) - go func() { - defer waitGroup.Done() - semaphore <- struct{}{} - defer func() { <-semaphore }() - instances, err := provider.ListInstances(regionID) - results <- countResult{count: len(instances), ok: err == nil} - }() - } - waitGroup.Wait() - close(results) - for result := range results { - if result.ok { - total += result.count - succeeded++ - } - } - return total, succeeded -} - // GetAccountRegions 获取指定云账号的区域列表(按 provider 过滤) // GET /cloud-accounts/:id/regions func (h *CloudSyncHandler) GetAccountRegions(c *gin.Context) { @@ -266,12 +227,28 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) { ip = inst.InstanceID } - // 重复资产补齐来源账号关联,保证服务器数统计准确。 // Logan.Li + hostname := cloudImportHostname(inst) + alias := cloudImportAlias(inst) + + // 检查是否已存在(优先实例 ID,其次 IP/hostname),重复导入只补空别名,避免覆盖人工命名。 // Logan.Li var existing model.Server - if err := h.db.Where("ip = ? OR hostname = ?", ip, inst.Hostname).Take(&existing).Error; err == nil { - if existing.CloudAccountID == nil { - h.db.Model(&existing).Update("cloud_account_id", account.ID) + existingQuery := h.db.Where("ip = ? OR hostname = ?", ip, hostname) + if inst.InstanceID != "" { + existingQuery = h.db.Where("cloud_instance_id = ? OR ip = ? OR hostname = ?", inst.InstanceID, ip, hostname) + } + if err := existingQuery.Take(&existing).Error; err == nil { + updates := map[string]interface{}{ + "cloud_instance_name": inst.InstanceName, + "cloud_instance_id": inst.InstanceID, + "description": "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")", } + if existing.Alias == "" && alias != "" { + updates["alias"] = alias + } + h.db.Model(&existing).Updates(updates) + skipped++ + continue + } else if err != gorm.ErrRecordNotFound { skipped++ continue } @@ -305,34 +282,28 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) { if ipAddr == "" { ipAddr = inst.InstanceID } - hostname := inst.Hostname - if hostname == "" { - hostname = inst.InstanceName - } - if hostname == "" { - hostname = inst.InstanceID - } - // 注意:servers 表的 cloud_id 外键引用的是 clouds 表(非 cloud_accounts), // region_id 外键引用 regions 表。未找到关联时设为 NULL 避免外键约束失败。 server := model.Server{ - Hostname: hostname, - IP: ipAddr, - PublicIP: inst.PublicIP, - Port: defaultPort, - Username: username, - AuthType: "password", - OSType: osType, - Protocol: protocol, - RDPPort: 3389, - OS: osName, - CPU: inst.CPU, - Memory: int64(inst.Memory), - Status: status, - RegionID: regionID, // nil = NULL - CloudAccountID: &account.ID, - Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")", - AgentStatus: "offline", + Hostname: hostname, + Alias: alias, + CloudInstanceName: inst.InstanceName, + CloudInstanceID: inst.InstanceID, + IP: ipAddr, + PublicIP: inst.PublicIP, + Port: defaultPort, + Username: username, + AuthType: "password", + OSType: osType, + Protocol: protocol, + RDPPort: 3389, + OS: osName, + CPU: inst.CPU, + Memory: int64(inst.Memory), + Status: status, + RegionID: regionID, // nil = NULL + Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")", + AgentStatus: "offline", } if protocol == "rdp" && hasRDPCred { applyCredential(rdpCred, rdpPlain, &server) @@ -347,6 +318,9 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) { } } + // 更新账号的 server_count + h.db.Model(&account).UpdateColumn("server_count", imported) + c.JSON(http.StatusOK, model.Response{ Code: 200, Message: "导入完成", @@ -356,3 +330,31 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) { }, }) } + +func cloudImportHostname(inst cloud.CloudInstance) string { + // hostname 保留技术主机名;展示友好名称写入 alias/cloud_instance_name。 // Logan.Li + if inst.Hostname != "" && inst.Hostname != inst.InstanceID { + return inst.Hostname + } + if inst.InstanceName != "" && inst.InstanceName != inst.InstanceID { + return inst.InstanceName + } + if inst.PrivateIP != "" { + return inst.PrivateIP + } + if inst.PublicIP != "" { + return inst.PublicIP + } + if inst.InstanceName != "" { + return inst.InstanceName + } + return inst.InstanceID +} + +func cloudImportAlias(inst cloud.CloudInstance) string { + // 云导入初始别名优先使用控制台实例名称,后续人工别名不被同步覆盖。 // Logan.Li + if inst.InstanceName != "" && inst.InstanceName != inst.InstanceID { + return inst.InstanceName + } + return "" +} diff --git a/backend/controller/cloud_sync_test.go b/backend/controller/cloud_sync_test.go deleted file mode 100644 index 6b3638f..0000000 --- a/backend/controller/cloud_sync_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package controller - -import ( - "errors" - "sync/atomic" - "testing" - "time" - - "ezops/cloud" -) - -type countInstancesProvider struct { - counts map[string]int - failures map[string]bool - activeCalls atomic.Int32 - maxCalls atomic.Int32 -} - -func (p *countInstancesProvider) Name() string { return "fake" } - -func (p *countInstancesProvider) ListRegions() ([]cloud.CloudRegion, error) { return nil, nil } - -func (p *countInstancesProvider) ListInstances(regionID string) ([]cloud.CloudInstance, error) { - active := p.activeCalls.Add(1) - defer p.activeCalls.Add(-1) - for { - maximum := p.maxCalls.Load() - if active <= maximum || p.maxCalls.CompareAndSwap(maximum, active) { - break - } - } - time.Sleep(5 * time.Millisecond) - if p.failures[regionID] { - return nil, errors.New("区域请求失败") - } - return make([]cloud.CloudInstance, p.counts[regionID]), nil -} - -func TestCountCloudInstances(t *testing.T) { - provider := &countInstancesProvider{ - counts: map[string]int{"a": 2, "b": 3, "c": 4, "d": 5, "e": 6}, - failures: map[string]bool{"c": true}, - } - regions := []cloud.CloudRegion{{RegionID: "a"}, {RegionID: "b"}, {RegionID: "c"}, {RegionID: "d"}, {RegionID: "e"}} - total, succeeded := countCloudInstances(provider, regions) - if total != 16 || succeeded != 4 { - t.Fatalf("实例统计错误: total=%d succeeded=%d", total, succeeded) - } - if maximum := provider.maxCalls.Load(); maximum > 4 { - t.Fatalf("并发数超过限制: %d", maximum) - } -} diff --git a/backend/controller/custom_dashboard.go b/backend/controller/custom_dashboard.go deleted file mode 100644 index 55624e1..0000000 --- a/backend/controller/custom_dashboard.go +++ /dev/null @@ -1,258 +0,0 @@ -package controller - -import ( - "encoding/json" - "net/http" - "strconv" - - ezops "ezops/model" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -// CustomDashboardHandler 自定义监控仪表盘 handler // Logan.Li -type CustomDashboardHandler struct { - db *gorm.DB -} - -// NewCustomDashboardHandler 创建 handler -func NewCustomDashboardHandler(db *gorm.DB) *CustomDashboardHandler { - return &CustomDashboardHandler{db: db} -} - -// ============================================ -// DTO -// ============================================ - -type CreateDashboardReq struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Datasource string `json:"datasource"` - DatasourceURL string `json:"datasource_url"` - Panels string `json:"panels"` // JSON string - IsDefault bool `json:"is_default"` -} - -type UpdateDashboardReq struct { - Name *string `json:"name"` - Description *string `json:"description"` - Datasource *string `json:"datasource"` - DatasourceURL *string `json:"datasource_url"` - Panels *string `json:"panels"` - IsDefault *bool `json:"is_default"` - IsFavorite *bool `json:"is_favorite"` -} - -// ============================================ -// CRUD -// ============================================ - -// List 仪表盘列表 -// GET /api/v1/custom-dashboards -func (h *CustomDashboardHandler) List(c *gin.Context) { - var dashboards []ezops.CustomDashboard - if err := h.db.Order("is_default DESC, updated_at DESC").Find(&dashboards).Error; err != nil { - c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "查询失败: " + err.Error()}) - return - } - - // 转换为前端期望的格式(id 转为 string,panels JSON 解析为数组) - result := make([]gin.H, 0, len(dashboards)) - for _, d := range dashboards { - item := gin.H{ - "id": strconv.Itoa(int(d.ID)), - "name": d.Name, - "description": d.Description, - "datasource": d.Datasource, - "datasourceUrl": d.DatasourceURL, - "datasource_url": d.DatasourceURL, - "createdAt": d.CreatedAt, - "updatedAt": d.UpdatedAt, - "createdBy": d.CreatedBy, - "isDefault": d.IsDefault, - "is_default": d.IsDefault, - } - // 解析 panels JSON - var panels []interface{} - if d.Panels != "" { - if err := json.Unmarshal([]byte(d.Panels), &panels); err == nil { - item["panels"] = panels - } else { - item["panels"] = []interface{}{} - } - } else { - item["panels"] = []interface{}{} - } - result = append(result, item) - } - - c.JSON(http.StatusOK, ezops.Response{Code: 200, Data: gin.H{ - "list": result, - "total": len(result), - }}) -} - -// Get 获取单个仪表盘 -// GET /api/v1/custom-dashboards/:id -func (h *CustomDashboardHandler) Get(c *gin.Context) { - id := c.Param("id") - var d ezops.CustomDashboard - if err := h.db.First(&d, id).Error; err != nil { - c.JSON(http.StatusNotFound, ezops.Response{Code: 404, Message: "仪表盘不存在"}) - return - } - - var panels []interface{} - if d.Panels != "" { - json.Unmarshal([]byte(d.Panels), &panels) - } - if panels == nil { - panels = []interface{}{} - } - - c.JSON(http.StatusOK, ezops.Response{Code: 200, Data: gin.H{ - "id": strconv.Itoa(int(d.ID)), - "name": d.Name, - "description": d.Description, - "datasource": d.Datasource, - "datasourceUrl": d.DatasourceURL, - "datasource_url": d.DatasourceURL, - "panels": panels, - "createdAt": d.CreatedAt, - "updatedAt": d.UpdatedAt, - "createdBy": d.CreatedBy, - "isDefault": d.IsDefault, - "is_default": d.IsDefault, - }}) -} - -// Create 创建仪表盘 -// POST /api/v1/custom-dashboards -func (h *CustomDashboardHandler) Create(c *gin.Context) { - var req CreateDashboardReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, ezops.Response{Code: 400, Message: "参数错误: " + err.Error()}) - return - } - - if req.Datasource == "" { - req.Datasource = "prometheus" - } - if req.Panels == "" { - req.Panels = "[]" - } - - // 如果设为默认,先清除其他默认 - if req.IsDefault { - h.db.Model(&ezops.CustomDashboard{}).Where("is_default = ?", true).Update("is_default", false) - } - - // 从 JWT 获取用户名 - username := "admin" - if u, exists := c.Get("username"); exists { - if s, ok := u.(string); ok { - username = s - } - } - - d := ezops.CustomDashboard{ - Name: req.Name, - Description: req.Description, - Datasource: req.Datasource, - DatasourceURL: req.DatasourceURL, - Panels: req.Panels, - CreatedBy: username, - IsDefault: req.IsDefault, - } - - if err := h.db.Create(&d).Error; err != nil { - c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "创建失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "创建成功", Data: gin.H{ - "success": true, - "id": strconv.Itoa(int(d.ID)), - }}) -} - -// Update 更新仪表盘 -// PUT /api/v1/custom-dashboards/:id -func (h *CustomDashboardHandler) Update(c *gin.Context) { - id := c.Param("id") - var d ezops.CustomDashboard - if err := h.db.First(&d, id).Error; err != nil { - c.JSON(http.StatusNotFound, ezops.Response{Code: 404, Message: "仪表盘不存在"}) - return - } - - var req UpdateDashboardReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, ezops.Response{Code: 400, Message: "参数错误: " + err.Error()}) - return - } - - updates := map[string]interface{}{} - if req.Name != nil { - updates["name"] = *req.Name - } - if req.Description != nil { - updates["description"] = *req.Description - } - if req.Datasource != nil { - updates["datasource"] = *req.Datasource - } - if req.DatasourceURL != nil { - updates["datasource_url"] = *req.DatasourceURL - } - if req.Panels != nil { - updates["panels"] = *req.Panels - } - if req.IsFavorite != nil { - updates["is_favorite"] = *req.IsFavorite - } - if req.IsDefault != nil && *req.IsDefault { - // 设为默认前先清除其他默认 - h.db.Model(&ezops.CustomDashboard{}).Where("is_default = ? AND id != ?", true, d.ID).Update("is_default", false) - updates["is_default"] = true - } - - if len(updates) > 0 { - if err := h.db.Model(&d).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "更新失败: " + err.Error()}) - return - } - } - - c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "更新成功", Data: gin.H{"success": true}}) -} - -// Delete 删除仪表盘 -// DELETE /api/v1/custom-dashboards/:id -func (h *CustomDashboardHandler) Delete(c *gin.Context) { - id := c.Param("id") - if err := h.db.Delete(&ezops.CustomDashboard{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "删除失败: " + err.Error()}) - return - } - c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "删除成功", Data: gin.H{"success": true}}) -} - -// SetDefault 设为默认仪表盘 -// POST /api/v1/custom-dashboards/:id/default -func (h *CustomDashboardHandler) SetDefault(c *gin.Context) { - id := c.Param("id") - var d ezops.CustomDashboard - if err := h.db.First(&d, id).Error; err != nil { - c.JSON(http.StatusNotFound, ezops.Response{Code: 404, Message: "仪表盘不存在"}) - return - } - - // 清除其他默认 - h.db.Model(&ezops.CustomDashboard{}).Where("is_default = ? AND id != ?", true, d.ID).Update("is_default", false) - // 设置当前为默认 - h.db.Model(&d).Update("is_default", true) - - c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "设置成功", Data: gin.H{"success": true}}) -} diff --git a/backend/controller/dashboard.go b/backend/controller/dashboard.go index be01b56..89fbf7d 100644 --- a/backend/controller/dashboard.go +++ b/backend/controller/dashboard.go @@ -359,15 +359,15 @@ func (h *DashboardHandler) GetRecentReleases(c *gin.Context) { // @Success 200 {object} model.Response{data=[]model.MetricData} // @Router /dashboard/metrics [get] func (h *DashboardHandler) GetMetrics(c *gin.Context) { - hours := 6 // 默认6小时,减少扫描行数 + hours := 6 // 默认6小时,减少扫描行数 if hv := c.Query("hours"); hv != "" { if parsed, err := strconv.Atoi(hv); err == nil && parsed > 0 && parsed <= 168 { hours = parsed } } - // 从 agent_reports 表查最近 N 小时的真实数据(整数分组,可用索引) - since := time.Now().Add(-time.Duration(hours) * time.Hour) + // 从 agent_reports 表按 UTC 边界查最近 N 小时的真实数据。 // Logan.Li + since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour) type avgPoint struct { BucketTS int64 AvgCPU float64 diff --git a/backend/controller/deploy.go b/backend/controller/deploy.go deleted file mode 100644 index 6ccbe3b..0000000 --- a/backend/controller/deploy.go +++ /dev/null @@ -1,164 +0,0 @@ -package controller - -import ( - "net/http" - - "ezops/model" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -// DeployHandler 发布系统处理器 -type DeployHandler struct { - db *gorm.DB -} - -func NewDeployHandler(db *gorm.DB) *DeployHandler { - return &DeployHandler{db: db} -} - -// ==================== Git 仓库认证 ==================== - -func (h *DeployHandler) ListRepos(c *gin.Context) { - var items []model.PipelineRepo - h.db.Order("updated_at DESC").Find(&items) - c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: items, Total: int64(len(items))}}) -} - -func (h *DeployHandler) GetRepo(c *gin.Context) { - var item model.PipelineRepo - if err := h.db.First(&item, c.Param("id")); err.Error != nil { - c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "不存在"}) - return - } - c.JSON(http.StatusOK, model.Response{Code: 200, Data: item}) -} - -func (h *DeployHandler) CreateRepo(c *gin.Context) { - var req struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - URL string `json:"url" binding:"required"` - AuthType model.RepoAuthType `json:"auth_type"` - CredentialID *uint `json:"credential_id"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) - return - } - if req.AuthType == "" { req.AuthType = model.RepoAuthToken } - item := model.PipelineRepo{ - Name: req.Name, Description: req.Description, URL: req.URL, - AuthType: req.AuthType, CredentialID: req.CredentialID, - CreatedBy: c.GetString("username"), - } - h.db.Create(&item) - c.JSON(http.StatusOK, model.Response{Code: 200, Data: item}) -} - -func (h *DeployHandler) UpdateRepo(c *gin.Context) { - var item model.PipelineRepo - if err := h.db.First(&item, c.Param("id")); err.Error != nil { - c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "不存在"}) - return - } - var req struct { - Name *string `json:"name"` - Description *string `json:"description"` - URL *string `json:"url"` - AuthType *model.RepoAuthType `json:"auth_type"` - CredentialID *uint `json:"credential_id"` - Enabled *bool `json:"enabled"` - } - c.ShouldBindJSON(&req) - updates := map[string]interface{}{} - if req.Name != nil { updates["name"] = *req.Name } - if req.Description != nil { updates["description"] = *req.Description } - if req.URL != nil { updates["url"] = *req.URL } - if req.AuthType != nil { updates["auth_type"] = *req.AuthType } - if req.Enabled != nil { updates["enabled"] = *req.Enabled } - if req.CredentialID != nil { updates["credential_id"] = req.CredentialID } - h.db.Model(&item).Updates(updates) - h.db.First(&item, item.ID) - c.JSON(http.StatusOK, model.Response{Code: 200, Data: item}) -} - -func (h *DeployHandler) DeleteRepo(c *gin.Context) { - h.db.Delete(&model.PipelineRepo{}, c.Param("id")) - c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已删除"}) -} - -// ==================== 镜像仓库认证 ==================== - -func (h *DeployHandler) ListRegistries(c *gin.Context) { - var items []model.PipelineRegistry - h.db.Order("updated_at DESC").Find(&items) - c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: items, Total: int64(len(items))}}) -} - -func (h *DeployHandler) GetRegistry(c *gin.Context) { - var item model.PipelineRegistry - if err := h.db.First(&item, c.Param("id")); err.Error != nil { - c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "不存在"}) - return - } - c.JSON(http.StatusOK, model.Response{Code: 200, Data: item}) -} - -func (h *DeployHandler) CreateRegistry(c *gin.Context) { - var req struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - RegistryURL string `json:"registry_url" binding:"required"` - Namespace string `json:"namespace"` - AuthType model.RegistryAuthType `json:"auth_type"` - CredentialID *uint `json:"credential_id"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) - return - } - if req.AuthType == "" { req.AuthType = model.RegistryAuthPassword } - item := model.PipelineRegistry{ - Name: req.Name, Description: req.Description, RegistryURL: req.RegistryURL, - Namespace: req.Namespace, AuthType: req.AuthType, CredentialID: req.CredentialID, - CreatedBy: c.GetString("username"), - } - h.db.Create(&item) - c.JSON(http.StatusOK, model.Response{Code: 200, Data: item}) -} - -func (h *DeployHandler) UpdateRegistry(c *gin.Context) { - var item model.PipelineRegistry - if err := h.db.First(&item, c.Param("id")); err.Error != nil { - c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "不存在"}) - return - } - var req struct { - Name *string `json:"name"` - Description *string `json:"description"` - RegistryURL *string `json:"registry_url"` - Namespace *string `json:"namespace"` - AuthType *model.RegistryAuthType `json:"auth_type"` - CredentialID *uint `json:"credential_id"` - Enabled *bool `json:"enabled"` - } - c.ShouldBindJSON(&req) - updates := map[string]interface{}{} - if req.Name != nil { updates["name"] = *req.Name } - if req.Description != nil { updates["description"] = *req.Description } - if req.RegistryURL != nil { updates["registry_url"] = *req.RegistryURL } - if req.Namespace != nil { updates["namespace"] = *req.Namespace } - if req.AuthType != nil { updates["auth_type"] = *req.AuthType } - if req.Enabled != nil { updates["enabled"] = *req.Enabled } - if req.CredentialID != nil { updates["credential_id"] = req.CredentialID } - h.db.Model(&item).Updates(updates) - h.db.First(&item, item.ID) - c.JSON(http.StatusOK, model.Response{Code: 200, Data: item}) -} - -func (h *DeployHandler) DeleteRegistry(c *gin.Context) { - h.db.Delete(&model.PipelineRegistry{}, c.Param("id")) - c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已删除"}) -} diff --git a/backend/controller/deploy_environments.go b/backend/controller/deploy_environments.go index 207fdb1..fe1c3b5 100644 --- a/backend/controller/deploy_environments.go +++ b/backend/controller/deploy_environments.go @@ -35,7 +35,9 @@ func (h *PipelineHandler) CreateEnvironment(c *gin.Context) { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) return } - if req.Color == "" { req.Color = typeDefaultColor(req.Type) } + if req.Color == "" { + req.Color = typeDefaultColor(req.Type) + } item := model.DeployEnvironment{ Name: req.Name, DisplayName: req.DisplayName, Type: model.EnvironmentType(req.Type), Color: req.Color, @@ -67,13 +69,27 @@ func (h *PipelineHandler) UpdateEnvironment(c *gin.Context) { } c.ShouldBindJSON(&req) updates := map[string]interface{}{} - if req.Name != nil { updates["name"] = *req.Name } - if req.DisplayName != nil { updates["display_name"] = *req.DisplayName } - if req.Type != nil { updates["type"] = *req.Type } - if req.Color != nil { updates["color"] = *req.Color } - if req.SortOrder != nil { updates["sort_order"] = *req.SortOrder } - if req.Description != nil { updates["description"] = *req.Description } - if req.Enabled != nil { updates["enabled"] = *req.Enabled } + if req.Name != nil { + updates["name"] = *req.Name + } + if req.DisplayName != nil { + updates["display_name"] = *req.DisplayName + } + if req.Type != nil { + updates["type"] = *req.Type + } + if req.Color != nil { + updates["color"] = *req.Color + } + if req.SortOrder != nil { + updates["sort_order"] = *req.SortOrder + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Enabled != nil { + updates["enabled"] = *req.Enabled + } h.db.Model(&item).Updates(updates) h.db.First(&item, id) c.JSON(http.StatusOK, model.Response{Code: 200, Data: item}) @@ -87,10 +103,14 @@ func (h *PipelineHandler) DeleteEnvironment(c *gin.Context) { func typeDefaultColor(t string) string { switch t { - case "production": return "#EF4444" - case "staging": return "#F59E0B" - case "testing": return "#10B981" - default: return "#3B82F6" // development + case "production": + return "#EF4444" + case "staging": + return "#F59E0B" + case "testing": + return "#10B981" + default: + return "#3B82F6" // development } } @@ -212,8 +232,8 @@ func (h *PipelineHandler) GetRunLog(c *gin.Context) { } c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ - "run": run, - "logs": logs, + "run": run, + "logs": logs, }}) } diff --git a/backend/controller/deploy_groups.go b/backend/controller/deploy_groups.go index f4260d9..a195bec 100644 --- a/backend/controller/deploy_groups.go +++ b/backend/controller/deploy_groups.go @@ -53,8 +53,12 @@ func (h *PipelineHandler) UpdateGroup(c *gin.Context) { } c.ShouldBindJSON(&req) updates := map[string]interface{}{} - if req.Name != nil { updates["name"] = *req.Name } - if req.Description != nil { updates["description"] = *req.Description } + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } h.db.Model(&group).Updates(updates) h.db.First(&group, id) c.JSON(http.StatusOK, model.Response{Code: 200, Data: group}) diff --git a/backend/controller/health_probe.go b/backend/controller/health_probe.go index 4eaf87c..1731f40 100644 --- a/backend/controller/health_probe.go +++ b/backend/controller/health_probe.go @@ -5,6 +5,7 @@ import ( "strconv" "time" + "ezops/middleware" "ezops/model" "github.com/gin-gonic/gin" @@ -190,6 +191,9 @@ func (h *AgentHandler) GetHealthProbesForAgent(c *gin.Context) { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "hostname 不能为空"}) return } + if !middleware.RequireAgentHostname(c, hostname) { + return + } var probes []model.HealthProbe h.db.Where("hostname = ? AND enabled = ?", hostname, true). diff --git a/backend/controller/k8s_cluster.go b/backend/controller/k8s_cluster.go index c0d31df..8a1dd0b 100644 --- a/backend/controller/k8s_cluster.go +++ b/backend/controller/k8s_cluster.go @@ -216,28 +216,28 @@ func (h *KubernetesHandler) GetClusterDetail(c *gin.Context) { Code: 200, Message: "success", Data: map[string]interface{}{ - "id": cluster.ID, - "name": cluster.Name, - "code": cluster.Code, - "version": cluster.Version, - "status": cluster.Status, - "config_type": cluster.ConfigType, - "api_server": cluster.APIServer, - "api_endpoint": cluster.APIEndpoint, - "description": cluster.Description, - "has_token": cluster.Token != "", - "has_kube_config": cluster.KubeConfig != "", + "id": cluster.ID, + "name": cluster.Name, + "code": cluster.Code, + "version": cluster.Version, + "status": cluster.Status, + "config_type": cluster.ConfigType, + "api_server": cluster.APIServer, + "api_endpoint": cluster.APIEndpoint, + "description": cluster.Description, + "has_token": cluster.Token != "", + "has_kube_config": cluster.KubeConfig != "", "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, - "labels": cluster.Labels, - "last_sync_at": cluster.LastSyncAt, - "created_at": cluster.CreatedAt, - "updated_at": cluster.UpdatedAt, + "cpu_usage": cluster.CPUUsage, + "memory_usage": cluster.MemoryUsage, + "labels": cluster.Labels, + "last_sync_at": cluster.LastSyncAt, + "created_at": cluster.CreatedAt, + "updated_at": cluster.UpdatedAt, }, }) } @@ -320,7 +320,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) { // Sync nodes if nodes, err := client.ListNodes(); err == nil { // Use raw SQL for reliability (avoid GORM soft-delete / auto-timestamp issues) - h.db.Exec("DELETE FROM kubernetes_nodes WHERE cluster_id = ?", cluster.ID) + h.db.Exec("DELETE FROM k8s_nodes WHERE cluster_id = ?", cluster.ID) for _, n := range nodes.Items { role := "worker" if _, ok := n.Metadata.Labels["node-role.kubernetes.io/master"]; ok { @@ -358,7 +358,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) { memUsagePct = float64(memMi) / float64(memMB) * 100 } } - err2 := h.db.Exec(`INSERT INTO kubernetes_nodes (cluster_id, name, ip, role, status, os, kubelet_version, cpu, memory, max_pods, cpu_usage, memory_usage, labels, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + err2 := h.db.Exec(`INSERT INTO k8s_nodes (cluster_id, name, ip, role, status, os, kubelet_version, cpu, memory, max_pods, cpu_usage, memory_usage, labels, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, cluster.ID, n.Metadata.Name, ip, role, nodeStatus, n.Status.NodeInfo.OSImage, n.Status.NodeInfo.KubeletVersion, parseIntResource(n.Status.Capacity["cpu"]), parseMemResource(n.Status.Capacity["memory"]), parseIntResource(n.Status.Capacity["pods"]), cpuUsagePct, memUsagePct, string(labelsJSON), now, now).Error @@ -508,7 +508,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) { c.JSON(http.StatusOK, model.Response{ Code: 200, Message: msg, - Data: result, + Data: result, }) } diff --git a/backend/controller/k8s_compare.go b/backend/controller/k8s_compare.go index 773161a..b9f3818 100644 --- a/backend/controller/k8s_compare.go +++ b/backend/controller/k8s_compare.go @@ -14,27 +14,27 @@ func (h *KubernetesHandler) GetMultiClusterComparison(c *gin.Context) { h.db.Find(&clusters) type ClusterComparison struct { - ID uint `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - Status string `json:"status"` - Version string `json:"version"` - NodeCount int `json:"node_count"` - PodCount int `json:"pod_count"` - DeploymentCount int `json:"deployment_count"` - ServiceCount int `json:"service_count"` - CPUUsage float64 `json:"cpu_usage"` - MemoryUsage float64 `json:"memory_usage"` - APIServer string `json:"api_server"` - LastSyncAt *time.Time `json:"last_sync_at"` + ID uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Status string `json:"status"` + Version string `json:"version"` + NodeCount int `json:"node_count"` + PodCount int `json:"pod_count"` + DeploymentCount int `json:"deployment_count"` + ServiceCount int `json:"service_count"` + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + APIServer string `json:"api_server"` + LastSyncAt *time.Time `json:"last_sync_at"` - // Node-level aggregated data from kubernetes_nodes table - TotalCPUCores int `json:"total_cpu_cores"` - TotalMemoryMB int64 `json:"total_memory_mb"` - MasterCount int `json:"master_count"` - WorkerCount int `json:"worker_count"` - HealthyNodes int `json:"healthy_nodes"` - UnhealthyNodes int `json:"unhealthy_nodes"` + // Node-level aggregated data from k8s_nodes table + TotalCPUCores int `json:"total_cpu_cores"` + TotalMemoryMB int64 `json:"total_memory_mb"` + MasterCount int `json:"master_count"` + WorkerCount int `json:"worker_count"` + HealthyNodes int `json:"healthy_nodes"` + UnhealthyNodes int `json:"unhealthy_nodes"` } // 2. 获取所有节点的聚合数据(按 cluster_id 分组) @@ -64,8 +64,16 @@ func (h *KubernetesHandler) GetMultiClusterComparison(c *gin.Context) { agg.TotalCPU += n.CPU agg.TotalMemory += n.Memory if n.CPUUsage > 0 || n.MemoryUsage > 0 { - if agg.AvgCPUUsage == 0 { agg.AvgCPUUsage = n.CPUUsage } else { agg.AvgCPUUsage = (agg.AvgCPUUsage*float64(agg.NodeCount-1) + n.CPUUsage) / float64(agg.NodeCount) } - if agg.AvgMemUsage == 0 { agg.AvgMemUsage = n.MemoryUsage } else { agg.AvgMemUsage = (agg.AvgMemUsage*float64(agg.NodeCount-1) + n.MemoryUsage) / float64(agg.NodeCount) } + if agg.AvgCPUUsage == 0 { + agg.AvgCPUUsage = n.CPUUsage + } else { + agg.AvgCPUUsage = (agg.AvgCPUUsage*float64(agg.NodeCount-1) + n.CPUUsage) / float64(agg.NodeCount) + } + if agg.AvgMemUsage == 0 { + agg.AvgMemUsage = n.MemoryUsage + } else { + agg.AvgMemUsage = (agg.AvgMemUsage*float64(agg.NodeCount-1) + n.MemoryUsage) / float64(agg.NodeCount) + } } if n.Role == "master" { agg.MasterCount++ diff --git a/backend/controller/k8s_events.go b/backend/controller/k8s_events.go index 27ce6e3..00d471c 100644 --- a/backend/controller/k8s_events.go +++ b/backend/controller/k8s_events.go @@ -14,14 +14,23 @@ import ( func (h *KubernetesHandler) ListEvents(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Query("namespace") eventType := c.Query("type") // Normal or Warning events, err := client.ListEvents(ns) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } type EventSummary struct { Namespace string `json:"namespace"` @@ -40,7 +49,9 @@ func (h *KubernetesHandler) ListEvents(c *gin.Context) { } result := []EventSummary{} for _, e := range events.Items { - if eventType != "" && e.Type != eventType { continue } + if eventType != "" && e.Type != eventType { + continue + } objectKind := e.InvolvedObject.Kind objectName := e.InvolvedObject.Name object := fmt.Sprintf("%s/%s", objectKind, objectName) diff --git a/backend/controller/k8s_hpa_cronjob.go b/backend/controller/k8s_hpa_cronjob.go index b0006f4..8d4e0e1 100644 --- a/backend/controller/k8s_hpa_cronjob.go +++ b/backend/controller/k8s_hpa_cronjob.go @@ -246,30 +246,45 @@ func (h *KubernetesHandler) DeleteCronJobNS(c *gin.Context) { } func (h *KubernetesHandler) listHPAsWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } hpas, err := client.ListHPAs() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } type HPASummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - TargetKind string `json:"target_kind"` - TargetName string `json:"target_name"` - MinReplicas int `json:"min_replicas"` - MaxReplicas int `json:"max_replicas"` - CPUPercent int `json:"cpu_percent"` - MemPercent int `json:"mem_percent"` + Namespace string `json:"namespace"` + Name string `json:"name"` + TargetKind string `json:"target_kind"` + TargetName string `json:"target_name"` + MinReplicas int `json:"min_replicas"` + MaxReplicas int `json:"max_replicas"` + CPUPercent int `json:"cpu_percent"` + MemPercent int `json:"mem_percent"` CurrentReplicas int `json:"current_replicas"` } result := []HPASummary{} for _, h := range hpas.Items { - if h.Metadata.Namespace != ns { continue } - cpu := 0; mem := 0 + if h.Metadata.Namespace != ns { + continue + } + cpu := 0 + mem := 0 for _, m := range h.Spec.Metrics { if m.Resource != nil { - if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization } - if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization } + if m.Resource.Name == "cpu" { + cpu = m.Resource.Target.AverageUtilization + } + if m.Resource.Name == "memory" { + mem = m.Resource.Target.AverageUtilization + } } } result = append(result, HPASummary{ @@ -288,80 +303,165 @@ func (h *KubernetesHandler) listHPAsWithNS(c *gin.Context, ns string) { } func (h *KubernetesHandler) createHPAWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } var req k8sclient.CreateHPARequest - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return } - if req.Namespace == "" { req.Namespace = ns } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, model.Response{Code: 400, Message: "参数错误"}) + return + } + if req.Namespace == "" { + req.Namespace = ns + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } hpa, err := client.CreateHPA(req) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "创建失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "创建失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Data: hpa}) } func (h *KubernetesHandler) deleteHPAWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } - if err := client.DeleteHPA(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } + if err := client.DeleteHPA(ns, c.Param("name")); err != nil { + c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Message: "已删除"}) } func (h *KubernetesHandler) listCronJobsWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } cjs, err := client.ListCronJobs() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } filtered := []k8sclient.CronJobItem{} - for _, cj := range cjs.Items { if cj.Metadata.Namespace == ns { filtered = append(filtered, cj) } } + for _, cj := range cjs.Items { + if cj.Metadata.Namespace == ns { + filtered = append(filtered, cj) + } + } c.JSON(200, model.Response{Code: 200, Data: filtered}) } func (h *KubernetesHandler) createCronJobWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } var req k8sclient.CreateCronJobRequest - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return } - if req.Namespace == "" { req.Namespace = ns } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, model.Response{Code: 400, Message: "参数错误"}) + return + } + if req.Namespace == "" { + req.Namespace = ns + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } cj, err := client.CreateCronJob(req) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "创建失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "创建失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Data: cj}) } func (h *KubernetesHandler) suspendCronJobWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - var req struct { Suspend bool `json:"suspend"` }; c.ShouldBindJSON(&req) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + var req struct { + Suspend bool `json:"suspend"` + } + c.ShouldBindJSON(&req) client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } - if err := client.SuspendCronJob(ns, c.Param("name"), req.Suspend); err != nil { c.JSON(500, model.Response{Code: 500, Message: "操作失败: " + err.Error()}); return } - action := "恢复"; if req.Suspend { action = "暂停" } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } + if err := client.SuspendCronJob(ns, c.Param("name"), req.Suspend); err != nil { + c.JSON(500, model.Response{Code: 500, Message: "操作失败: " + err.Error()}) + return + } + action := "恢复" + if req.Suspend { + action = "暂停" + } c.JSON(200, model.Response{Code: 200, Message: "CronJob 已" + action}) } func (h *KubernetesHandler) triggerCronJobWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } - if err := client.TriggerCronJob(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "触发失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } + if err := client.TriggerCronJob(ns, c.Param("name")); err != nil { + c.JSON(500, model.Response{Code: 500, Message: "触发失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Message: "手动触发成功"}) } func (h *KubernetesHandler) deleteCronJobWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } - if err := client.DeleteCronJob(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } + if err := client.DeleteCronJob(ns, c.Param("name")); err != nil { + c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Message: "已删除"}) } func (h *KubernetesHandler) ListHPAsAllNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } hpas, err := client.ListHPAs() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } type HPASummary struct { Namespace string `json:"namespace"` Name string `json:"name"` @@ -375,11 +475,16 @@ func (h *KubernetesHandler) ListHPAsAllNS(c *gin.Context) { } result := []HPASummary{} for _, h := range hpas.Items { - cpu := 0; mem := 0 + cpu := 0 + mem := 0 for _, m := range h.Spec.Metrics { if m.Resource != nil { - if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization } - if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization } + if m.Resource.Name == "cpu" { + cpu = m.Resource.Target.AverageUtilization + } + if m.Resource.Name == "memory" { + mem = m.Resource.Target.AverageUtilization + } } } result = append(result, HPASummary{ diff --git a/backend/controller/k8s_namespace.go b/backend/controller/k8s_namespace.go index 57bb4fd..bf4fa40 100644 --- a/backend/controller/k8s_namespace.go +++ b/backend/controller/k8s_namespace.go @@ -38,16 +38,16 @@ func (h *KubernetesHandler) ListNamespaces(c *gin.Context) { // NamespaceDetail is the enriched namespace info for the management page type NamespaceDetail struct { - Name string `json:"name"` - Status string `json:"status"` - Age string `json:"age"` - Labels map[string]string `json:"labels"` - Annotations map[string]string `json:"annotations,omitempty"` - ResourceCounts map[string]int `json:"resource_counts"` - HasResourceQuota bool `json:"has_resource_quota"` - HasLimitRange bool `json:"has_limit_range"` - ResourceQuotas []k8sclient.ResourceQuotaItem `json:"resource_quotas,omitempty"` - LimitRanges []k8sclient.LimitRangeItem `json:"limit_ranges,omitempty"` + Name string `json:"name"` + Status string `json:"status"` + Age string `json:"age"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations,omitempty"` + ResourceCounts map[string]int `json:"resource_counts"` + HasResourceQuota bool `json:"has_resource_quota"` + HasLimitRange bool `json:"has_limit_range"` + ResourceQuotas []k8sclient.ResourceQuotaItem `json:"resource_quotas,omitempty"` + LimitRanges []k8sclient.LimitRangeItem `json:"limit_ranges,omitempty"` } // GetNamespacesDetail returns all namespaces with metadata, resource counts and quota info @@ -110,11 +110,11 @@ func (h *KubernetesHandler) GetNamespacesDetail(c *gin.Context) { for _, ns := range nsList.Items { name := ns.Metadata.Name nd := NamespaceDetail{ - Name: name, - Status: ns.Status.Phase, - Age: formatAge(ns.Metadata.CreationTimestamp), - Labels: ns.Metadata.Labels, - Annotations: ns.Metadata.Annotations, + Name: name, + Status: ns.Status.Phase, + Age: formatAge(ns.Metadata.CreationTimestamp), + Labels: ns.Metadata.Labels, + Annotations: ns.Metadata.Annotations, ResourceCounts: map[string]int{ "deployments": depCounts[name], "pods": podCounts[name], @@ -181,21 +181,27 @@ func (h *KubernetesHandler) GetNamespaceDetailHandler(c *gin.Context) { if deps, err := client.ListDeployments(); err == nil { count := 0 for _, d := range deps.Items { - if d.Metadata.Namespace == nsName { count++ } + if d.Metadata.Namespace == nsName { + count++ + } } nd.ResourceCounts["deployments"] = count } if pods, err := client.ListPods(); err == nil { count := 0 for _, p := range pods.Items { - if p.Metadata.Namespace == nsName { count++ } + if p.Metadata.Namespace == nsName { + count++ + } } nd.ResourceCounts["pods"] = count } if svcs, err := client.ListServices(); err == nil { count := 0 for _, s := range svcs.Items { - if s.Metadata.Namespace == nsName { count++ } + if s.Metadata.Namespace == nsName { + count++ + } } nd.ResourceCounts["services"] = count } diff --git a/backend/controller/k8s_node.go b/backend/controller/k8s_node.go index 3cda9ee..0d8e000 100644 --- a/backend/controller/k8s_node.go +++ b/backend/controller/k8s_node.go @@ -305,20 +305,20 @@ func (h *KubernetesHandler) GetNodeDetail(c *gin.Context) { } detail := map[string]interface{}{ - "name": node.Metadata.Name, - "role": role, - "status": nodeStatus, - "creation_time": node.Metadata.CreationTimestamp, - "internal_ip": internalIP, - "external_ip": externalIP, - "hostname": hostname, - "pod_cidr": node.Spec.PodCIDR, + "name": node.Metadata.Name, + "role": role, + "status": nodeStatus, + "creation_time": node.Metadata.CreationTimestamp, + "internal_ip": internalIP, + "external_ip": externalIP, + "hostname": hostname, + "pod_cidr": node.Spec.PodCIDR, "container_runtime": node.Status.NodeInfo.ContainerRuntimeVersion, - "kubelet_version": node.Status.NodeInfo.KubeletVersion, - "os_image": node.Status.NodeInfo.OSImage, - "kernel_version": node.Status.NodeInfo.KernelVersion, - "architecture": node.Status.NodeInfo.Architecture, - "operating_system": node.Status.NodeInfo.OperatingSystem, + "kubelet_version": node.Status.NodeInfo.KubeletVersion, + "os_image": node.Status.NodeInfo.OSImage, + "kernel_version": node.Status.NodeInfo.KernelVersion, + "architecture": node.Status.NodeInfo.Architecture, + "operating_system": node.Status.NodeInfo.OperatingSystem, // Capacity & Allocatable "capacity": node.Status.Capacity, "allocatable": node.Status.Allocatable, @@ -326,18 +326,18 @@ func (h *KubernetesHandler) GetNodeDetail(c *gin.Context) { "cpu_usage_m": cpuUsageM, "mem_usage_mi": memUsageMi, // Allocation - "pod_count": podCount, - "cpu_request_m": totalCPURequestM, - "mem_request_mi": totalMemRequestMi, - "cpu_alloc_pct": cpuAllocPct, - "mem_alloc_pct": memAllocPct, - "pod_alloc_pct": podAllocPct, - "cpu_capacity": capCPU, - "cpu_allocatable": allocCPU, - "mem_capacity_mb": capMem, + "pod_count": podCount, + "cpu_request_m": totalCPURequestM, + "mem_request_mi": totalMemRequestMi, + "cpu_alloc_pct": cpuAllocPct, + "mem_alloc_pct": memAllocPct, + "pod_alloc_pct": podAllocPct, + "cpu_capacity": capCPU, + "cpu_allocatable": allocCPU, + "mem_capacity_mb": capMem, "mem_allocatable_mb": allocMem, - "pod_capacity": capPods, - "pod_allocatable": allocPods, + "pod_capacity": capPods, + "pod_allocatable": allocPods, // Lists "conditions": conditions, "taints": taints, @@ -397,20 +397,29 @@ func parseMemToMi(val string) int64 { // @Router /kubernetes-clusters/{id}/pods [get] func (h *KubernetesHandler) GetCapacityAnalysis(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Query("namespace") deps, err := client.ListDeployments() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } type ContainerResource struct { - Name string `json:"name"` - CPUReq string `json:"cpu_request"` - CPULim string `json:"cpu_limit"` - MemReq string `json:"mem_request"` - MemLim string `json:"mem_limit"` + Name string `json:"name"` + CPUReq string `json:"cpu_request"` + CPULim string `json:"cpu_limit"` + MemReq string `json:"mem_request"` + MemLim string `json:"mem_limit"` } type CapacityRow struct { Namespace string `json:"namespace"` @@ -420,7 +429,9 @@ func (h *KubernetesHandler) GetCapacityAnalysis(c *gin.Context) { } result := []CapacityRow{} for _, d := range deps.Items { - if ns != "" && d.Metadata.Namespace != ns { continue } + if ns != "" && d.Metadata.Namespace != ns { + continue + } containers := []ContainerResource{} for _, c := range d.Spec.Template.Spec.Containers { cpuReq := c.Resources.Requests["cpu"] diff --git a/backend/controller/k8s_pod.go b/backend/controller/k8s_pod.go index 161e803..617b3d4 100644 --- a/backend/controller/k8s_pod.go +++ b/backend/controller/k8s_pod.go @@ -198,8 +198,8 @@ func (h *KubernetesHandler) ExecTerminal(c *gin.Context) { // Dial K8s API server via WebSocket with channel protocol subprotocol k8sDialer := &websocket.Dialer{ - TLSClientConfig: client.TLSConfig, - Subprotocols: []string{"v4.channel.k8s.io", "v3.channel.k8s.io", "v5.channel.k8s.io"}, + TLSClientConfig: client.TLSConfig, + Subprotocols: []string{"v4.channel.k8s.io", "v3.channel.k8s.io", "v5.channel.k8s.io"}, HandshakeTimeout: 10 * time.Second, } k8sHeader := http.Header{} @@ -354,30 +354,46 @@ func (h *KubernetesHandler) ExecInPodNS(c *gin.Context) { // Internal helpers that resolve clusterCode and inject namespace func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } pods, err := client.ListPods() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } // M2: inject pod metrics (best-effort) // Logan.Li podMetrics := buildPodMetricsMap(client, ns) type PodSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Status string `json:"status"` - NodeName string `json:"node_name"` - IP string `json:"ip"` - RestartCount int `json:"restart_count"` - Image string `json:"image"` - CPUUsageM string `json:"cpu_usage_m,omitempty"` - MemUsageMi string `json:"mem_usage_mi,omitempty"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Status string `json:"status"` + NodeName string `json:"node_name"` + IP string `json:"ip"` + RestartCount int `json:"restart_count"` + Image string `json:"image"` + CPUUsageM string `json:"cpu_usage_m,omitempty"` + MemUsageMi string `json:"mem_usage_mi,omitempty"` Conditions []k8sclient.PodCondition `json:"conditions,omitempty"` } result := []PodSummary{} for _, p := range pods.Items { - if p.Metadata.Namespace != ns { continue } - image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image } - restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount } + if p.Metadata.Namespace != ns { + continue + } + image := "" + if len(p.Spec.Containers) > 0 { + image = p.Spec.Containers[0].Image + } + restarts := 0 + if len(p.Status.ContainerStatuses) > 0 { + restarts = p.Status.ContainerStatuses[0].RestartCount + } ps := PodSummary{ Namespace: p.Metadata.Namespace, Name: p.Metadata.Name, @@ -398,7 +414,9 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) { } func (h *KubernetesHandler) getPodLogsWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } podName := c.Param("podName") stream := c.Query("stream") == "true" // 日志增强参数:container / sinceTime / previous / tailLines(兼容 tail) // Logan.Li @@ -416,34 +434,61 @@ func (h *KubernetesHandler) getPodLogsWithNS(c *gin.Context, ns string) { Previous: c.Query("previous") == "true", } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } if stream { body, err := client.GetPodLogsStreamWithOptions(ns, podName, opts) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "获取日志流失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "获取日志流失败: " + err.Error()}) + return + } defer body.Close() c.Header("Content-Type", "text/plain; charset=utf-8") c.Header("Cache-Control", "no-cache") c.Stream(func(w io.Writer) bool { - buf := make([]byte, 4096); n, err := body.Read(buf) - if n > 0 { w.Write(buf[:n]); return true } + buf := make([]byte, 4096) + n, err := body.Read(buf) + if n > 0 { + w.Write(buf[:n]) + return true + } return err == nil }) return } logs, err := client.GetPodLogsWithOptions(ns, podName, opts) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "获取日志失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "获取日志失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Message: "success", Data: gin.H{"logs": logs, "pod": podName, "namespace": ns}}) } func (h *KubernetesHandler) execInPodWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } podName := c.Param("podName") - var req struct { Command []string `json:"command"`; Container string `json:"container"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return } + var req struct { + Command []string `json:"command"` + Container string `json:"container"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, model.Response{Code: 400, Message: "参数错误"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } result, err := client.ExecInPod(ns, podName, req.Container, req.Command) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "执行失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "执行失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Data: result}) } @@ -454,11 +499,20 @@ func (h *KubernetesHandler) execInPodWithNS(c *gin.Context, ns string) { // ListDeploymentsAllNS lists all deployments across all namespaces using clusterCode. func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } pods, err := client.ListPods() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } // M2: inject pod metrics (best-effort) // Logan.Li podMetrics := buildPodMetricsMap(client, "") type PodSummary struct { @@ -474,8 +528,14 @@ func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) { } result := []PodSummary{} for _, p := range pods.Items { - image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image } - restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount } + image := "" + if len(p.Spec.Containers) > 0 { + image = p.Spec.Containers[0].Image + } + restarts := 0 + if len(p.Status.ContainerStatuses) > 0 { + restarts = p.Status.ContainerStatuses[0].RestartCount + } ps := PodSummary{ Namespace: p.Metadata.Namespace, Name: p.Metadata.Name, @@ -559,58 +619,80 @@ func parseMemResource(val string) int64 { // formatCpuFromNano converts K8s metrics CPU (nanocores "80410173n") to millicores "80m" func formatCpuFromNano(raw string) string { - if raw == "" { return "" } + if raw == "" { + return "" + } val := raw val = strings.TrimSuffix(val, "n") var nano int64 fmt.Sscanf(val, "%d", &nano) milli := nano / 1000000 - if milli < 1 { milli = 1 } + if milli < 1 { + milli = 1 + } return fmt.Sprintf("%dm", milli) } // parseCpuNanoToMilli converts K8s metrics CPU (nanocores "100354992n") to millicores (int) // Logan.Li func parseCpuNanoToMilli(raw string) int { - if raw == "" { return 0 } + if raw == "" { + return 0 + } val := strings.TrimSuffix(raw, "n") var nano int64 fmt.Sscanf(val, "%d", &nano) milli := nano / 1000000 - if milli < 1 { milli = 1 } + if milli < 1 { + milli = 1 + } return int(milli) } // formatMemFromKi converts K8s metrics memory (Ki "2154292Ki") to Mi "2105Mi" func formatMemFromKi(raw string) string { - if raw == "" { return "" } + if raw == "" { + return "" + } val := strings.TrimSuffix(raw, "Ki") var ki int64 fmt.Sscanf(val, "%d", &ki) mi := ki / 1024 - if mi < 1 { mi = 1 } + if mi < 1 { + mi = 1 + } return fmt.Sprintf("%dMi", mi) } // parseMemKiToMi converts K8s metrics memory (Ki "2154292Ki") to Mi (int) // Logan.Li func parseMemKiToMi(raw string) int { - if raw == "" { return 0 } + if raw == "" { + return 0 + } val := strings.TrimSuffix(raw, "Ki") var ki int64 fmt.Sscanf(val, "%d", &ki) return int(ki / 1024) } - // GetResourceYAMLNS returns the YAML of a K8s resource func (h *KubernetesHandler) GetPodMetrics(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Query("namespace") metrics, err := client.ListPodMetrics(ns) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "metrics API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "metrics API error: " + err.Error()}) + return + } type ContainerMetric struct { Name string `json:"name"` @@ -643,13 +725,22 @@ func (h *KubernetesHandler) DeletePodNS(c *gin.Context) { } func (h *KubernetesHandler) deletePodWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } podName := c.Param("podName") - if podName == "" { c.JSON(400, model.Response{Code: 400, Message: "缺少 podName"}); return } + if podName == "" { + c.JSON(400, model.Response{Code: 400, Message: "缺少 podName"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } if err := client.DeletePod(ns, podName); err != nil { - c.JSON(500, model.Response{Code: 500, Message: "删除 Pod 失败: " + err.Error()}); return + c.JSON(500, model.Response{Code: 500, Message: "删除 Pod 失败: " + err.Error()}) + return } c.JSON(200, model.Response{Code: 200, Message: fmt.Sprintf("Pod %s 已删除", podName)}) } diff --git a/backend/controller/k8s_pod_files.go b/backend/controller/k8s_pod_files.go index cc87dad..7167990 100644 --- a/backend/controller/k8s_pod_files.go +++ b/backend/controller/k8s_pod_files.go @@ -19,20 +19,33 @@ func (h *KubernetesHandler) ListPodFilesNS(c *gin.Context) { } func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } podName := c.Param("podName") var req struct { Path string `json:"path"` Container string `json:"container"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误: " + err.Error()}); return } - if req.Path == "" { req.Path = "/" } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, model.Response{Code: 400, Message: "参数错误: " + err.Error()}) + return + } + if req.Path == "" { + req.Path = "/" + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } // ls -la — BusyBox ignores --time-style, use default "Mon DD HH:MM" format cmd := []string{"ls", "-la", req.Path} result, err := client.ExecInPod(ns, podName, req.Container, cmd) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "执行 ls 失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "执行 ls 失败: " + err.Error()}) + return + } type FileInfo struct { Name string `json:"name"` @@ -45,11 +58,17 @@ func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) { output := result.Stdout // WebSocket output is already clean text for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "total ") { continue } + if line == "" || strings.HasPrefix(line, "total ") { + continue + } fields := strings.Fields(line) - if len(fields) < 7 { continue } + if len(fields) < 7 { + continue + } mode := fields[0] - if len(mode) == 0 || (mode[0] != 'd' && mode[0] != '-' && mode[0] != 'l') { continue } + if len(mode) == 0 || (mode[0] != 'd' && mode[0] != '-' && mode[0] != 'l') { + continue + } size := fields[4] // BusyBox ls date format: "Jun 23 02:17" (3 fields: month day time) // GNU ls --time-style: "2026-06-23T02:17:00" (1 field) @@ -64,7 +83,9 @@ func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) { modTime = strings.Join(fields[5:8], " ") name = strings.Join(fields[8:], " ") } - if name == "" || name == "." || name == ".." { continue } + if name == "" || name == "." || name == ".." { + continue + } files = append(files, FileInfo{ Name: name, Size: size, @@ -82,16 +103,27 @@ func (h *KubernetesHandler) DownloadPodFileNS(c *gin.Context) { } func (h *KubernetesHandler) downloadPodFileWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } podName := c.Param("podName") filePath := c.Query("path") container := c.Query("container") - if filePath == "" { c.JSON(400, model.Response{Code: 400, Message: "path 参数不能为空"}); return } + if filePath == "" { + c.JSON(400, model.Response{Code: 400, Message: "path 参数不能为空"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } cmd := []string{"cat", filePath} result, err := client.ExecInPod(ns, podName, container, cmd) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "读取文件失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "读取文件失败: " + err.Error()}) + return + } content := result.Stdout fileName := path.Base(filePath) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName)) diff --git a/backend/controller/k8s_resource.go b/backend/controller/k8s_resource.go index 0590ecd..90a98c1 100644 --- a/backend/controller/k8s_resource.go +++ b/backend/controller/k8s_resource.go @@ -223,7 +223,6 @@ func (h *KubernetesHandler) SaveTopology(c *gin.Context) { c.JSON(http.StatusOK, model.Response{Code: 200, Message: "保存成功"}) } - // SyncCluster 同步集群资源(连接真实K8s API拉取数据) func (h *KubernetesHandler) ListConfigMaps(c *gin.Context) { clusterID, _ := strconv.Atoi(c.Param("id")) @@ -313,11 +312,19 @@ func (h *KubernetesHandler) ListIngressesNS(c *gin.Context) { } func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } svcs, err := client.ListServices() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } type SvcPortDetail struct { Name string `json:"name,omitempty"` Port int `json:"port"` @@ -326,17 +333,19 @@ func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) { Protocol string `json:"protocol,omitempty"` } type SvcSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Type string `json:"type"` - ClusterIP string `json:"cluster_ip"` - Ports int `json:"ports"` - PortList []SvcPortDetail `json:"portList,omitempty"` - Selector map[string]string `json:"selector,omitempty"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Type string `json:"type"` + ClusterIP string `json:"cluster_ip"` + Ports int `json:"ports"` + PortList []SvcPortDetail `json:"portList,omitempty"` + Selector map[string]string `json:"selector,omitempty"` } result := []SvcSummary{} for _, s := range svcs.Items { - if s.Metadata.Namespace != ns { continue } + if s.Metadata.Namespace != ns { + continue + } ports := []SvcPortDetail{} for _, p := range s.Spec.Ports { ports = append(ports, SvcPortDetail{ @@ -345,28 +354,36 @@ func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) { }) } result = append(result, SvcSummary{ - Namespace: s.Metadata.Namespace, - Name: s.Metadata.Name, - Type: s.Spec.Type, - ClusterIP: s.Spec.ClusterIP, - Ports: len(s.Spec.Ports), - PortList: ports, - Selector: s.Spec.Selector, + Namespace: s.Metadata.Namespace, + Name: s.Metadata.Name, + Type: s.Spec.Type, + ClusterIP: s.Spec.ClusterIP, + Ports: len(s.Spec.Ports), + PortList: ports, + Selector: s.Spec.Selector, }) } c.JSON(200, model.Response{Code: 200, Data: result}) } func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ings, err := client.ListIngresses() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } type PathDetail struct { - Path string `json:"path"` - PathType string `json:"pathType,omitempty"` - BackendSvc string `json:"backendService,omitempty"` + Path string `json:"path"` + PathType string `json:"pathType,omitempty"` + BackendSvc string `json:"backendService,omitempty"` BackendPort string `json:"backendPort,omitempty"` } type RuleDetail struct { @@ -374,20 +391,24 @@ func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) { Paths []PathDetail `json:"paths"` } type IngSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Hosts []string `json:"hosts"` - TLS bool `json:"tls"` - IngressClass string `json:"ingressClass,omitempty"` - Rules []RuleDetail `json:"rules,omitempty"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Hosts []string `json:"hosts"` + TLS bool `json:"tls"` + IngressClass string `json:"ingressClass,omitempty"` + Rules []RuleDetail `json:"rules,omitempty"` } result := []IngSummary{} for _, ing := range ings.Items { - if ing.Metadata.Namespace != ns { continue } + if ing.Metadata.Namespace != ns { + continue + } hosts := []string{} rules := []RuleDetail{} for _, rule := range ing.Spec.Rules { - if rule.Host != "" { hosts = append(hosts, rule.Host) } + if rule.Host != "" { + hosts = append(hosts, rule.Host) + } rd := RuleDetail{Host: rule.Host, Paths: []PathDetail{}} if rule.HTTP != nil { for _, p := range rule.HTTP.Paths { @@ -453,12 +474,21 @@ func (h *KubernetesHandler) ListIngressClasses(c *gin.Context) { func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") pvcs, err := client.ListPVCs(ns) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } type PVCSummary struct { Namespace string `json:"namespace"` Name string `json:"name"` @@ -470,14 +500,16 @@ func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) { for _, p := range pvcs.Items { size := "" if p.Status.Capacity != nil { - if s, ok := p.Status.Capacity["storage"]; ok { size = s } + if s, ok := p.Status.Capacity["storage"]; ok { + size = s + } } result = append(result, PVCSummary{ - Namespace: p.Metadata.Namespace, - Name: p.Metadata.Name, - Phase: p.Status.Phase, - Size: size, - StorageClass: p.Spec.StorageClassName, + Namespace: p.Metadata.Namespace, + Name: p.Metadata.Name, + Phase: p.Status.Phase, + Size: size, + StorageClass: p.Spec.StorageClassName, }) } c.JSON(200, model.Response{Code: 200, Data: result}) @@ -486,11 +518,18 @@ func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) { // DeletePVCNS deletes a PVC (code-based) func (h *KubernetesHandler) DeletePVCNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } if err := client.DeletePVC(c.Param("namespace"), c.Param("name")); err != nil { - c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}); return + c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}) + return } c.JSON(200, model.Response{Code: 200, Message: "已删除"}) } diff --git a/backend/controller/k8s_workload.go b/backend/controller/k8s_workload.go index a7a3d0b..bc5342a 100644 --- a/backend/controller/k8s_workload.go +++ b/backend/controller/k8s_workload.go @@ -208,15 +208,25 @@ func (h *KubernetesHandler) GetDeploymentNS(c *gin.Context) { ns := c.Param("namespace") name := c.Param("name") clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } deps, err := client.ListDeployments() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } for _, d := range deps.Items { if d.Metadata.Namespace == ns && d.Metadata.Name == name { image := "" - if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } + if len(d.Spec.Template.Spec.Containers) > 0 { + image = d.Spec.Template.Spec.Containers[0].Image + } c.JSON(200, model.Response{Code: 200, Data: map[string]interface{}{ "namespace": d.Metadata.Namespace, "name": d.Metadata.Name, @@ -248,11 +258,19 @@ func (h *KubernetesHandler) ResumeDeploymentNS(c *gin.Context) { } func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } deps, err := client.ListDeployments() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } type DepSummary struct { Namespace string `json:"namespace"` Name string `json:"name"` @@ -265,8 +283,13 @@ func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) { } result := []DepSummary{} for _, d := range deps.Items { - if d.Metadata.Namespace != ns { continue } - image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } + if d.Metadata.Namespace != ns { + continue + } + image := "" + if len(d.Spec.Template.Spec.Containers) > 0 { + image = d.Spec.Template.Spec.Containers[0].Image + } result = append(result, DepSummary{ Namespace: d.Metadata.Namespace, Name: d.Metadata.Name, @@ -275,63 +298,131 @@ func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) { ReadyReplicas: d.Status.ReadyReplicas, Image: image, Paused: d.Spec.Paused, - Selector: func() map[string]string { if d.Spec.Selector != nil { return d.Spec.Selector.MatchLabels }; return nil }(), + Selector: func() map[string]string { + if d.Spec.Selector != nil { + return d.Spec.Selector.MatchLabels + } + return nil + }(), }) } c.JSON(200, model.Response{Code: 200, Data: result}) } func (h *KubernetesHandler) scaleDeploymentWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - var req struct { Replicas int `json:"replicas"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + var req struct { + Replicas int `json:"replicas"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, model.Response{Code: 400, Message: "参数错误"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } - if err := client.ScaleDeployment(ns, c.Param("name"), req.Replicas); err != nil { c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } + if err := client.ScaleDeployment(ns, c.Param("name"), req.Replicas); err != nil { + c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Message: fmt.Sprintf("已设置为 %d 副本", req.Replicas)}) } func (h *KubernetesHandler) restartDeploymentWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } - if err := client.RestartDeployment(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } + if err := client.RestartDeployment(ns, c.Param("name")); err != nil { + c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Message: "重启指令已下发"}) } func (h *KubernetesHandler) updateDeploymentImageWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - var req struct { Image string `json:"image"`; ContainerName string `json:"container_name"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return } - if strings.TrimSpace(req.Image) == "" { c.JSON(400, model.Response{Code: 400, Message: "image 不能为空"}); return } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + var req struct { + Image string `json:"image"` + ContainerName string `json:"container_name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, model.Response{Code: 400, Message: "参数错误"}) + return + } + if strings.TrimSpace(req.Image) == "" { + c.JSON(400, model.Response{Code: 400, Message: "image 不能为空"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } - if err := client.UpdateDeploymentImage(ns, c.Param("name"), req.ContainerName, req.Image); err != nil { c.JSON(500, model.Response{Code: 500, Message: "更新镜像失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } + if err := client.UpdateDeploymentImage(ns, c.Param("name"), req.ContainerName, req.Image); err != nil { + c.JSON(500, model.Response{Code: 500, Message: "更新镜像失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Message: fmt.Sprintf("镜像已更新为 %s", req.Image)}) } func (h *KubernetesHandler) pauseDeploymentWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } - if err := client.PauseDeployment(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "暂停更新失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } + if err := client.PauseDeployment(ns, c.Param("name")); err != nil { + c.JSON(500, model.Response{Code: 500, Message: "暂停更新失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Message: "已暂停滚动更新"}) } func (h *KubernetesHandler) resumeDeploymentWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } - if err := client.ResumeDeployment(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "恢复更新失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } + if err := client.ResumeDeployment(ns, c.Param("name")); err != nil { + c.JSON(500, model.Response{Code: 500, Message: "恢复更新失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Message: "已恢复滚动更新"}) } func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } deps, err := client.ListDeployments() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } type DepSummary struct { Namespace string `json:"namespace"` Name string `json:"name"` @@ -342,7 +433,10 @@ func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) { } result := []DepSummary{} for _, d := range deps.Items { - image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } + image := "" + if len(d.Spec.Template.Spec.Containers) > 0 { + image = d.Spec.Template.Spec.Containers[0].Image + } result = append(result, DepSummary{ Namespace: d.Metadata.Namespace, Name: d.Metadata.Name, @@ -358,11 +452,20 @@ func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) { // ListPodsAllNS lists all pods across all namespaces using clusterCode. func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } sts, err := client.ListStatefulSets() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } ns := c.Param("namespace") type StsSummary struct { Namespace string `json:"namespace"` @@ -373,8 +476,13 @@ func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) { } result := []StsSummary{} for _, s := range sts.Items { - if s.Metadata.Namespace != ns { continue } - image := ""; if len(s.Spec.Template.Spec.Containers) > 0 { image = s.Spec.Template.Spec.Containers[0].Image } + if s.Metadata.Namespace != ns { + continue + } + image := "" + if len(s.Spec.Template.Spec.Containers) > 0 { + image = s.Spec.Template.Spec.Containers[0].Image + } result = append(result, StsSummary{ Namespace: s.Metadata.Namespace, Name: s.Metadata.Name, @@ -389,11 +497,20 @@ func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) { // ListDaemonSetsNS lists DaemonSets in a namespace func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ds, err := client.ListDaemonSets() - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } ns := c.Param("namespace") type DsSummary struct { Namespace string `json:"namespace"` @@ -405,8 +522,13 @@ func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) { } result := []DsSummary{} for _, d := range ds.Items { - if d.Metadata.Namespace != ns { continue } - image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } + if d.Metadata.Namespace != ns { + continue + } + image := "" + if len(d.Spec.Template.Spec.Containers) > 0 { + image = d.Spec.Template.Spec.Containers[0].Image + } result = append(result, DsSummary{ Namespace: d.Metadata.Namespace, Name: d.Metadata.Name, @@ -418,18 +540,31 @@ func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) { } c.JSON(200, model.Response{Code: 200, Data: result}) } + // ScaleStatefulSetNS scales a StatefulSet (Kuboard code route) func (h *KubernetesHandler) ScaleStatefulSetNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") name := c.Param("name") - var req struct{ Replicas int `json:"replicas"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return } + var req struct { + Replicas int `json:"replicas"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, model.Response{Code: 400, Message: "参数错误"}) + return + } if err := client.ScaleStatefulSet(ns, name, req.Replicas); err != nil { - c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}); return + c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}) + return } c.JSON(200, model.Response{Code: 200, Message: "StatefulSet 副本数已更新为 " + strconv.Itoa(req.Replicas)}) } @@ -437,16 +572,30 @@ func (h *KubernetesHandler) ScaleStatefulSetNS(c *gin.Context) { // ScaleStatefulSet scales a StatefulSet (ID route) func (h *KubernetesHandler) ScaleStatefulSet(c *gin.Context) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") name := c.Param("name") - var req struct{ Replicas int `json:"replicas"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return } + var req struct { + Replicas int `json:"replicas"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, model.Response{Code: 400, Message: "参数错误"}) + return + } if err := client.ScaleStatefulSet(ns, name, req.Replicas); err != nil { - c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}); return + c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}) + return } c.JSON(200, model.Response{Code: 200, Message: "StatefulSet 副本数已更新为 " + strconv.Itoa(req.Replicas)}) } @@ -454,13 +603,20 @@ func (h *KubernetesHandler) ScaleStatefulSet(c *gin.Context) { // RestartStatefulSetNS restarts a StatefulSet (Kuboard code route) func (h *KubernetesHandler) RestartStatefulSetNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") name := c.Param("name") if err := client.RestartStatefulSet(ns, name); err != nil { - c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}); return + c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}) + return } c.JSON(200, model.Response{Code: 200, Message: "StatefulSet 滚动重启已触发"}) } @@ -468,14 +624,23 @@ func (h *KubernetesHandler) RestartStatefulSetNS(c *gin.Context) { // RestartStatefulSet restarts a StatefulSet (ID route) func (h *KubernetesHandler) RestartStatefulSet(c *gin.Context) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") name := c.Param("name") if err := client.RestartStatefulSet(ns, name); err != nil { - c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}); return + c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}) + return } c.JSON(200, model.Response{Code: 200, Message: "StatefulSet 滚动重启已触发"}) } @@ -483,13 +648,20 @@ func (h *KubernetesHandler) RestartStatefulSet(c *gin.Context) { // RestartDaemonSetNS restarts a DaemonSet (Kuboard code route) func (h *KubernetesHandler) RestartDaemonSetNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") name := c.Param("name") if err := client.RestartDaemonSet(ns, name); err != nil { - c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}); return + c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}) + return } c.JSON(200, model.Response{Code: 200, Message: "DaemonSet 滚动重启已触发"}) } @@ -497,14 +669,23 @@ func (h *KubernetesHandler) RestartDaemonSetNS(c *gin.Context) { // RestartDaemonSet restarts a DaemonSet (ID route) func (h *KubernetesHandler) RestartDaemonSet(c *gin.Context) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") name := c.Param("name") if err := client.RestartDaemonSet(ns, name); err != nil { - c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}); return + c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}) + return } c.JSON(200, model.Response{Code: 200, Message: "DaemonSet 滚动重启已触发"}) } @@ -512,26 +693,44 @@ func (h *KubernetesHandler) RestartDaemonSet(c *gin.Context) { // GetStatefulSetDetailNS returns a single StatefulSet detail (Kuboard code route) func (h *KubernetesHandler) GetStatefulSetDetailNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") name := c.Param("name") sts, err := client.GetStatefulSet(ns, name) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Data: sts}) } // GetDaemonSetDetailNS returns a single DaemonSet detail (Kuboard code route) func (h *KubernetesHandler) GetDaemonSetDetailNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") name := c.Param("name") ds, err := client.GetDaemonSet(ns, name) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Data: ds}) } @@ -545,11 +744,19 @@ func (h *KubernetesHandler) ListReplicaSetsNS(c *gin.Context) { } func (h *KubernetesHandler) listReplicaSetsWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } items, err := client.ListReplicaSetsRaw(ns) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } type RSSummary struct { Namespace string `json:"namespace"` Name string `json:"name"` @@ -564,11 +771,16 @@ func (h *KubernetesHandler) listReplicaSetsWithNS(c *gin.Context, ns string) { result := []RSSummary{} for _, rs := range items { image := "" - if len(rs.Spec.Template.Spec.Containers) > 0 { image = rs.Spec.Template.Spec.Containers[0].Image } + if len(rs.Spec.Template.Spec.Containers) > 0 { + image = rs.Spec.Template.Spec.Containers[0].Image + } revision := rs.Metadata.Annotations["deployment.kubernetes.io/revision"] owner := "" for _, o := range rs.Metadata.OwnerReferences { - if o.Kind == "Deployment" { owner = o.Name; break } + if o.Kind == "Deployment" { + owner = o.Name + break + } } result = append(result, RSSummary{ Namespace: rs.Metadata.Namespace, @@ -591,13 +803,24 @@ func (h *KubernetesHandler) ListDeploymentRevisionsNS(c *gin.Context) { } func (h *KubernetesHandler) listDeploymentRevisionsWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } deploymentName := c.Param("name") - if deploymentName == "" { c.JSON(400, model.Response{Code: 400, Message: "缺少 deployment name"}); return } + if deploymentName == "" { + c.JSON(400, model.Response{Code: 400, Message: "缺少 deployment name"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } items, err := client.ListReplicaSetsRaw(ns) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } type Revision struct { Revision string `json:"revision"` ReplicaSet string `json:"replica_set"` @@ -610,11 +833,18 @@ func (h *KubernetesHandler) listDeploymentRevisionsWithNS(c *gin.Context, ns str for _, rs := range items { isOwner := false for _, o := range rs.Metadata.OwnerReferences { - if o.Kind == "Deployment" && o.Name == deploymentName { isOwner = true; break } + if o.Kind == "Deployment" && o.Name == deploymentName { + isOwner = true + break + } + } + if !isOwner { + continue } - if !isOwner { continue } image := "" - if len(rs.Spec.Template.Spec.Containers) > 0 { image = rs.Spec.Template.Spec.Containers[0].Image } + if len(rs.Spec.Template.Spec.Containers) > 0 { + image = rs.Spec.Template.Spec.Containers[0].Image + } result = append(result, Revision{ Revision: rs.Metadata.Annotations["deployment.kubernetes.io/revision"], ReplicaSet: rs.Metadata.Name, @@ -639,28 +869,44 @@ func (h *KubernetesHandler) RollbackDeploymentNS(c *gin.Context) { } func (h *KubernetesHandler) rollbackDeploymentWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } deploymentName := c.Param("name") - if deploymentName == "" { c.JSON(400, model.Response{Code: 400, Message: "缺少 deployment name"}); return } + if deploymentName == "" { + c.JSON(400, model.Response{Code: 400, Message: "缺少 deployment name"}) + return + } var req struct { Revision int `json:"revision"` ReplicaSet string `json:"replicaSet"` } _ = c.ShouldBindJSON(&req) // 允许空 body(但下面会校验) client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } targetRS := req.ReplicaSet // 通过 revision 定位 ReplicaSet if targetRS == "" && req.Revision > 0 { items, err := client.ListReplicaSetsRaw(ns) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "获取 ReplicaSet 列表失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "获取 ReplicaSet 列表失败: " + err.Error()}) + return + } for _, rs := range items { isOwner := false for _, o := range rs.Metadata.OwnerReferences { - if o.Kind == "Deployment" && o.Name == deploymentName { isOwner = true; break } + if o.Kind == "Deployment" && o.Name == deploymentName { + isOwner = true + break + } + } + if !isOwner { + continue } - if !isOwner { continue } if rs.Metadata.Annotations["deployment.kubernetes.io/revision"] == strconv.Itoa(req.Revision) { targetRS = rs.Metadata.Name break @@ -671,18 +917,31 @@ func (h *KubernetesHandler) rollbackDeploymentWithNS(c *gin.Context, ns string) return } } - if targetRS == "" { c.JSON(400, model.Response{Code: 400, Message: "需要 revision 或 replicaSet 参数"}); return } + if targetRS == "" { + c.JSON(400, model.Response{Code: 400, Message: "需要 revision 或 replicaSet 参数"}) + return + } // 取 ReplicaSet 的 template,patch 到 Deployment.spec.template 实现回滚 rsJSON, err := client.GetReplicaSet(ns, targetRS) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "获取 ReplicaSet 失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "获取 ReplicaSet 失败: " + err.Error()}) + return + } specMap, ok := rsJSON["spec"].(map[string]interface{}) - if !ok { c.JSON(500, model.Response{Code: 500, Message: "ReplicaSet 缺少 spec"}); return } + if !ok { + c.JSON(500, model.Response{Code: 500, Message: "ReplicaSet 缺少 spec"}) + return + } template, ok := specMap["template"].(map[string]interface{}) - if !ok { c.JSON(500, model.Response{Code: 500, Message: "ReplicaSet 缺少 template"}); return } + if !ok { + c.JSON(500, model.Response{Code: 500, Message: "ReplicaSet 缺少 template"}) + return + } if err := client.PatchDeploymentSpecTemplate(ns, deploymentName, template); err != nil { - c.JSON(500, model.Response{Code: 500, Message: "回滚失败: " + err.Error()}); return + c.JSON(500, model.Response{Code: 500, Message: "回滚失败: " + err.Error()}) + return } c.JSON(200, model.Response{ Code: 200, diff --git a/backend/controller/k8s_yaml.go b/backend/controller/k8s_yaml.go index d7ecfbf..cca8e33 100644 --- a/backend/controller/k8s_yaml.go +++ b/backend/controller/k8s_yaml.go @@ -12,9 +12,15 @@ import ( func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") resource := c.Param("resource") // Fallback: specific routes (statefulsets/:name/yaml, daemonsets/:name/yaml) have no :resource param @@ -62,10 +68,14 @@ func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) { case "rolebindings", "rolebinding": apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name) default: - c.JSON(400, model.Response{Code: 400, Message: "unsupported resource type: " + resource}); return + c.JSON(400, model.Response{Code: 400, Message: "unsupported resource type: " + resource}) + return } yamlBytes, err := client.GetResourceYAML(apiPath) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "获取YAML失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "获取YAML失败: " + err.Error()}) + return + } c.Header("Content-Type", "application/yaml") c.String(200, string(yamlBytes)) } @@ -73,9 +83,15 @@ func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) { // UpdateResourceYAMLNS applies YAML to update a K8s resource func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") resource := c.Param("resource") // Fallback: specific routes (statefulsets/:name/yaml, daemonsets/:name/yaml) have no :resource param @@ -90,7 +106,10 @@ func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) { } name := c.Param("name") yamlData, err := io.ReadAll(c.Request.Body) - if err != nil { c.JSON(400, model.Response{Code: 400, Message: "读取body失败"}); return } + if err != nil { + c.JSON(400, model.Response{Code: 400, Message: "读取body失败"}) + return + } var apiPath string switch resource { case "deployments", "deployment": @@ -124,10 +143,12 @@ func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) { case "rolebindings", "rolebinding": apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name) default: - c.JSON(400, model.Response{Code: 400, Message: "unsupported resource type: " + resource}); return + c.JSON(400, model.Response{Code: 400, Message: "unsupported resource type: " + resource}) + return } if err := client.UpdateResourceYAML(apiPath, yamlData); err != nil { - c.JSON(500, model.Response{Code: 500, Message: "更新失败: " + err.Error()}); return + c.JSON(500, model.Response{Code: 500, Message: "更新失败: " + err.Error()}) + return } c.JSON(200, model.Response{Code: 200, Message: "YAML已应用"}) } @@ -173,16 +194,26 @@ func resourceAPIPath(ns, resource, name string) (string, error) { // DeleteResourceYAMLNS deletes a K8s resource (Service/Ingress/ConfigMap etc.) func (h *KubernetesHandler) DeleteResourceYAMLNS(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } ns := c.Param("namespace") resource := c.Param("resource") name := c.Param("name") apiPath, err := resourceAPIPath(ns, resource, name) - if err != nil { c.JSON(400, model.Response{Code: 400, Message: err.Error()}); return } + if err != nil { + c.JSON(400, model.Response{Code: 400, Message: err.Error()}) + return + } if err := client.DeleteResource(apiPath); err != nil { - c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}); return + c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}) + return } c.JSON(200, model.Response{Code: 200, Message: "资源已删除"}) } @@ -190,18 +221,33 @@ func (h *KubernetesHandler) DeleteResourceYAMLNS(c *gin.Context) { // getK8sClient builds a K8s API client from cluster's stored kubeconfig func (h *KubernetesHandler) ApplyYAML(c *gin.Context) { clusterID := h.resolveClusterCode(c.Param("clusterCode")) - if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return } + if clusterID == 0 { + c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}) + return + } client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: err.Error()}) + return + } var req struct { YAML string `json:"yaml"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误: " + err.Error()}); return } - if req.YAML == "" { c.JSON(400, model.Response{Code: 400, Message: "YAML 内容不能为空"}); return } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, model.Response{Code: 400, Message: "参数错误: " + err.Error()}) + return + } + if req.YAML == "" { + c.JSON(400, model.Response{Code: 400, Message: "YAML 内容不能为空"}) + return + } result, err := client.ApplyYAML(req.YAML) - if err != nil { c.JSON(500, model.Response{Code: 500, Message: "Apply 失败: " + err.Error()}); return } + if err != nil { + c.JSON(500, model.Response{Code: 500, Message: "Apply 失败: " + err.Error()}) + return + } c.JSON(200, model.Response{Code: 200, Message: "资源已应用", Data: result}) } diff --git a/backend/controller/ldap_ad.go b/backend/controller/ldap_ad.go index 16922b2..6ac5359 100644 --- a/backend/controller/ldap_ad.go +++ b/backend/controller/ldap_ad.go @@ -151,4 +151,4 @@ func ldapAddADUser(conn *ldap.Conn, userDN, username, displayName, email, passwo modEnable := ldap.NewModifyRequest(userDN, nil) modEnable.Replace("userAccountControl", []string{"512"}) return conn.Modify(modEnable) -} \ No newline at end of file +} diff --git a/backend/controller/llm.go b/backend/controller/llm.go index ca927bd..8791ee7 100644 --- a/backend/controller/llm.go +++ b/backend/controller/llm.go @@ -34,20 +34,10 @@ func maskAPIKey(key string) string { return strings.Repeat("*", len(key)-4) + key[len(key)-4:] } -// canReuseMaskedAPIKey 仅允许原提供商与原 Endpoint 继续使用数据库中的密钥。 // Logan.Li -func canReuseMaskedAPIKey(provider, endpoint string, saved model.LLMConfig) bool { - if saved.ID == 0 || saved.Provider != provider { - return false - } - requestedEndpoint := resolveEndpoint(provider, strings.TrimSpace(endpoint)) - savedEndpoint := resolveEndpoint(saved.Provider, strings.TrimSpace(saved.Endpoint)) - return requestedEndpoint != "" && requestedEndpoint == savedEndpoint -} - // getActiveConfig 读取启用的配置(脱敏后) // Logan.Li func (h *LLMHandler) getActiveConfig(maskKey bool) (model.LLMConfig, error) { var cfg model.LLMConfig - if err := h.db.Take(&cfg).Error; err != nil { + if err := h.db.First(&cfg).Error; err != nil { return cfg, err } if maskKey { @@ -93,13 +83,9 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) { return } var cfg model.LLMConfig - h.db.Take(&cfg) - // 脱敏密钥只能在提供商和 Endpoint 均未改变时复用,避免凭证被转发到其他地址。 // Logan.Li - if strings.Contains(req.APIKey, "****") { - if !canReuseMaskedAPIKey(req.Provider, req.Endpoint, cfg) { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "提供商或 Endpoint 已变化,请重新填写 API Key"}) - return - } + h.db.First(&cfg) + // 若前端回传脱敏值(含 ****),保留原 Key + if strings.Contains(req.APIKey, "****") && cfg.ID != 0 { req.APIKey = cfg.APIKey } cfg.Provider = req.Provider @@ -151,18 +137,12 @@ func resolveEndpoint(provider, endpoint string) string { return "https://open.bigmodel.cn/api/paas/v4" case "qwen": return "https://dashscope.aliyuncs.com/compatible-mode/v1" - case "hunyuan": // 腾讯混元 OpenAI 兼容端点 // Logan.Li - return "https://tokenhub.tencentmaas.com/v1" - case "volcengine": // 火山引擎方舟 OpenAI 兼容端点 // Logan.Li - return "https://ark.cn-beijing.volces.com/api/v3" - case "baidu": // 百度千帆 v2 OpenAI 兼容端点 // Logan.Li - return "https://qianfan.baidubce.com/v2" case "moonshot": return "https://api.moonshot.cn/v1" case "kimi": // Kimi 编码套餐 OpenAI 兼容端点(注意:该端点有客户端白名单,通用调用可能被拒) // Logan.Li return "https://api.kimi.com/coding/v1" case "mimo": // 小米 MiMo OpenAI 兼容端点 // Logan.Li - return "https://api.xiaomimimo.com/v1" + return "https://api.mimo.mi.com/v1" case "anthropic": // Anthropic 官方 Messages 协议(anthropic.go 客户端拼 /v1/messages) // Logan.Li return "https://api.anthropic.com" case "glm-coding": // 智谱 GLM 编码套餐 Anthropic 协议端点(模型 GLM-5.2) // Logan.Li @@ -173,43 +153,20 @@ func resolveEndpoint(provider, endpoint string) string { return "https://api.minimaxi.com/anthropic" case "openai": // OpenAI 官方端点 // Logan.Li return "https://api.openai.com/v1" - case "google": // Gemini OpenAI 兼容端点 // Logan.Li - return "https://generativelanguage.googleapis.com/v1beta/openai" - case "xai": // xAI OpenAI 兼容端点 // Logan.Li - return "https://api.x.ai/v1" - case "mistral": // Mistral OpenAI 兼容端点 // Logan.Li - return "https://api.mistral.ai/v1" - case "groq": // Groq OpenAI 兼容端点 // Logan.Li - return "https://api.groq.com/openai/v1" - case "openrouter": // OpenRouter OpenAI 兼容端点 // Logan.Li - return "https://openrouter.ai/api/v1" - case "together": // Together AI OpenAI 兼容端点 // Logan.Li - return "https://api.together.ai/v1" - case "cerebras": // Cerebras OpenAI 兼容端点 // Logan.Li - return "https://api.cerebras.ai/v1" case "siliconflow": // 硅基流动 OpenAI 兼容端点 // Logan.Li return "https://api.siliconflow.cn/v1" case "ollama": return "http://localhost:11434/v1" case "vllm": // vLLM OpenAI 兼容端点 // Logan.Li return "http://localhost:8000/v1" - case "lmstudio": // LM Studio OpenAI 兼容端点 // Logan.Li - return "http://localhost:1234/v1" - case "new-api", "custom": // 网关地址必须由用户明确填写 // Logan.Li - return "" - default: // 未知的历史供应商保持 OpenAI 默认兼容 // Logan.Li + default: // openai / custom return "https://api.openai.com/v1" } } // isLocalLLMProvider 本地/自建模型,API Key 可选 // Logan.Li func isLocalLLMProvider(provider string) bool { - return provider == "ollama" || provider == "vllm" || provider == "lmstudio" || provider == "custom" -} - -// requiresExplicitEndpoint 判断是否必须手动填写网关地址。 // Logan.Li -func requiresExplicitEndpoint(provider string) bool { - return provider == "new-api" || provider == "custom" + return provider == "ollama" || provider == "vllm" || provider == "custom" } // resolveProtocol 根据提供商推断接入协议(决定走 OpenAI 还是 Anthropic 客户端) // Logan.Li diff --git a/backend/controller/llm_chat.go b/backend/controller/llm_chat.go index cf05bdb..98612b1 100644 --- a/backend/controller/llm_chat.go +++ b/backend/controller/llm_chat.go @@ -195,11 +195,10 @@ func (h *LLMHandler) llmConfigFromTestRequest(req model.SaveLLMConfigRequest) (m } if strings.Contains(req.APIKey, "****") { var saved model.LLMConfig - if err := h.db.Take(&saved).Error; err != nil || !canReuseMaskedAPIKey(req.Provider, req.Endpoint, saved) { + if err := h.db.First(&saved).Error; err != nil { if !isLocalLLMProvider(req.Provider) { - return cfg, "提供商或 Endpoint 已变化,请重新填写 API Key" + return cfg, "请填写 API Key" } - cfg.APIKey = "" } else { cfg.APIKey = saved.APIKey } @@ -210,9 +209,6 @@ func (h *LLMHandler) llmConfigFromTestRequest(req model.SaveLLMConfigRequest) (m if cfg.Model == "" { return cfg, "请填写模型名" } - if requiresExplicitEndpoint(cfg.Provider) && strings.TrimSpace(cfg.Endpoint) == "" { - return cfg, "请填写 Endpoint" - } if cfg.APIKey == "" && !isLocalLLMProvider(cfg.Provider) { return cfg, "请填写 API Key" } @@ -230,7 +226,7 @@ func (h *LLMHandler) TestLLMConnection(c *gin.Context) { if req.Provider != "" { cfg, errMsg = h.llmConfigFromTestRequest(req) } else { - if err := h.db.Take(&cfg).Error; err != nil { + if err := h.db.First(&cfg).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先填写配置并测试连接"}) return } diff --git a/backend/controller/llm_client.go b/backend/controller/llm_client.go index 17df285..3d6c574 100644 --- a/backend/controller/llm_client.go +++ b/backend/controller/llm_client.go @@ -23,4 +23,4 @@ func buildLLMConfigFromModel(cfg model.LLMConfig) llm.Config { // buildLLMClientFromConfig 从 model.LLMConfig 构造 LLM 客户端(OpenAI/Anthropic 自动路由) // Logan.Li func buildLLMClientFromConfig(cfg model.LLMConfig) llm.Client { return llm.NewClient(buildLLMConfigFromModel(cfg)) -} \ No newline at end of file +} diff --git a/backend/controller/llm_models.go b/backend/controller/llm_models.go deleted file mode 100644 index 387b3d8..0000000 --- a/backend/controller/llm_models.go +++ /dev/null @@ -1,181 +0,0 @@ -package controller - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "sort" - "strings" - "time" - - "ezops/model" - - "github.com/gin-gonic/gin" -) - -const maxLLMModelListBytes = 2 << 20 - -type llmModelListItem struct { - ID string `json:"id"` - Name string `json:"name"` - Model string `json:"model"` -} - -// resolveModelListCredential 解析草稿配置,并在前端回传脱敏值时复用已保存密钥。 // Logan.Li -func (h *LLMHandler) resolveModelListCredential(req model.SaveLLMConfigRequest) (string, string, string) { - provider := strings.TrimSpace(req.Provider) - if provider == "" { - return "", "", "请选择提供商" - } - endpoint := resolveEndpoint(provider, strings.TrimSpace(req.Endpoint)) - if endpoint == "" { - return "", "", "请填写 Endpoint" - } - apiKey := strings.TrimSpace(req.APIKey) - if strings.Contains(apiKey, "****") { - var saved model.LLMConfig - if err := h.db.Take(&saved).Error; err != nil || !canReuseMaskedAPIKey(provider, req.Endpoint, saved) { - return "", "", "提供商或 Endpoint 已变化,请重新填写 API Key" - } - apiKey = saved.APIKey - } - if apiKey == "" && !isLocalLLMProvider(provider) { - return "", "", "请填写 API Key" - } - return endpoint, apiKey, "" -} - -// buildLLMModelListURL 将基础地址转换为兼容协议的模型列表地址。 // Logan.Li -func buildLLMModelListURL(provider, endpoint string) (string, error) { - base := strings.TrimRight(strings.TrimSpace(endpoint), "/") - if resolveProtocol(provider) == "anthropic" && !strings.HasSuffix(base, "/v1") { - base += "/v1" - } - parsed, err := url.Parse(base + "/models") - if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { - return "", fmt.Errorf("Endpoint 必须是有效的 HTTP 或 HTTPS 地址") - } - return parsed.String(), nil -} - -// decodeLLMModelItems 兼容 OpenAI/New API 的 data 数组及部分本地服务的 models 数组。 // Logan.Li -func decodeLLMModelItems(raw json.RawMessage) []string { - var items []llmModelListItem - if err := json.Unmarshal(raw, &items); err == nil { - models := make([]string, 0, len(items)) - for _, item := range items { - name := strings.TrimSpace(item.ID) - if name == "" { - name = strings.TrimSpace(item.Name) - } - if name == "" { - name = strings.TrimSpace(item.Model) - } - if name != "" { - models = append(models, name) - } - } - return models - } - var names []string - _ = json.Unmarshal(raw, &names) - return names -} - -// parseLLMModelList 提取、去重并排序上游返回的模型 ID。 // Logan.Li -func parseLLMModelList(raw []byte) ([]string, error) { - var payload map[string]json.RawMessage - if err := json.Unmarshal(raw, &payload); err != nil { - return nil, fmt.Errorf("解析模型列表失败: %w", err) - } - models := decodeLLMModelItems(payload["data"]) - models = append(models, decodeLLMModelItems(payload["models"])...) - unique := make(map[string]struct{}, len(models)) - result := make([]string, 0, len(models)) - for _, item := range models { - name := strings.TrimSpace(item) - if name == "" { - continue - } - if _, exists := unique[name]; exists { - continue - } - unique[name] = struct{}{} - result = append(result, name) - } - sort.Strings(result) - return result, nil -} - -// requestLLMModelList 请求上游模型目录,禁止跨域重定向以免凭证被转发。 // Logan.Li -func requestLLMModelList(ctx context.Context, provider, endpoint, apiKey string) ([]string, error) { - requestURL, err := buildLLMModelListURL(provider, endpoint) - if err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) - if err != nil { - return nil, fmt.Errorf("构造模型列表请求失败: %w", err) - } - req.Header.Set("Accept", "application/json") - if apiKey != "" { - req.Header.Set("Authorization", "Bearer "+apiKey) - } - if resolveProtocol(provider) == "anthropic" { - req.Header.Set("x-api-key", apiKey) - req.Header.Set("anthropic-version", "2023-06-01") - } - client := &http.Client{ - Timeout: 20 * time.Second, - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, - } - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("读取模型列表失败: %w", err) - } - defer resp.Body.Close() - raw, err := io.ReadAll(io.LimitReader(resp.Body, maxLLMModelListBytes+1)) - if err != nil { - return nil, fmt.Errorf("读取模型列表响应失败: %w", err) - } - if len(raw) > maxLLMModelListBytes { - return nil, fmt.Errorf("模型列表响应超过 2 MiB 限制") - } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - message := []rune(strings.TrimSpace(string(raw))) - if len(message) > 1000 { - message = message[:1000] - } - return nil, fmt.Errorf("模型服务返回 HTTP %d: %s", resp.StatusCode, string(message)) - } - return parseLLMModelList(raw) -} - -// ListLLMModels 从当前草稿 Endpoint 读取可用模型,便于 New API/One API 和本地服务配置。 // Logan.Li -// POST /api/v1/aiops/llm-config/models // Logan.Li -func (h *LLMHandler) ListLLMModels(c *gin.Context) { - var req model.SaveLLMConfigRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) - return - } - endpoint, apiKey, errMsg := h.resolveModelListCredential(req) - if errMsg != "" { - c.JSON(http.StatusOK, model.Response{Code: 400, Message: errMsg}) - return - } - models, err := requestLLMModelList(c.Request.Context(), req.Provider, endpoint, apiKey) - if err != nil { - c.JSON(http.StatusOK, model.Response{Code: 502, Message: err.Error()}) - return - } - c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ - "models": models, - "total": len(models), - }}) -} diff --git a/backend/controller/llm_models_test.go b/backend/controller/llm_models_test.go deleted file mode 100644 index ec67eaa..0000000 --- a/backend/controller/llm_models_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package controller - -import ( - "context" - "net/http" - "net/http/httptest" - "reflect" - "testing" - - "ezops/model" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -func TestParseLLMModelList(t *testing.T) { - raw := []byte(`{"data":[{"id":"model-b"},{"id":"model-a"},{"id":"model-b"}],"models":[{"name":"model-c"}]}`) - got, err := parseLLMModelList(raw) - if err != nil { - t.Fatalf("parseLLMModelList() error = %v", err) - } - want := []string{"model-a", "model-b", "model-c"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("parseLLMModelList() = %#v, want %#v", got, want) - } -} - -func TestRequestLLMModelListForOpenAICompatibleGateway(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/models" { - t.Errorf("request path = %q, want /v1/models", r.URL.Path) - } - if got := r.Header.Get("Authorization"); got != "Bearer test-key" { - t.Errorf("Authorization = %q", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data":[{"id":"gpt-test"}]}`)) - })) - defer server.Close() - - models, err := requestLLMModelList(context.Background(), "new-api", server.URL+"/v1", "test-key") - if err != nil { - t.Fatalf("requestLLMModelList() error = %v", err) - } - if !reflect.DeepEqual(models, []string{"gpt-test"}) { - t.Fatalf("requestLLMModelList() = %#v", models) - } -} - -func TestRequestLLMModelListForAnthropic(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/models" { - t.Errorf("request path = %q, want /v1/models", r.URL.Path) - } - if got := r.Header.Get("x-api-key"); got != "anthropic-key" { - t.Errorf("x-api-key = %q", got) - } - if got := r.Header.Get("anthropic-version"); got != "2023-06-01" { - t.Errorf("anthropic-version = %q", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data":[{"id":"claude-test"}]}`)) - })) - defer server.Close() - - models, err := requestLLMModelList(context.Background(), "anthropic", server.URL, "anthropic-key") - if err != nil { - t.Fatalf("requestLLMModelList() error = %v", err) - } - if !reflect.DeepEqual(models, []string{"claude-test"}) { - t.Fatalf("requestLLMModelList() = %#v", models) - } -} - -func TestResolveModelListCredentialDoesNotReuseKeyAcrossProviders(t *testing.T) { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open database: %v", err) - } - if err := db.AutoMigrate(&model.LLMConfig{}); err != nil { - t.Fatalf("migrate database: %v", err) - } - if err := db.Create(&model.LLMConfig{Provider: "deepseek", APIKey: "deepseek-secret"}).Error; err != nil { - t.Fatalf("create config: %v", err) - } - - handler := NewLLMHandler(db) - _, _, errMsg := handler.resolveModelListCredential(model.SaveLLMConfigRequest{ - Provider: "new-api", - Endpoint: "https://new-api.example.com/v1", - APIKey: "****cret", - }) - if errMsg != "提供商或 Endpoint 已变化,请重新填写 API Key" { - t.Fatalf("provider mismatch error = %q", errMsg) - } -} diff --git a/backend/controller/llm_provider_test.go b/backend/controller/llm_provider_test.go deleted file mode 100644 index 931fa21..0000000 --- a/backend/controller/llm_provider_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package controller - -import ( - "testing" - - "ezops/model" -) - -func TestResolveEndpoint(t *testing.T) { - tests := []struct { - provider string - endpoint string - want string - }{ - {provider: "google", want: "https://generativelanguage.googleapis.com/v1beta/openai"}, - {provider: "openrouter", want: "https://openrouter.ai/api/v1"}, - {provider: "hunyuan", want: "https://tokenhub.tencentmaas.com/v1"}, - {provider: "mimo", want: "https://api.xiaomimimo.com/v1"}, - {provider: "together", want: "https://api.together.ai/v1"}, - {provider: "lmstudio", want: "http://localhost:1234/v1"}, - {provider: "new-api", want: ""}, - {provider: "new-api", endpoint: "https://llm.example.com/v1/", want: "https://llm.example.com/v1"}, - } - - for _, test := range tests { - t.Run(test.provider+test.endpoint, func(t *testing.T) { - if got := resolveEndpoint(test.provider, test.endpoint); got != test.want { - t.Fatalf("resolveEndpoint() = %q, want %q", got, test.want) - } - }) - } -} - -func TestResolveProtocol(t *testing.T) { - for _, provider := range []string{"anthropic", "glm-coding", "minimax-coding"} { - if got := resolveProtocol(provider); got != "anthropic" { - t.Fatalf("resolveProtocol(%q) = %q, want anthropic", provider, got) - } - } - for _, provider := range []string{"openai", "google", "openrouter", "new-api"} { - if got := resolveProtocol(provider); got != "openai" { - t.Fatalf("resolveProtocol(%q) = %q, want openai", provider, got) - } - } -} - -func TestProviderRequirements(t *testing.T) { - if !isLocalLLMProvider("lmstudio") { - t.Fatal("LM Studio should allow an empty API key") - } - if isLocalLLMProvider("new-api") { - t.Fatal("New API should require an API key") - } - if !requiresExplicitEndpoint("new-api") || !requiresExplicitEndpoint("custom") { - t.Fatal("API gateway providers should require an explicit endpoint") - } -} - -func TestCanReuseMaskedAPIKey(t *testing.T) { - saved := model.LLMConfig{ID: 1, Provider: "openai", Endpoint: "https://api.openai.com/v1"} - if !canReuseMaskedAPIKey("openai", "https://api.openai.com/v1/", saved) { - t.Fatal("same provider and endpoint should reuse the saved key") - } - if canReuseMaskedAPIKey("openai", "https://gateway.example.com/v1", saved) { - t.Fatal("changed endpoint must not reuse the saved key") - } - if canReuseMaskedAPIKey("new-api", saved.Endpoint, saved) { - t.Fatal("changed provider must not reuse the saved key") - } -} diff --git a/backend/controller/log.go b/backend/controller/log.go index d398777..d518279 100644 --- a/backend/controller/log.go +++ b/backend/controller/log.go @@ -74,10 +74,10 @@ func (h *LogHandler) CreateSource(c *gin.Context) { configJSON, _ := json.Marshal(req.Config) now := time.Now() item := model.LogSource{ - Name: req.Name, - Type: req.Type, - Status: "active", - Config: string(configJSON), + Name: req.Name, + Type: req.Type, + Status: "active", + Config: string(configJSON), LastSeen: &now, } h.db.Create(&item) diff --git a/backend/controller/log_datasource.go b/backend/controller/log_datasource.go index 74630cf..a2fdf70 100644 --- a/backend/controller/log_datasource.go +++ b/backend/controller/log_datasource.go @@ -76,7 +76,7 @@ func (h *LogDSHandler) Create(c *gin.Context) { } ds := model.LogDataSource{ Name: req.Name, Type: req.Type, URL: strings.TrimRight(req.URL, "/"), - AuthType: normalizeLogDSAuthType(req.AuthType), + AuthType: normalizeLogDSAuthType(req.AuthType), BasicAuth: req.BasicAuth, BasicAuthUser: req.BasicAuthUser, BasicAuthPass: req.BasicAuthPass, Token: req.Token, Headers: req.Headers, SkipTLSVerify: req.SkipTLSVerify, DefaultIndex: req.DefaultIndex, @@ -439,4 +439,4 @@ func normalizeLogDSAuthType(t string) string { default: return "none" } -} \ No newline at end of file +} diff --git a/backend/controller/log_proxy_explore.go b/backend/controller/log_proxy_explore.go index 288b039..7c428b8 100644 --- a/backend/controller/log_proxy_explore.go +++ b/backend/controller/log_proxy_explore.go @@ -477,4 +477,4 @@ func formatLokiMetricTimestamp(v interface{}) string { } } return formatLokiTimestamp(fmt.Sprintf("%v", v)) -} \ No newline at end of file +} diff --git a/backend/controller/log_scope_role.go b/backend/controller/log_scope_role.go index 77b711c..b5e8741 100644 --- a/backend/controller/log_scope_role.go +++ b/backend/controller/log_scope_role.go @@ -198,4 +198,4 @@ func (h *LogScopeRoleHandler) PreviewLogScope(c *gin.Context) { "matched": matched, "total": len(matched), }}) -} \ No newline at end of file +} diff --git a/backend/controller/metrics.go b/backend/controller/metrics.go index 087c21b..3667f9f 100644 --- a/backend/controller/metrics.go +++ b/backend/controller/metrics.go @@ -134,15 +134,15 @@ func (h *MetricsHandler) TopHosts(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "5")) type HostRank struct { - Hostname string `json:"hostname"` - IP string `json:"ip"` - CPUUsage float64 `json:"cpu_usage"` - MemUsage float64 `json:"memory_usage"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + CPUUsage float64 `json:"cpu_usage"` + MemUsage float64 `json:"memory_usage"` DiskUsage float64 `json:"disk_usage"` - CPUCores int `json:"cpu_cores"` - MemMB int64 `json:"memory_mb"` - DiskGB int64 `json:"disk_gb"` - Status string `json:"status"` + CPUCores int `json:"cpu_cores"` + MemMB int64 `json:"memory_mb"` + DiskGB int64 `json:"disk_gb"` + Status string `json:"status"` } var servers []model.Server @@ -166,15 +166,15 @@ func (h *MetricsHandler) TopHosts(c *gin.Context) { result := make([]HostRank, len(servers)) for i, s := range servers { result[i] = HostRank{ - Hostname: s.Hostname, - IP: s.IP, - CPUUsage: s.CPUUsage, - MemUsage: s.MemoryUsage, + Hostname: s.Hostname, + IP: s.IP, + CPUUsage: s.CPUUsage, + MemUsage: s.MemoryUsage, DiskUsage: s.DiskUsage, - CPUCores: s.CPU, - MemMB: s.Memory, - DiskGB: s.Disk, - Status: s.Status, + CPUCores: s.CPU, + MemMB: s.Memory, + DiskGB: s.Disk, + Status: s.Status, } } if result == nil { diff --git a/backend/controller/metrics_ws.go b/backend/controller/metrics_ws.go index ae955f7..12c316d 100644 --- a/backend/controller/metrics_ws.go +++ b/backend/controller/metrics_ws.go @@ -14,7 +14,7 @@ import ( // MetricsUpdate 单个主机的指标更新 // Logan.Li type MetricsUpdate struct { - Type string `json:"type"` // "metrics_update" + Type string `json:"type"` // "metrics_update" Hostname string `json:"hostname"` CPU float64 `json:"cpu_usage"` Memory float64 `json:"memory_usage"` diff --git a/backend/controller/middleware_monitor.go b/backend/controller/middleware_monitor.go index d07df03..7e1f0dd 100644 --- a/backend/controller/middleware_monitor.go +++ b/backend/controller/middleware_monitor.go @@ -15,8 +15,8 @@ import ( "ezops/model" "ezops/pkg/systask" - _ "github.com/go-sql-driver/mysql" "github.com/gin-gonic/gin" + _ "github.com/go-sql-driver/mysql" "gorm.io/gorm" ) @@ -28,8 +28,8 @@ import ( // MiddlewareConfig 存储在 MonitorTarget.Config JSON 字段中 type MiddlewareConfig struct { Password string `json:"password,omitempty"` - Username string `json:"username,omitempty"` // MySQL 专用 - Database string `json:"database,omitempty"` // MySQL 专用 + Username string `json:"username,omitempty"` // MySQL 专用 + Database string `json:"database,omitempty"` // MySQL 专用 RedisMode string `json:"redis_mode,omitempty"` // Redis: standalone / cluster } diff --git a/backend/controller/middleware_monitor_redis.go b/backend/controller/middleware_monitor_redis.go index 3d79d18..234a2cb 100644 --- a/backend/controller/middleware_monitor_redis.go +++ b/backend/controller/middleware_monitor_redis.go @@ -98,4 +98,4 @@ func buildRedisMetrics(info string, dbSize int64, clusterInfo, mode string) map[ } return result -} \ No newline at end of file +} diff --git a/backend/controller/middleware_monitor_seed.go b/backend/controller/middleware_monitor_seed.go index 0a31d92..6476888 100644 --- a/backend/controller/middleware_monitor_seed.go +++ b/backend/controller/middleware_monitor_seed.go @@ -14,12 +14,12 @@ import ( // middlewareSeedResult 种子创建结果 // Logan.Li type middlewareSeedResult struct { - Created int `json:"created"` - Skipped []string `json:"skipped"` - Failed []string `json:"failed"` - Total int64 `json:"total"` - Message string `json:"message"` - Success bool `json:"success"` + Created int `json:"created"` + Skipped []string `json:"skipped"` + Failed []string `json:"failed"` + Total int64 `json:"total"` + Message string `json:"message"` + Success bool `json:"success"` } // SeedDefaultMiddlewareTargets 首次启动时创建平台默认 Redis/MySQL 监控目标 // Logan.Li @@ -117,4 +117,4 @@ func seedDefaultMiddlewareTargets(db *gorm.DB) middlewareSeedResult { result.Message = "未创建任何默认目标" } return result -} \ No newline at end of file +} diff --git a/backend/controller/monitor_alert_rule.go b/backend/controller/monitor_alert_rule.go index 4ac3d47..1f504a5 100644 --- a/backend/controller/monitor_alert_rule.go +++ b/backend/controller/monitor_alert_rule.go @@ -282,4 +282,4 @@ func (h *AlertHandler) ResolveUnifiedAlert(c *gin.Context) { return } c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已恢复"}) -} \ No newline at end of file +} diff --git a/backend/controller/pipeline.go b/backend/controller/pipeline.go index 1d526c0..20c68b2 100644 --- a/backend/controller/pipeline.go +++ b/backend/controller/pipeline.go @@ -6,8 +6,9 @@ import ( "strconv" "time" - "ezops/model" "ezops/middleware" + "ezops/model" + "ezops/pkg/crypto" ezredis "ezops/pkg/redis" "ezops/pkg/toolinstaller" @@ -57,11 +58,12 @@ type PipelineHandler struct { db *gorm.DB cache ezredis.Cache toolInstaller *toolinstaller.ToolInstaller + crypt *crypto.SecretBox } // NewPipelineHandler 创建流水线处理器 -func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache, ti *toolinstaller.ToolInstaller) *PipelineHandler { - h := &PipelineHandler{db: db, cache: cache, toolInstaller: ti} +func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache, ti *toolinstaller.ToolInstaller, masterKey string) *PipelineHandler { + h := &PipelineHandler{db: db, cache: cache, toolInstaller: ti, crypt: crypto.NewSecretBox(masterKey)} h.ensureBuildMasterConfig() return h } @@ -69,7 +71,7 @@ func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache, ti *toolinstaller.Tool // PipelineListItem 列表项(含最近一次运行摘要)// Logan.Li type PipelineListItem struct { model.Pipeline - LastRunStages string `json:"last_run_stages,omitempty"` // JSON: [{name,status}] + LastRunStages string `json:"last_run_stages,omitempty"` // JSON: [{name,status}] LastImageTag string `json:"last_image_tag,omitempty"` LastCommitID string `json:"last_commit_id,omitempty"` LastRunDuration int `json:"last_duration,omitempty"` @@ -112,7 +114,11 @@ func (h *PipelineHandler) ListPipelines(c *gin.Context) { db.Count(&total) var pipelines []model.Pipeline - db.Preload("Environment").Offset(query.GetOffset()).Limit(query.GetLimit()).Order("updated_at DESC").Find(&pipelines) + db.Preload("Environment"). + Offset(query.GetOffset()). + Limit(query.GetLimit()). + Order("CASE WHEN last_run_at IS NULL THEN 1 ELSE 0 END ASC, last_run_at DESC, updated_at DESC"). + Find(&pipelines) // 批量获取每个流水线最近一次运行的摘要(阶段状态 + 镜像 + 耗时)// Logan.Li items := make([]PipelineListItem, len(pipelines)) @@ -123,7 +129,7 @@ func (h *PipelineHandler) ListPipelines(c *gin.Context) { } // 子查询:每个 pipeline_id 的最新 run id var latestRuns []model.PipelineRun - h.db.Where("id IN (SELECT MAX(id) FROM ops_pipeline_runs WHERE pipeline_id IN ? AND status IN ('success','failed','running','pending') GROUP BY pipeline_id)", pids). + h.db.Where("id IN (SELECT MAX(id) FROM deploy_pipeline_runs WHERE pipeline_id IN ? AND status IN ('success','failed','running','pending') GROUP BY pipeline_id)", pids). Find(&latestRuns) runMap := map[uint]*model.PipelineRun{} for i := range latestRuns { @@ -147,8 +153,8 @@ func (h *PipelineHandler) ListPipelines(c *gin.Context) { } c.JSON(http.StatusOK, model.Response{ - Code: 200, - Data: model.ListResponse{List: items, Total: total}, + Code: 200, + Data: model.ListResponse{List: items, Total: total}, }) } @@ -211,30 +217,29 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) { } pipeline := model.Pipeline{ - Name: req.Name, - Code: req.Code, - JobName: req.JobName, - Description: req.Description, - Type: req.Type, - Source: req.Source, - Branch: req.Branch, - Dockerfile: req.Dockerfile, - Stages: req.Stages, - Variables: req.Variables, - DeployMode: req.DeployMode, - DeployTarget: req.DeployTarget, - ServerIDs: req.ServerIDs, - DeployRemotePath: req.DeployRemotePath, - DeployScript: req.DeployScript, - K8sClusterID: req.K8sClusterID, - K8sNamespace: req.K8sNamespace, - K8sDeployment: req.K8sDeployment, - K8sContainerName: req.K8sContainerName, - RepoID: req.RepoID, - RegistryID: req.RegistryID, + Name: req.Name, + Code: req.Code, + JobName: req.JobName, + Description: req.Description, + Type: req.Type, + CIProvider: normalizeCIProvider(req.CIProvider), + Source: req.Source, + Branch: req.Branch, + Dockerfile: req.Dockerfile, + Stages: req.Stages, + Variables: req.Variables, + DeployMode: req.DeployMode, + DeployTarget: req.DeployTarget, + ServerIDs: req.ServerIDs, + DeployRemotePath: req.DeployRemotePath, + DeployScript: req.DeployScript, + K8sClusterID: req.K8sClusterID, + K8sNamespace: req.K8sNamespace, + K8sDeployment: req.K8sDeployment, + K8sContainerName: req.K8sContainerName, GitCredentialID: req.GitCredentialID, RegistryCredentialID: req.RegistryCredentialID, - EnvironmentID: req.EnvironmentID, + EnvironmentID: req.EnvironmentID, // 触发配置 TriggerMode: req.TriggerMode, WatchBranches: req.WatchBranches, @@ -242,11 +247,13 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) { CronExpr: req.CronExpr, WebhookToken: req.WebhookToken, // 缓存 - CachePaths: req.CachePaths, + CachePaths: req.CachePaths, + JenkinsJobName: req.JenkinsJobName, + JenkinsJobURL: req.JenkinsJobURL, // 并发控制 MaxExecutors: req.MaxExecutors, BuildNodeID: req.BuildNodeID, - Enabled: true, + Enabled: true, } if err := h.db.Create(&pipeline).Error; err != nil { @@ -301,38 +308,105 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) { } updates := map[string]interface{}{} - if req.Name != "" { updates["name"] = req.Name } - if req.JobName != "" { updates["job_name"] = req.JobName } - if req.Description != "" { updates["description"] = req.Description } - if req.Type != "" { updates["type"] = req.Type } - if req.Source != "" { updates["source"] = req.Source } - if req.Branch != "" { updates["branch"] = req.Branch } - if req.Dockerfile != "" { updates["dockerfile"] = req.Dockerfile } - if req.Stages != "" { updates["stages"] = req.Stages } - if req.Variables != "" { updates["variables"] = req.Variables } - if req.DeployMode != "" { updates["deploy_mode"] = req.DeployMode } - if req.DeployTarget != "" { updates["deploy_target"] = req.DeployTarget } - if req.ServerIDs != "" { updates["server_ids"] = req.ServerIDs } - if req.DeployRemotePath != "" { updates["deploy_remote_path"] = req.DeployRemotePath } - if req.DeployScript != "" { updates["deploy_script"] = req.DeployScript } - if req.K8sNamespace != "" { updates["k8s_namespace"] = req.K8sNamespace } - if req.K8sDeployment != "" { updates["k8s_deployment"] = req.K8sDeployment } - if req.K8sContainerName != "" { updates["k8s_container_name"] = req.K8sContainerName } - if req.K8sClusterID != nil { updates["k8s_cluster_id"] = req.K8sClusterID } - if req.RepoID != nil { updates["repo_id"] = req.RepoID } - if req.RegistryID != nil { updates["registry_id"] = req.RegistryID } - if req.GitCredentialID != nil { updates["git_credential_id"] = req.GitCredentialID } - if req.RegistryCredentialID != nil { updates["registry_credential_id"] = req.RegistryCredentialID } - if req.EnvironmentID != nil { updates["environment_id"] = req.EnvironmentID } - if req.TriggerMode != "" { updates["trigger_mode"] = req.TriggerMode } - if req.WatchBranches != "" { updates["watch_branches"] = req.WatchBranches } - if req.ExcludeBranch != "" { updates["exclude_branch"] = req.ExcludeBranch } - if req.CronExpr != "" { updates["cron_expr"] = req.CronExpr } - if req.WebhookToken != "" { updates["webhook_token"] = req.WebhookToken } - if req.CachePaths != "" { updates["cache_paths"] = req.CachePaths } - if req.Enabled != nil { updates["enabled"] = req.Enabled } - if req.MaxExecutors > 0 { updates["max_executors"] = req.MaxExecutors } - if req.BuildNodeID != nil { updates["build_node_id"] = req.BuildNodeID } + if req.Name != "" { + updates["name"] = req.Name + } + if req.JobName != "" { + updates["job_name"] = req.JobName + } + if req.Description != "" { + updates["description"] = req.Description + } + if req.Type != "" { + updates["type"] = req.Type + } + if req.CIProvider != "" { + updates["ci_provider"] = normalizeCIProvider(req.CIProvider) + } + if req.Source != "" { + updates["source"] = req.Source + } + if req.Branch != "" { + updates["branch"] = req.Branch + } + if req.Dockerfile != "" { + updates["dockerfile"] = req.Dockerfile + } + if req.Stages != "" { + updates["stages"] = req.Stages + } + if req.Variables != "" { + updates["variables"] = req.Variables + } + if req.DeployMode != "" { + updates["deploy_mode"] = req.DeployMode + } + if req.DeployTarget != "" { + updates["deploy_target"] = req.DeployTarget + } + if req.ServerIDs != "" { + updates["server_ids"] = req.ServerIDs + } + if req.DeployRemotePath != "" { + updates["deploy_remote_path"] = req.DeployRemotePath + } + if req.DeployScript != "" { + updates["deploy_script"] = req.DeployScript + } + if req.K8sNamespace != "" { + updates["k8s_namespace"] = req.K8sNamespace + } + if req.K8sDeployment != "" { + updates["k8s_deployment"] = req.K8sDeployment + } + if req.K8sContainerName != "" { + updates["k8s_container_name"] = req.K8sContainerName + } + if req.K8sClusterID != nil { + updates["k8s_cluster_id"] = req.K8sClusterID + } + if req.GitCredentialID != nil { + updates["git_credential_id"] = req.GitCredentialID + } + if req.RegistryCredentialID != nil { + updates["registry_credential_id"] = req.RegistryCredentialID + } + if req.EnvironmentID != nil { + updates["environment_id"] = req.EnvironmentID + } + if req.TriggerMode != "" { + updates["trigger_mode"] = req.TriggerMode + } + if req.WatchBranches != "" { + updates["watch_branches"] = req.WatchBranches + } + if req.ExcludeBranch != "" { + updates["exclude_branch"] = req.ExcludeBranch + } + if req.CronExpr != "" { + updates["cron_expr"] = req.CronExpr + } + if req.WebhookToken != "" { + updates["webhook_token"] = req.WebhookToken + } + if req.CachePaths != "" { + updates["cache_paths"] = req.CachePaths + } + if req.JenkinsJobName != "" { + updates["jenkins_job_name"] = req.JenkinsJobName + } + if req.JenkinsJobURL != "" { + updates["jenkins_job_url"] = req.JenkinsJobURL + } + if req.Enabled != nil { + updates["enabled"] = req.Enabled + } + if req.MaxExecutors > 0 { + updates["max_executors"] = req.MaxExecutors + } + if req.BuildNodeID != nil { + updates["build_node_id"] = req.BuildNodeID + } if err := h.db.Model(&pipeline).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{ @@ -404,6 +478,11 @@ func (h *PipelineHandler) TriggerPipeline(c *gin.Context) { var req model.TriggerPipelineRequest c.ShouldBindJSON(&req) + if pipeline.CIProvider == "jenkins" { + h.triggerJenkinsPipeline(c, pipeline, req) + return + } + var maxBuildNumber int h.db.Model(&model.PipelineRun{}).Where("pipeline_id = ?", id). Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber) diff --git a/backend/controller/pipeline_executor.go b/backend/controller/pipeline_executor.go index 57b2d74..df351a4 100644 --- a/backend/controller/pipeline_executor.go +++ b/backend/controller/pipeline_executor.go @@ -162,7 +162,7 @@ func (h *PipelineHandler) executePipeline(pipeline model.Pipeline, run model.Pip } runUpdates := map[string]interface{}{ - "workspace_path": workspace, + "workspace_path": workspace, "build_node_name": execCtx.NodeName, } if nodeID > 0 { @@ -545,13 +545,10 @@ func (h *PipelineHandler) runDeploy(workspace string, pipeline model.Pipeline, t // resolvePipelineImage 解析流水线构建镜像全名(与镜像打包阶段一致) // Logan.Li func (h *PipelineHandler) resolvePipelineImage(pipeline model.Pipeline, tag string) string { var registryURL, imageName string - if pipeline.RegistryID != nil { - var reg model.PipelineRegistry - if err := h.db.First(®, *pipeline.RegistryID).Error; err == nil { - registryURL = reg.RegistryURL - if reg.Namespace != "" { - imageName = reg.Namespace + "/" - } + if pipeline.RegistryCredentialID != nil { + var credential model.Credential + if err := h.db.Take(&credential, *pipeline.RegistryCredentialID).Error; err == nil { + registryURL = credential.Summary } } appName := pipeline.Code @@ -838,7 +835,7 @@ func cleanOldWorkspaces(baseDir string, maxAge time.Duration) { var ( pipelineRunningCount = make(map[uint]int) // pipelineID → 当前运行数 - pipelineQueues = make(map[uint][]uint) // pipelineID → 排队 runID 列表 + pipelineQueues = make(map[uint][]uint) // pipelineID → 排队 runID 列表 queueMu sync.Mutex ) @@ -909,9 +906,13 @@ func (h *PipelineHandler) dequeueNext(pipelineID uint) { // 加载 pipeline + run var pipeline model.Pipeline - if err := h.db.First(&pipeline, pipelineID).Error; err != nil { return } + if err := h.db.First(&pipeline, pipelineID).Error; err != nil { + return + } var run model.PipelineRun - if err := h.db.First(&run, nextRunID).Error; err != nil { return } + if err := h.db.First(&run, nextRunID).Error; err != nil { + return + } nodeID, canNode := h.tryAcquireBuildNode(pipeline) if !canNode { @@ -935,8 +936,8 @@ func (h *PipelineHandler) GetQueueStatus(pipelineID uint) map[string]interface{} running := pipelineRunningCount[pipelineID] queued := pipelineQueues[pipelineID] return map[string]interface{}{ - "running": running, - "queued": len(queued), + "running": running, + "queued": len(queued), "queue_ids": queued, } } diff --git a/backend/controller/pipeline_jenkins.go b/backend/controller/pipeline_jenkins.go new file mode 100644 index 0000000..16c41f9 --- /dev/null +++ b/backend/controller/pipeline_jenkins.go @@ -0,0 +1,1362 @@ +package controller + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "html" + "io" + "net/http" + "net/url" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "ezops/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const jenkinsPollInterval = 5 * time.Second + +type jenkinsClient struct { + baseURL string + username string + token string + client *http.Client + crumb map[string]string +} + +type jenkinsJob struct { + Name string `json:"name"` + URL string `json:"url"` + Color string `json:"color"` + Class string `json:"_class"` + Description string `json:"description"` + Jobs []jenkinsJob `json:"jobs"` + Properties []struct { + ParameterDefinitions []jenkinsParameterDefinition `json:"parameterDefinitions"` + } `json:"property"` + ViewName string `json:"-"` + ViewURL string `json:"-"` + Views []jenkinsJobView `json:"-"` + LastBuild *struct { + Number int `json:"number"` + URL string `json:"url"` + Timestamp int64 `json:"timestamp"` + Duration int64 `json:"duration"` + Building bool `json:"building"` + Result string `json:"result"` + } `json:"lastBuild"` +} + +type jenkinsJobView struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type jenkinsParameterDefinition struct { + Class string `json:"_class,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Choices []string `json:"choices,omitempty"` + Default interface{} `json:"default,omitempty"` + DefaultParameterValue *struct { + Value interface{} `json:"value"` + } `json:"defaultParameterValue,omitempty"` +} + +type jenkinsJobTree struct { + Name string `json:"name"` + URL string `json:"url"` + Color string `json:"color"` + Class string `json:"_class"` + Description string `json:"description"` + Jobs []jenkinsJob `json:"jobs"` + Properties []struct { + ParameterDefinitions []jenkinsParameterDefinition `json:"parameterDefinitions"` + } `json:"property"` + Views []jenkinsView `json:"views"` + LastBuild *struct { + Number int `json:"number"` + URL string `json:"url"` + Timestamp int64 `json:"timestamp"` + Duration int64 `json:"duration"` + Building bool `json:"building"` + Result string `json:"result"` + } `json:"lastBuild"` +} + +type jenkinsView struct { + Name string `json:"name"` + URL string `json:"url"` + Jobs []jenkinsJob `json:"jobs"` +} + +type jenkinsQueueItem struct { + ID int `json:"id"` + Cancelled bool `json:"cancelled"` + Why string `json:"why"` + Executable *struct { + Number int `json:"number"` + URL string `json:"url"` + } `json:"executable"` +} + +type jenkinsBuildInfo struct { + Building bool `json:"building"` + Result string `json:"result"` + Number int `json:"number"` + URL string `json:"url"` + Timestamp int64 `json:"timestamp"` + Duration int64 `json:"duration"` +} + +type jenkinsJobMetadata struct { + Source string `json:"source,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + ShellSteps []string `json:"shell_steps,omitempty"` + Parameters []jenkinsParameterDefinition +} + +func normalizeCIProvider(provider string) string { + if strings.EqualFold(provider, "jenkins") { + return "jenkins" + } + return "aiops" +} + +func normalizeJenkinsRefMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "branch", "tag", "both": + return strings.ToLower(strings.TrimSpace(mode)) + default: + return "both" + } +} + +func normalizeJenkinsRefParam(param string) string { + param = strings.TrimSpace(param) + if param == "" { + return "SELECT" + } + return param +} + +func (h *PipelineHandler) getJenkinsConfig() (model.JenkinsConfig, string, error) { + var cfg model.JenkinsConfig + if err := h.db.Order("id ASC").First(&cfg).Error; err != nil { + return cfg, "", err + } + token := "" + if cfg.Token != "" { + plain, err := h.crypt.Decrypt(cfg.Token) + if err != nil { + return cfg, "", err + } + token = plain + } + return cfg, token, nil +} + +func (h *PipelineHandler) jenkinsConfigPayload(cfg model.JenkinsConfig) gin.H { + return gin.H{ + "id": cfg.ID, + "base_url": cfg.BaseURL, + "username": cfg.Username, + "auth_type": cfg.AuthType, + "enabled": cfg.Enabled, + "ref_mode": normalizeJenkinsRefMode(cfg.RefMode), + "ref_param": normalizeJenkinsRefParam(cfg.RefParam), + "token_set": cfg.Token != "", + "last_sync_at": cfg.LastSyncAt, + "created_at": cfg.CreatedAt, + "updated_at": cfg.UpdatedAt, + } +} + +// GetJenkinsConfig 获取 Jenkins 接入配置,密钥不回显。 // Logan.Li +func (h *PipelineHandler) GetJenkinsConfig(c *gin.Context) { + cfg, _, err := h.getJenkinsConfig() + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"enabled": false, "token_set": false}}) + return + } + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取 Jenkins 配置失败"}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Data: h.jenkinsConfigPayload(cfg)}) +} + +// SaveJenkinsConfig 保存 Jenkins 接入配置,Token 为空时保留旧值。 // Logan.Li +func (h *PipelineHandler) SaveJenkinsConfig(c *gin.Context) { + var req model.JenkinsConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") + if _, err := url.ParseRequestURI(baseURL); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 地址格式不正确"}) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + authType := req.AuthType + if authType == "" { + authType = "token" + } + var cfg model.JenkinsConfig + err := h.db.Order("id ASC").First(&cfg).Error + if err != nil && err != gorm.ErrRecordNotFound { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取 Jenkins 配置失败"}) + return + } + tokenCipher := cfg.Token + if req.Token != "" { + enc, encErr := h.crypt.Encrypt(req.Token) + if encErr != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "加密 Jenkins Token 失败"}) + return + } + tokenCipher = enc + } + cfg.BaseURL = baseURL + cfg.Username = strings.TrimSpace(req.Username) + cfg.Token = tokenCipher + cfg.AuthType = authType + cfg.Enabled = enabled + cfg.RefMode = normalizeJenkinsRefMode(req.RefMode) + cfg.RefParam = normalizeJenkinsRefParam(req.RefParam) + if cfg.ID == 0 { + err = h.db.Create(&cfg).Error + } else { + err = h.db.Save(&cfg).Error + } + if err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存 Jenkins 配置失败"}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "保存成功", Data: h.jenkinsConfigPayload(cfg)}) +} + +// TestJenkinsConfig 测试 Jenkins 连接。 // Logan.Li +func (h *PipelineHandler) TestJenkinsConfig(c *gin.Context) { + cfg, token, err := h.getJenkinsConfig() + if err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请先保存 Jenkins 配置"}) + return + } + client := newJenkinsClient(cfg.BaseURL, cfg.Username, token) + if err := client.ensureCrumb(c.Request.Context()); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 连接失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功", Data: gin.H{"base_url": cfg.BaseURL, "crumb": client.crumb != nil}}) +} + +// SyncJenkinsJobs 扫描 Jenkins Jobs 并同步成 AIOps 流水线。 // Logan.Li +func (h *PipelineHandler) SyncJenkinsJobs(c *gin.Context) { + var req model.JenkinsSyncRequest + _ = c.ShouldBindJSON(&req) + cfg, token, err := h.getJenkinsConfig() + if err != nil || !cfg.Enabled { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 未配置或未启用"}) + return + } + client := newJenkinsClient(cfg.BaseURL, cfg.Username, token) + jobs, err := client.listJobs(c.Request.Context()) + if err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "同步 Jenkins Jobs 失败: " + err.Error()}) + return + } + created, updated, failed := 0, 0, 0 + for _, job := range jobs { + meta := client.jobMetadata(c.Request.Context(), job.URL) + jobViewsJSON := encodeJenkinsJobViews(job.Views) + jobParamsJSON := encodeJenkinsParameters(mergeJenkinsParameters(job.Parameters(), meta.Parameters)) + jobStepsJSON := encodeJenkinsSteps(meta.ShellSteps) + jobLastRunAt := jenkinsLastBuildTime(job) + jobBranch := meta.DefaultBranch + if jobBranch == "" { + jobBranch = "main" + } + code := pipelineCodeFromJenkinsJob(req.Prefix, job.Name) + uniqueCode := h.uniquePipelineCode(code) + var p model.Pipeline + err := h.db.Where("ci_provider = ? AND (jenkins_job_url = ? OR jenkins_job_name = ?)", "jenkins", job.URL, job.Name).First(&p).Error + if err == gorm.ErrRecordNotFound { + p = model.Pipeline{ + Name: job.Name, + Code: uniqueCode, + JobName: uniqueCode, + Description: job.Description, + Type: "build", + CIProvider: "jenkins", + Source: meta.Source, + Branch: jobBranch, + DeployMode: "tag", + DeployTarget: "ssh", + TriggerMode: "manual", + MaxExecutors: 1, + Enabled: true, + JenkinsJobName: job.Name, + JenkinsJobURL: job.URL, + JenkinsView: job.ViewName, + JenkinsViewURL: job.ViewURL, + JenkinsViews: jobViewsJSON, + JenkinsParams: jobParamsJSON, + JenkinsSteps: jobStepsJSON, + LastRunAt: jobLastRunAt, + LastStatus: jenkinsColorToStatus(job.Color), + } + if err := h.db.Create(&p).Error; err == nil { + created++ + } else { + failed++ + } + continue + } + if err != nil { + failed++ + continue + } + if req.Overwrite { + p.Name = job.Name + p.Description = job.Description + } + p.CIProvider = "jenkins" + p.JenkinsJobName = job.Name + p.JenkinsJobURL = job.URL + p.JenkinsView = job.ViewName + p.JenkinsViewURL = job.ViewURL + p.JenkinsViews = jobViewsJSON + p.JenkinsParams = jobParamsJSON + p.JenkinsSteps = jobStepsJSON + if meta.Source != "" { + p.Source = meta.Source + } + if meta.DefaultBranch != "" { + p.Branch = meta.DefaultBranch + } + if jobLastRunAt != nil { + p.LastRunAt = jobLastRunAt + } + p.LastStatus = jenkinsColorToStatus(job.Color) + if err := h.db.Save(&p).Error; err == nil { + updated++ + } else { + failed++ + } + } + now := time.Now() + _ = h.db.Model(&model.JenkinsConfig{}).Where("id = ?", cfg.ID).Update("last_sync_at", &now).Error + message := "同步完成" + if len(jobs) == 0 { + message = "同步完成,但未发现 Jenkins Jobs" + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: message, Data: gin.H{"created": created, "updated": updated, "failed": failed, "total": len(jobs), "views": countJenkinsViews(jobs)}}) +} + +// GetJenkinsRefs 只读获取 Jenkins Job 关联 Git 仓库的分支或 Tag 候选,不修改 Jenkins 配置。 // Logan.Li +func (h *PipelineHandler) GetJenkinsRefs(c *gin.Context) { + code := strings.TrimSpace(c.Query("code")) + refType := strings.ToLower(strings.TrimSpace(c.DefaultQuery("type", "branch"))) + if refType != "tag" { + refType = "branch" + } + var pipeline model.Pipeline + q := h.db.Where("ci_provider = ?", "jenkins") + if code != "" { + q = q.Where("code = ? OR job_name = ? OR jenkins_job_name = ?", code, code, code) + } else if id, err := strconv.ParseUint(c.Query("pipeline_id"), 10, 64); err == nil && id > 0 { + q = q.Where("id = ?", id) + } else { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少流水线编码"}) + return + } + if err := q.First(&pipeline).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "Jenkins 流水线不存在"}) + return + } + source := strings.TrimSpace(pipeline.Source) + if source == "" { + if cfg, token, err := h.getJenkinsConfig(); err == nil && cfg.Enabled { + client := newJenkinsClient(cfg.BaseURL, cfg.Username, token) + meta := client.jobMetadata(c.Request.Context(), pipeline.JenkinsJobURL) + source = meta.Source + if source != "" || meta.DefaultBranch != "" || len(meta.ShellSteps) > 0 { + updates := map[string]interface{}{} + if source != "" { + updates["source"] = source + } + if meta.DefaultBranch != "" { + updates["branch"] = meta.DefaultBranch + } + if len(meta.ShellSteps) > 0 { + updates["jenkins_steps"] = encodeJenkinsSteps(meta.ShellSteps) + } + _ = h.db.Model(&model.Pipeline{}).Where("id = ?", pipeline.ID).Updates(updates).Error + } + } + } + choices := jenkinsStaticRefChoices(pipeline, refType) + if source != "" { + refs, err := listGitRefs(c.Request.Context(), source, refType) + if err == nil && len(refs) > 0 { + choices = refs + } + } + c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"source": source, "type": refType, "refs": choices}}) +} + +func (h *PipelineHandler) triggerJenkinsPipeline(c *gin.Context, pipeline model.Pipeline, req model.TriggerPipelineRequest) { + cfg, token, err := h.getJenkinsConfig() + if err != nil || !cfg.Enabled { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 未配置或未启用"}) + return + } + var maxBuildNumber int + h.db.Model(&model.PipelineRun{}).Where("pipeline_id = ?", pipeline.ID). + Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber) + ref := req.Tag + if ref == "" { + ref = req.Branch + } + if ref == "" { + ref = pipeline.Branch + } + refMode := normalizeJenkinsRefMode(cfg.RefMode) + if refMode == "tag" && strings.TrimSpace(req.Tag) == "" { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该 Jenkins 模式要求选择 Tag"}) + return + } + if refMode == "branch" && strings.TrimSpace(req.Branch) == "" { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该 Jenkins 模式要求选择分支"}) + return + } + username, _ := c.Get("username") + userStr, _ := username.(string) + now := time.Now() + run := model.PipelineRun{ + PipelineID: pipeline.ID, + BuildNumber: maxBuildNumber + 1, + Status: "running", + Branch: ref, + ImageTag: req.Tag, + TriggeredBy: userStr, + StartedAt: &now, + ExternalURL: pipeline.JenkinsJobURL, + BuildNodeName: "Jenkins", + } + if err := h.db.Create(&run).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "触发失败"}) + return + } + client := newJenkinsClient(cfg.BaseURL, cfg.Username, token) + params := map[string]string{} + for k, v := range req.Variables { + params[k] = v + } + if ref != "" { + params[preferredJenkinsRefParam(pipeline, cfg.RefParam)] = ref + } + go h.executeJenkinsRun(c.Request.Context(), client, pipeline, run, params) + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已触发 Jenkins", Data: run}) +} + +func (h *PipelineHandler) executeJenkinsRun(parent context.Context, client *jenkinsClient, pipeline model.Pipeline, run model.PipelineRun, params map[string]string) { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour) + defer cancel() + logKey := fmt.Sprintf("pipeline:run:%d:logs", run.ID) + logFn := h.jenkinsLogFn(ctx, logKey) + start := time.Now() + logFn("info", "Jenkins", ">>> 触发 Jenkins Job: "+pipeline.JenkinsJobName) + queueURL, err := client.trigger(ctx, pipeline, params) + if err != nil { + h.finishJenkinsRun(run, pipeline, "failed", start, "", "", logKey, fmt.Sprintf("触发失败: %v", err)) + return + } + _ = parent + logFn("info", "Jenkins", ">>> Jenkins 队列: "+queueURL) + build, err := client.waitQueue(ctx, queueURL, logFn) + if err != nil { + h.finishJenkinsRun(run, pipeline, "failed", start, "", queueURL, logKey, fmt.Sprintf("获取构建号失败: %v", err)) + return + } + buildNumber := strconv.Itoa(build.Number) + _ = h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]interface{}{ + "external_run_id": buildNumber, + "external_url": build.URL, + "build_number": build.Number, + }).Error + logFn("info", "Jenkins", fmt.Sprintf(">>> Jenkins Build #%d: %s", build.Number, build.URL)) + final, err := client.waitBuild(ctx, build.URL, logFn) + if err != nil { + h.finishJenkinsRun(run, pipeline, "failed", start, buildNumber, build.URL, logKey, fmt.Sprintf("轮询失败: %v", err)) + return + } + status := "failed" + if final.Result == "SUCCESS" { + status = "success" + } else if final.Result == "ABORTED" { + status = "cancelled" + } + h.finishJenkinsRun(run, pipeline, status, start, buildNumber, final.URL, logKey, "Jenkins result: "+final.Result) +} + +func (h *PipelineHandler) jenkinsLogFn(ctx context.Context, logKey string) func(string, string, string) { + return func(level, stage, msg string) { + entry := LogEntry{Time: time.Now().Format("15:04:05"), Level: level, Stage: stage, Message: msg} + if h.cache == nil { + return + } + existing, _ := h.cache.Get(ctx, logKey) + var entries []LogEntry + if existing != "" { + _ = json.Unmarshal([]byte(existing), &entries) + } + entries = append(entries, entry) + buf, _ := json.Marshal(entries) + _ = h.cache.Set(ctx, logKey, string(buf), 30*time.Minute) + } +} + +func (h *PipelineHandler) finishJenkinsRun(run model.PipelineRun, pipeline model.Pipeline, status string, start time.Time, externalID, externalURL, logKey, message string) { + now := time.Now() + duration := int(now.Sub(start).Seconds()) + logFn := h.jenkinsLogFn(context.Background(), logKey) + level := "info" + if status == "failed" { + level = "error" + } + logFn(level, "Jenkins", ">>> "+message) + logs, _ := h.cache.Get(context.Background(), logKey) + updates := map[string]interface{}{ + "status": status, + "duration": duration, + "finished_at": &now, + "logs": logs, + } + if externalID != "" { + updates["external_run_id"] = externalID + } + if externalURL != "" { + updates["external_url"] = externalURL + } + _ = h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates).Error + pUpdates := map[string]interface{}{"last_status": status, "last_run_at": now, "run_count": gorm.Expr("run_count + ?", 1)} + if status == "success" { + pUpdates["success_count"] = gorm.Expr("success_count + ?", 1) + } else if status == "failed" { + pUpdates["fail_count"] = gorm.Expr("fail_count + ?", 1) + } + _ = h.db.Model(&model.Pipeline{}).Where("id = ?", pipeline.ID).Updates(pUpdates).Error +} + +func newJenkinsClient(baseURL, username, token string) *jenkinsClient { + return &jenkinsClient{ + baseURL: strings.TrimRight(baseURL, "/"), + username: username, + token: token, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *jenkinsClient) request(ctx context.Context, method, rawURL string, form url.Values) (*http.Response, error) { + var body *strings.Reader + if form != nil { + body = strings.NewReader(form.Encode()) + } else { + body = strings.NewReader("") + } + req, err := http.NewRequestWithContext(ctx, method, rawURL, body) + if err != nil { + return nil, err + } + if c.username != "" || c.token != "" { + req.SetBasicAuth(c.username, c.token) + } + if form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + for k, v := range c.crumb { + req.Header.Set(k, v) + } + return c.client.Do(req) +} + +func (c *jenkinsClient) ensureCrumb(ctx context.Context) error { + resp, err := c.request(ctx, http.MethodGet, c.baseURL+"/crumbIssuer/api/json", nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden { + return nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("crumb status %d", resp.StatusCode) + } + var data struct { + Field string `json:"crumbRequestField"` + Crumb string `json:"crumb"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return err + } + if data.Field != "" && data.Crumb != "" { + c.crumb = map[string]string{data.Field: data.Crumb} + } + return nil +} + +func (c *jenkinsClient) listJobs(ctx context.Context) ([]jenkinsJob, error) { + if err := c.ensureCrumb(ctx); err != nil { + return nil, err + } + visited := map[string]bool{} + return c.listJobsAt(ctx, c.baseURL, 0, visited) +} + +func (c *jenkinsClient) listJobsAt(ctx context.Context, rootURL string, depth int, visited map[string]bool) ([]jenkinsJob, error) { + if depth > 8 { + return nil, nil + } + apiURL := strings.TrimRight(rootURL, "/") + "/api/json?tree=" + url.QueryEscape("name,url,color,description,_class,property[parameterDefinitions[_class,name,type,description,choices,defaultParameterValue[value]]],lastBuild[number,url,timestamp,duration,building,result],jobs[name,url,color,description,_class,property[parameterDefinitions[_class,name,type,description,choices,defaultParameterValue[value]]],lastBuild[number,url,timestamp,duration,building,result],jobs[name,url]],views[name,url,jobs[name,url,color,description,_class,property[parameterDefinitions[_class,name,type,description,choices,defaultParameterValue[value]]],lastBuild[number,url,timestamp,duration,building,result]]]") + resp, err := c.request(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("jobs api status %d", resp.StatusCode) + } + var root jenkinsJobTree + if err := json.NewDecoder(resp.Body).Decode(&root); err != nil { + return nil, err + } + out := make([]jenkinsJob, 0) + if len(root.Jobs) == 0 && isBuildableJenkinsJob(root.Name, root.URL, root.Color, root.Class, root.LastBuild != nil) { + return []jenkinsJob{{ + Name: root.Name, + URL: root.URL, + Color: root.Color, + Class: root.Class, + Description: root.Description, + LastBuild: root.LastBuild, + }}, nil + } + merged := map[string]jenkinsJob{} + for _, job := range root.Jobs { + children, err := c.collectJenkinsJob(ctx, job, depth, visited) + if err != nil { + return nil, err + } + mergeJenkinsJobs(merged, children...) + } + for _, view := range root.Views { + for _, job := range view.Jobs { + viewVisited := map[string]bool{} + children, err := c.collectJenkinsJob(ctx, job, depth, viewVisited) + if err != nil { + return nil, err + } + for i := range children { + children[i].ViewName = view.Name + children[i].ViewURL = view.URL + children[i].Views = appendJenkinsJobView(children[i].Views, jenkinsJobView{Name: view.Name, URL: view.URL}) + } + mergeJenkinsJobs(merged, children...) + } + } + keys := make([]string, 0, len(merged)) + for key := range merged { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + job := merged[key] + out = append(out, job) + } + return out, nil +} + +func (c *jenkinsClient) collectJenkinsJob(ctx context.Context, job jenkinsJob, depth int, visited map[string]bool) ([]jenkinsJob, error) { + key := strings.TrimRight(job.URL, "/") + if key == "" || visited[key] { + return nil, nil + } + visited[key] = true + if len(job.Jobs) > 0 || isContainerJenkinsJob(job.Class) { + children, err := c.listJobsAt(ctx, job.URL, depth+1, visited) + if err != nil { + return nil, err + } + if len(children) > 0 { + return children, nil + } + if !isBuildableJenkinsJob(job.Name, job.URL, job.Color, job.Class, job.LastBuild != nil) { + return nil, nil + } + } + if !isBuildableJenkinsJob(job.Name, job.URL, job.Color, job.Class, job.LastBuild != nil) { + return nil, nil + } + return []jenkinsJob{job}, nil +} + +func (c *jenkinsClient) jobMetadata(ctx context.Context, jobURL string) jenkinsJobMetadata { + var meta jenkinsJobMetadata + xmlText := c.configXML(ctx, jobURL) + if xmlText == "" { + return meta + } + meta.Source = firstXMLValue(xmlText, `(?s).*?(.*?)`) + if meta.Source == "" { + meta.Source = firstXMLValue(xmlText, `(?s)]*>.*?(.*?)`) + } + meta.DefaultBranch = normalizeJenkinsBranch(firstXMLValue(xmlText, `(?s).*?(.*?)`)) + meta.ShellSteps = allXMLValues(xmlText, `(?s).*?(.*?)`) + meta.Parameters = parseJenkinsConfigParameters(xmlText) + if meta.Source == "" { + meta.Source = sourceFromJenkinsParameters(meta.Parameters) + } + return meta +} + +func (c *jenkinsClient) configXML(ctx context.Context, jobURL string) string { + jobURL = strings.TrimRight(jobURL, "/") + if jobURL == "" { + return "" + } + resp, err := c.request(ctx, http.MethodGet, jobURL+"/config.xml", nil) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "" + } + b, _ := io.ReadAll(resp.Body) + return string(b) +} + +func firstXMLValue(raw, pattern string) string { + values := allXMLValues(raw, pattern) + if len(values) == 0 { + return "" + } + return values[0] +} + +func allXMLValues(raw, pattern string) []string { + re := regexp.MustCompile(pattern) + matches := re.FindAllStringSubmatch(raw, -1) + out := make([]string, 0, len(matches)) + for _, match := range matches { + if len(match) < 2 { + continue + } + value := strings.TrimSpace(html.UnescapeString(match[1])) + if value != "" { + out = append(out, value) + } + } + return out +} + +func normalizeJenkinsBranch(branch string) string { + branch = strings.TrimSpace(branch) + branch = strings.TrimPrefix(branch, "*/") + branch = strings.TrimPrefix(branch, "origin/") + if branch == "" || strings.ContainsAny(branch, "*$") { + return "" + } + return branch +} + +func parseJenkinsConfigParameters(raw string) []jenkinsParameterDefinition { + re := regexp.MustCompile(`(?s)<([^/][^>\s]*ParameterDefinition[^>]*)>(.*?)]*ParameterDefinition>`) + matches := re.FindAllStringSubmatch(raw, -1) + out := make([]jenkinsParameterDefinition, 0, len(matches)) + for _, match := range matches { + if len(match) < 3 { + continue + } + className := strings.TrimSpace(match[1]) + body := match[2] + name := firstXMLValue(body, `(?s)(.*?)`) + if name == "" { + continue + } + def := jenkinsParameterDefinition{ + Class: className, + Name: name, + Type: className, + Description: firstXMLValue(body, `(?s)(.*?)`), + Choices: parseJenkinsConfigChoices(body), + } + if v := firstXMLValue(body, `(?s)(.*?)`); v != "" { + def.Default = v + } else if v := firstXMLValue(body, `(?s).*?(.*?)`); v != "" { + def.Default = v + } + out = append(out, def) + } + return out +} + +func parseJenkinsConfigChoices(body string) []string { + choicesBlock := firstXMLValue(body, `(?s)]*>(.*?)`) + if choicesBlock != "" { + choices := allXMLValues(choicesBlock, `(?s)(.*?)`) + if len(choices) > 0 { + return uniqueSortedStrings(choices) + } + } + value := firstXMLValue(body, `(?s)(.*?)`) + if value == "" { + value = firstXMLValue(body, `(?s)(.*?)`) + } + if strings.Contains(value, ",") || strings.Contains(value, "\n") { + parts := regexp.MustCompile(`[,\n\r]+`).Split(value, -1) + return uniqueSortedStrings(parts) + } + return nil +} + +func mergeJenkinsParameters(primary, fallback []jenkinsParameterDefinition) []jenkinsParameterDefinition { + merged := map[string]jenkinsParameterDefinition{} + order := []string{} + add := func(def jenkinsParameterDefinition) { + name := strings.TrimSpace(def.Name) + if name == "" { + return + } + existing, ok := merged[name] + if !ok { + order = append(order, name) + merged[name] = def + return + } + if existing.Type == "" { + existing.Type = def.Type + } + if existing.Class == "" { + existing.Class = def.Class + } + if existing.Description == "" { + existing.Description = def.Description + } + if len(existing.Choices) == 0 { + existing.Choices = def.Choices + } + if existing.Default == nil { + existing.Default = def.Default + } + merged[name] = existing + } + for _, def := range primary { + add(def) + } + for _, def := range fallback { + add(def) + } + out := make([]jenkinsParameterDefinition, 0, len(order)) + for _, name := range order { + out = append(out, merged[name]) + } + return out +} + +func sourceFromJenkinsParameters(params []jenkinsParameterDefinition) string { + for _, param := range params { + name := strings.ToLower(strings.TrimSpace(param.Name)) + if name != "git_url" && name != "giturl" && name != "repo_url" && name != "repository" { + continue + } + if param.Default != nil { + if v := strings.TrimSpace(fmt.Sprint(param.Default)); strings.Contains(v, ".git") || strings.Contains(v, "://") || strings.Contains(v, "@") { + return v + } + } + } + return "" +} + +func mergeJenkinsJobs(merged map[string]jenkinsJob, jobs ...jenkinsJob) { + for _, job := range jobs { + key := strings.TrimRight(job.URL, "/") + if key == "" { + key = job.Name + } + existing, ok := merged[key] + if !ok { + job.Views = normalizeJenkinsJobViews(job.Views) + job.ViewName, job.ViewURL = preferredJenkinsJobView(job.Views, job.ViewName, job.ViewURL) + merged[key] = job + continue + } + existing.Views = normalizeJenkinsJobViews(append(existing.Views, job.Views...)) + existing.ViewName, existing.ViewURL = preferredJenkinsJobView(existing.Views, existing.ViewName, existing.ViewURL) + if existing.Color == "" { + existing.Color = job.Color + } + if existing.Description == "" { + existing.Description = job.Description + } + if existing.LastBuild == nil { + existing.LastBuild = job.LastBuild + } + merged[key] = existing + } +} + +func appendJenkinsJobView(views []jenkinsJobView, view jenkinsJobView) []jenkinsJobView { + if strings.TrimSpace(view.Name) == "" { + return views + } + for _, item := range views { + if item.Name == view.Name && item.URL == view.URL { + return views + } + } + return append(views, view) +} + +func normalizeJenkinsJobViews(views []jenkinsJobView) []jenkinsJobView { + out := make([]jenkinsJobView, 0, len(views)) + for _, view := range views { + out = appendJenkinsJobView(out, view) + } + sort.Slice(out, func(i, j int) bool { + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + return out +} + +func preferredJenkinsJobView(views []jenkinsJobView, fallbackName, fallbackURL string) (string, string) { + for _, view := range views { + if !strings.EqualFold(view.Name, "all") { + return view.Name, view.URL + } + } + for _, view := range views { + if strings.TrimSpace(view.Name) != "" { + return view.Name, view.URL + } + } + return fallbackName, fallbackURL +} + +func encodeJenkinsJobViews(views []jenkinsJobView) string { + views = normalizeJenkinsJobViews(views) + if len(views) == 0 { + return "" + } + buf, err := json.Marshal(views) + if err != nil { + return "" + } + return string(buf) +} + +func countJenkinsViews(jobs []jenkinsJob) int { + seen := map[string]bool{} + for _, job := range jobs { + for _, view := range job.Views { + if strings.TrimSpace(view.Name) != "" && !strings.EqualFold(view.Name, "all") { + seen[view.Name] = true + } + } + } + return len(seen) +} + +func (job jenkinsJob) Parameters() []jenkinsParameterDefinition { + out := []jenkinsParameterDefinition{} + for _, prop := range job.Properties { + for _, def := range prop.ParameterDefinitions { + if strings.TrimSpace(def.Name) == "" { + continue + } + if def.Default == nil && def.DefaultParameterValue != nil { + def.Default = def.DefaultParameterValue.Value + } + out = append(out, def) + } + } + return out +} + +func encodeJenkinsParameters(params []jenkinsParameterDefinition) string { + if len(params) == 0 { + return "" + } + buf, err := json.Marshal(params) + if err != nil { + return "" + } + return string(buf) +} + +func encodeJenkinsSteps(steps []string) string { + if len(steps) == 0 { + return "" + } + items := make([]gin.H, 0, len(steps)) + for i, step := range steps { + items = append(items, gin.H{"type": "shell", "name": fmt.Sprintf("Shell #%d", i+1), "command": step}) + } + buf, err := json.Marshal(items) + if err != nil { + return "" + } + return string(buf) +} + +func preferredJenkinsRefParam(pipeline model.Pipeline, fallback string) string { + params := decodeJenkinsParameters(pipeline.JenkinsParams) + for _, param := range params { + if strings.EqualFold(param.Name, fallback) { + return param.Name + } + } + for _, param := range params { + if strings.Contains(param.Type, "GitParameterDefinition") || strings.HasPrefix(param.Type, "PT_") { + return param.Name + } + } + return normalizeJenkinsRefParam(fallback) +} + +func decodeJenkinsParameters(raw string) []jenkinsParameterDefinition { + var params []jenkinsParameterDefinition + if strings.TrimSpace(raw) == "" { + return params + } + _ = json.Unmarshal([]byte(raw), ¶ms) + return params +} + +func jenkinsStaticRefChoices(pipeline model.Pipeline, refType string) []string { + out := []string{} + for _, param := range decodeJenkinsParameters(pipeline.JenkinsParams) { + if len(param.Choices) > 0 { + for _, choice := range param.Choices { + if strings.TrimSpace(choice) != "" { + out = append(out, choice) + } + } + } + if param.Default != nil { + if v := strings.TrimSpace(fmt.Sprint(param.Default)); v != "" { + out = append(out, v) + } + } + } + if refType == "branch" && strings.TrimSpace(pipeline.Branch) != "" { + out = append(out, pipeline.Branch) + } + return uniqueSortedStrings(out) +} + +func uniqueSortedStrings(items []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" || seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + sort.Strings(out) + return out +} + +func listGitRefs(ctx context.Context, repoURL, refType string) ([]string, error) { + args := []string{"ls-remote", "--refs"} + if refType == "tag" { + args = append(args, "--tags") + } else { + args = append(args, "--heads") + } + args = append(args, repoURL) + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", args...) + out, err := cmd.Output() + if err != nil { + return nil, err + } + prefix := "refs/heads/" + if refType == "tag" { + prefix = "refs/tags/" + } + refs := []string{} + for _, line := range strings.Split(string(out), "\n") { + parts := strings.Fields(line) + if len(parts) < 2 || !strings.HasPrefix(parts[1], prefix) { + continue + } + ref := strings.TrimPrefix(parts[1], prefix) + ref = strings.TrimSuffix(ref, "^{}") + if ref != "" { + refs = append(refs, ref) + } + } + return uniqueSortedStrings(refs), nil +} + +func jenkinsLastBuildTime(job jenkinsJob) *time.Time { + if job.LastBuild == nil || job.LastBuild.Timestamp <= 0 { + return nil + } + t := time.UnixMilli(job.LastBuild.Timestamp) + return &t +} + +func isContainerJenkinsJob(className string) bool { + className = strings.ToLower(className) + return strings.Contains(className, "folder") || + strings.Contains(className, "multibranch") || + strings.Contains(className, "organizationfolder") +} + +func isBuildableJenkinsJob(name, jobURL, color, className string, hasLastBuild bool) bool { + if strings.TrimSpace(name) == "" || strings.TrimSpace(jobURL) == "" { + return false + } + if isContainerJenkinsJob(className) { + return false + } + className = strings.ToLower(className) + return color != "" || + hasLastBuild || + strings.Contains(className, "workflowjob") || + strings.Contains(className, "freestyleproject") || + strings.Contains(className, "mavenmoduleset") || + strings.Contains(className, "matrixproject") || + strings.Contains(className, "job") +} + +func (c *jenkinsClient) trigger(ctx context.Context, pipeline model.Pipeline, params map[string]string) (string, error) { + if err := c.ensureCrumb(ctx); err != nil { + return "", err + } + jobURL := strings.TrimRight(pipeline.JenkinsJobURL, "/") + if jobURL == "" { + jobURL = c.baseURL + "/job/" + url.PathEscape(pipeline.JenkinsJobName) + } + form := url.Values{} + for k, v := range params { + if strings.TrimSpace(v) != "" { + form.Set(k, v) + } + } + endpoint := jobURL + "/build" + if len(form) > 0 { + endpoint = jobURL + "/buildWithParameters" + } + resp, err := c.request(ctx, http.MethodPost, endpoint, form) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("trigger status %d", resp.StatusCode) + } + queueURL := resp.Header.Get("Location") + if queueURL == "" { + queueURL = jobURL + "/lastBuild" + } + return queueURL, nil +} + +func (c *jenkinsClient) waitQueue(ctx context.Context, queueURL string, logFn func(string, string, string)) (jenkinsBuildInfo, error) { + if strings.Contains(queueURL, "/lastBuild") { + return c.getBuild(ctx, queueURL) + } + apiURL := strings.TrimRight(queueURL, "/") + "/api/json" + for { + select { + case <-ctx.Done(): + return jenkinsBuildInfo{}, ctx.Err() + case <-time.After(jenkinsPollInterval): + } + resp, err := c.request(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return jenkinsBuildInfo{}, err + } + var item jenkinsQueueItem + err = json.NewDecoder(resp.Body).Decode(&item) + resp.Body.Close() + if err != nil { + return jenkinsBuildInfo{}, err + } + if item.Cancelled { + return jenkinsBuildInfo{}, fmt.Errorf("queue cancelled: %s", item.Why) + } + if item.Executable != nil { + return jenkinsBuildInfo{Number: item.Executable.Number, URL: item.Executable.URL, Building: true}, nil + } + if item.Why != "" { + logFn("info", "Jenkins", ">>> 等待队列: "+item.Why) + } + } +} + +func (c *jenkinsClient) waitBuild(ctx context.Context, buildURL string, logFn func(string, string, string)) (jenkinsBuildInfo, error) { + var consoleOffset int64 + for { + build, err := c.getBuild(ctx, buildURL) + if err != nil { + return build, err + } + console, nextOffset, _ := c.progressiveConsoleText(ctx, buildURL, consoleOffset) + if console != "" { + logJenkinsConsoleChunk(logFn, console) + consoleOffset = nextOffset + } + if !build.Building && build.Result != "" { + console, _, _ := c.progressiveConsoleText(ctx, buildURL, consoleOffset) + if console != "" { + logJenkinsConsoleChunk(logFn, console) + } + return build, nil + } + logFn("info", "Jenkins", fmt.Sprintf(">>> Build #%d 仍在执行...", build.Number)) + select { + case <-ctx.Done(): + return build, ctx.Err() + case <-time.After(jenkinsPollInterval): + } + } +} + +func (c *jenkinsClient) getBuild(ctx context.Context, buildURL string) (jenkinsBuildInfo, error) { + resp, err := c.request(ctx, http.MethodGet, strings.TrimRight(buildURL, "/")+"/api/json", nil) + if err != nil { + return jenkinsBuildInfo{}, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return jenkinsBuildInfo{}, fmt.Errorf("build api status %d", resp.StatusCode) + } + var info jenkinsBuildInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return info, err + } + if info.URL == "" { + info.URL = strings.TrimRight(buildURL, "/") + } + return info, nil +} + +func (c *jenkinsClient) consoleText(ctx context.Context, buildURL string) string { + resp, err := c.request(ctx, http.MethodGet, strings.TrimRight(buildURL, "/")+"/consoleText", nil) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "" + } + b, _ := io.ReadAll(resp.Body) + return string(b) +} + +func (c *jenkinsClient) progressiveConsoleText(ctx context.Context, buildURL string, start int64) (string, int64, bool) { + endpoint := fmt.Sprintf("%s/logText/progressiveText?start=%d", strings.TrimRight(buildURL, "/"), start) + resp, err := c.request(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", start, false + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", start, false + } + b, _ := io.ReadAll(resp.Body) + next := start + if h := resp.Header.Get("X-Text-Size"); h != "" { + if n, err := strconv.ParseInt(h, 10, 64); err == nil { + next = n + } + } else { + next = start + int64(len(b)) + } + return string(b), next, strings.EqualFold(resp.Header.Get("X-More-Data"), "true") +} + +func logJenkinsConsoleChunk(logFn func(string, string, string), chunk string) { + normalized := strings.ReplaceAll(chunk, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + for _, line := range strings.Split(normalized, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + logFn("info", "Console", line) + } +} + +func pipelineCodeFromJenkinsJob(prefix, name string) string { + parts := []string{} + if strings.TrimSpace(prefix) != "" { + parts = append(parts, strings.TrimSpace(prefix)) + } + parts = append(parts, strings.TrimSpace(name)) + raw := strings.ToLower(strings.Join(parts, "-")) + re := regexp.MustCompile(`[^a-z0-9]+`) + code := strings.Trim(re.ReplaceAllString(raw, "-"), "-") + if code == "" { + code = "jenkins-job" + } + if len(code) > 54 { + sum := sha1.Sum([]byte(raw)) + code = strings.Trim(code[:54], "-") + "-" + hex.EncodeToString(sum[:])[:8] + } + return code +} + +func (h *PipelineHandler) uniquePipelineCode(base string) string { + code := base + for i := 0; i < 100; i++ { + var count int64 + h.db.Model(&model.Pipeline{}).Where("code = ? OR job_name = ?", code, code).Count(&count) + if count == 0 { + return code + } + code = fmt.Sprintf("%s-%d", strings.TrimRight(base, "-"), i+1) + if len(code) > 64 { + code = code[:64] + } + } + return base + "-jenkins" +} + +func jenkinsColorToStatus(color string) string { + color = strings.ToLower(color) + switch { + case strings.Contains(color, "blue"): + return "success" + case strings.Contains(color, "red"): + return "failed" + case strings.Contains(color, "anime"): + return "running" + default: + return "" + } +} diff --git a/backend/controller/pipeline_node.go b/backend/controller/pipeline_node.go index cab3247..86a30fb 100644 --- a/backend/controller/pipeline_node.go +++ b/backend/controller/pipeline_node.go @@ -260,4 +260,4 @@ func (h *PipelineHandler) hasBuildFileCtx(ctx *PipelineExecCtx, dir string) bool } } return false -} \ No newline at end of file +} diff --git a/backend/controller/rdp.go b/backend/controller/rdp.go index b74a7f3..690a5d6 100644 --- a/backend/controller/rdp.go +++ b/backend/controller/rdp.go @@ -266,9 +266,7 @@ func (h *RDPHandler) Serve(c *gin.Context) { if errMsg != "" { updates["error"] = errMsg } - if err := h.db.Model(&model.RDPSession{}).Where("id = ?", session.ID).Updates(updates).Error; err != nil { - log.Printf("[RDP] 更新会话结束状态失败 (session_id=%d, status=%s): %v", session.ID, status, err) - } + _ = h.db.Model(&model.RDPSession{}).Where("id = ?", session.ID).Updates(updates).Error }() audit := newRDPOperationRecorder(h.db, session, userIDPtr, username) @@ -288,6 +286,9 @@ func (h *RDPHandler) Serve(c *gin.Context) { continue } modifiers.Apply(&event) + if event.Type == "clipboard" { + log.Printf("[RDP] ws clipboard local→remote session=%d len=%d", session.ID, len(event.Text)) + } audit.Record(event) enqueueRDPEvent(events, event) } @@ -530,9 +531,7 @@ func (r *rdpOperationRecorder) create(command string) { Command: command, Timestamp: time.Now(), } - if err := r.db.Create(&cmd).Error; err != nil { - log.Printf("[RDPAudit] 记录 RDP 操作命令失败 (session_id=%d, user=%s, cmd=%q): %v", r.session.ID, r.username, command, err) - } + _ = r.db.Create(&cmd).Error } func (h *RDPHandler) loadRDPCredential(server model.Server) (model.RDPSecretContent, error) { @@ -662,7 +661,7 @@ func isMostlyBlackRGBA(img *image.RGBA, threshold float64) bool { func writeRDPFrame(mgr *wshub.WSManager, conn *websocket.Conn, frame rdpengine.ServerFrame) error { mgr.SetWriteDeadline(conn) if frame.Type == "bitmap" && len(frame.RGBA) > 0 { - return conn.WriteMessage(websocket.BinaryMessage, encodeRDPBitmapBinary(frame)) + return writeCompressedRDPBitmap(conn, frame) } return conn.WriteJSON(frame) } diff --git a/backend/controller/rdp_cred.go b/backend/controller/rdp_cred.go index f7c8a73..f7c7cee 100644 --- a/backend/controller/rdp_cred.go +++ b/backend/controller/rdp_cred.go @@ -32,4 +32,4 @@ func loadRDPCredential(crypt *crypto.SecretBox, server model.Server) (model.RDPS return cred, errors.New("RDP 凭据缺少用户名或密码") } return cred, nil -} \ No newline at end of file +} diff --git a/backend/controller/rdp_live_frame.go b/backend/controller/rdp_live_frame.go new file mode 100644 index 0000000..3759aba --- /dev/null +++ b/backend/controller/rdp_live_frame.go @@ -0,0 +1,48 @@ +package controller + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/png" + + rdpengine "ezops/pkg/rdp" + + "github.com/gorilla/websocket" +) + +// encodeRDPBitmapPNG 将实时 RGBA 脏矩形压缩为 PNG,避免原始整帧占满低带宽 WebSocket。 // Logan.Li +func encodeRDPBitmapPNG(frame rdpengine.ServerFrame) (rdpengine.ServerFrame, error) { + if frame.Type != "bitmap" || len(frame.RGBA) == 0 { + return frame, nil + } + if frame.Width <= 0 || frame.Height <= 0 { + return rdpengine.ServerFrame{}, fmt.Errorf("RDP 位图尺寸无效: %dx%d", frame.Width, frame.Height) + } + required := frame.Width * frame.Height * 4 + if len(frame.RGBA) < required { + return rdpengine.ServerFrame{}, fmt.Errorf("RDP 位图数据不足: got=%d want=%d", len(frame.RGBA), required) + } + + img := image.NewRGBA(image.Rect(0, 0, frame.Width, frame.Height)) + copy(img.Pix, frame.RGBA[:required]) + var buf bytes.Buffer + encoder := png.Encoder{CompressionLevel: png.BestSpeed} + if err := encoder.Encode(&buf, img); err != nil { + return rdpengine.ServerFrame{}, fmt.Errorf("压缩 RDP 位图失败: %w", err) + } + + frame.Image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) + frame.RGBA = nil + return frame, nil +} + +// writeCompressedRDPBitmap 发送浏览器已支持的 JSON PNG 帧,兼容现有前端绘制逻辑。 // Logan.Li +func writeCompressedRDPBitmap(conn *websocket.Conn, frame rdpengine.ServerFrame) error { + encoded, err := encodeRDPBitmapPNG(frame) + if err != nil { + return err + } + return conn.WriteJSON(encoded) +} diff --git a/backend/controller/rdp_live_frame_test.go b/backend/controller/rdp_live_frame_test.go new file mode 100644 index 0000000..d6480f2 --- /dev/null +++ b/backend/controller/rdp_live_frame_test.go @@ -0,0 +1,66 @@ +package controller + +import ( + "bytes" + "encoding/base64" + "image/png" + "strings" + "testing" + + rdpengine "ezops/pkg/rdp" +) + +func TestEncodeRDPBitmapPNGCompressesFrame(t *testing.T) { + const width = 900 + const height = 540 + rgba := make([]byte, width*height*4) + for i := 0; i < len(rgba); i += 4 { + rgba[i+3] = 255 + } + + encoded, err := encodeRDPBitmapPNG(rdpengine.ServerFrame{ + Type: "bitmap", + X: 12, + Y: 34, + Width: width, + Height: height, + RGBA: rgba, + }) + if err != nil { + t.Fatalf("压缩 RDP 位图失败: %v", err) + } + if len(encoded.RGBA) != 0 { + t.Fatalf("压缩后仍携带原始 RGBA: %d", len(encoded.RGBA)) + } + const prefix = "data:image/png;base64," + if !strings.HasPrefix(encoded.Image, prefix) { + t.Fatalf("压缩帧格式错误: %q", encoded.Image[:min(len(encoded.Image), len(prefix))]) + } + pngData, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encoded.Image, prefix)) + if err != nil { + t.Fatalf("解码 PNG 数据失败: %v", err) + } + img, err := png.Decode(bytes.NewReader(pngData)) + if err != nil { + t.Fatalf("解析 PNG 数据失败: %v", err) + } + if img.Bounds().Dx() != width || img.Bounds().Dy() != height { + t.Fatalf("PNG 尺寸错误: got=%dx%d", img.Bounds().Dx(), img.Bounds().Dy()) + } + if len(encoded.Image) >= len(rgba)/4 { + t.Fatalf("PNG 压缩率不足: encoded=%d raw=%d", len(encoded.Image), len(rgba)) + } + t.Logf("RDP 帧压缩: raw=%d encoded=%d", len(rgba), len(encoded.Image)) +} + +func TestEncodeRDPBitmapPNGRejectsShortPayload(t *testing.T) { + _, err := encodeRDPBitmapPNG(rdpengine.ServerFrame{ + Type: "bitmap", + Width: 10, + Height: 10, + RGBA: make([]byte, 8), + }) + if err == nil { + t.Fatal("短位图数据应返回错误") + } +} diff --git a/backend/controller/rdp_session.go b/backend/controller/rdp_session.go index 8c1ff0d..dac402e 100644 --- a/backend/controller/rdp_session.go +++ b/backend/controller/rdp_session.go @@ -2,7 +2,6 @@ package controller import ( "fmt" - "log" "net/http" "strconv" "strings" @@ -128,9 +127,7 @@ func (h *RDPSessionHandler) KillRDPSession(c *gin.Context) { return } duration := int(now.Sub(session.StartTime).Seconds()) - if err := h.db.Model(&session).Updates(map[string]interface{}{"status": "closed", "end_time": now, "duration": duration}).Error; err != nil { - log.Printf("[RDPSession] 终止会话更新状态失败 (session_id=%d): %v", id, err) - } + _ = h.db.Model(&session).Updates(map[string]interface{}{"status": "closed", "end_time": now, "duration": duration}).Error c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已终止"}) } @@ -163,7 +160,7 @@ func (h *RDPSessionHandler) LogRDPReplayView(c *gin.Context) { return } uid := middleware.GetUserID(c) - auditLog := model.AuditLog{ + log := model.AuditLog{ UserID: &uid, Username: middleware.GetUsername(c), IP: c.ClientIP(), @@ -175,8 +172,6 @@ func (h *RDPSessionHandler) LogRDPReplayView(c *gin.Context) { Request: fmt.Sprintf("回放 Windows RDP 会话 #%d(%s → %s/%s)", id, session.Username, session.ServerName, session.ServerIP), Status: 200, } - if err := h.db.Create(&auditLog).Error; err != nil { - log.Printf("[RDPAudit] 记录回放审计日志失败 (session_id=%d, user=%s): %v", id, session.Username, err) - } + _ = h.db.Create(&log).Error c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"}) } diff --git a/backend/controller/remote_exec.go b/backend/controller/remote_exec.go index bf85255..3bc2587 100644 --- a/backend/controller/remote_exec.go +++ b/backend/controller/remote_exec.go @@ -84,8 +84,8 @@ func (h *RemoteExecHandler) BatchExec(c *gin.Context) { // BatchUploadRequest 批量上传请求 type BatchUploadRequest struct { - ServerIDs []uint `json:"server_ids" binding:"required"` // 目标服务器 ID 列表 - LocalPath string `json:"local_path" binding:"required"` // AIOps 主机上的文件路径 + ServerIDs []uint `json:"server_ids" binding:"required"` // 目标服务器 ID 列表 + LocalPath string `json:"local_path" binding:"required"` // AIOps 主机上的文件路径 RemotePath string `json:"remote_path" binding:"required"` // 远程目标路径 } diff --git a/backend/controller/server.go b/backend/controller/server.go index a3167e0..2808be4 100644 --- a/backend/controller/server.go +++ b/backend/controller/server.go @@ -94,8 +94,8 @@ func (h *ServerHandler) ListServers(c *gin.Context) { // 关键词搜索 if query.Keyword != "" { - db = db.Where("hostname LIKE ? OR ip LIKE ? OR public_ip LIKE ?", - "%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%") + db = db.Where("alias LIKE ? OR cloud_instance_name LIKE ? OR hostname LIKE ? OR ip LIKE ? OR public_ip LIKE ? OR cloud_instance_id LIKE ?", + "%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%") } // 状态过滤 @@ -194,23 +194,26 @@ func (h *ServerHandler) CreateServer(c *gin.Context) { } server := model.Server{ - Hostname: req.Hostname, - IP: req.IP, - PublicIP: req.PublicIP, - Port: req.Port, - Username: req.Username, - Password: req.Password, - SSHKey: req.SSHKey, - AuthType: req.AuthType, - OS: req.OS, - CPU: req.CPU, - Memory: req.Memory, - Disk: req.Disk, - RegionID: req.RegionID, - CloudID: req.CloudID, - Tags: req.Tags, - Description: req.Description, - Status: "stopped", + Hostname: req.Hostname, + Alias: req.Alias, + CloudInstanceName: req.CloudInstanceName, + CloudInstanceID: req.CloudInstanceID, + IP: req.IP, + PublicIP: req.PublicIP, + Port: req.Port, + Username: req.Username, + Password: req.Password, + SSHKey: req.SSHKey, + AuthType: req.AuthType, + OS: req.OS, + CPU: req.CPU, + Memory: req.Memory, + Disk: req.Disk, + RegionID: req.RegionID, + CloudID: req.CloudID, + Tags: req.Tags, + Description: req.Description, + Status: "stopped", } if req.Status != "" { @@ -295,6 +298,15 @@ func (h *ServerHandler) UpdateServer(c *gin.Context) { if req.Hostname != "" { updates["hostname"] = req.Hostname } + if req.Alias != nil { + updates["alias"] = *req.Alias + } + if req.CloudInstanceName != "" { + updates["cloud_instance_name"] = req.CloudInstanceName + } + if req.CloudInstanceID != "" { + updates["cloud_instance_id"] = req.CloudInstanceID + } if req.IP != "" { updates["ip"] = req.IP } diff --git a/backend/controller/server_sort_test.go b/backend/controller/server_sort_test.go deleted file mode 100644 index 2522640..0000000 --- a/backend/controller/server_sort_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package controller - -import ( - "net/http/httptest" - "testing" - - "ezops/model" - - "github.com/gin-gonic/gin" -) - -// TestServerPageQueryBinding 验证服务器列表能绑定排序查询参数。 // Logan.Li -func TestServerPageQueryBinding(t *testing.T) { - gin.SetMode(gin.TestMode) - request := httptest.NewRequest("GET", "/servers?page=1&page_size=10&sort=hostname&order=asc", nil) - context, _ := gin.CreateTestContext(httptest.NewRecorder()) - context.Request = request - - var query model.PageQuery - if err := context.ShouldBindQuery(&query); err != nil { - t.Fatalf("ShouldBindQuery() error = %v", err) - } - if query.Sort != "hostname" || query.Order != "asc" { - t.Fatalf("排序参数绑定结果为 sort=%q order=%q", query.Sort, query.Order) - } -} diff --git a/backend/controller/server_v2.go b/backend/controller/server_v2.go index 93a2379..af8e883 100644 --- a/backend/controller/server_v2.go +++ b/backend/controller/server_v2.go @@ -57,9 +57,7 @@ func (h *ServerHandlerV2) ListServers(c *gin.Context) { status := c.Query("status") regionID := c.Query("region_id") - list, total, err := h.svc.ListServers( - query.Keyword, status, regionID, query.Sort, query.Order, query.Page, query.PageSize, - ) + list, total, err := h.svc.ListServers(query.Keyword, status, regionID, query.Page, query.PageSize) if err != nil { utils.InternalError(c, "查询失败") return diff --git a/backend/controller/session_command.go b/backend/controller/session_command.go index 382e43b..14b1c21 100644 --- a/backend/controller/session_command.go +++ b/backend/controller/session_command.go @@ -67,7 +67,7 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) { // 联表带出会话录制大小,前端可判断能否回放 // Logan.Li var items []model.SessionCommandItem - listQuery := h.db.Table("sys_session_commands AS sc"). + listQuery := h.db.Table("audit_session_commands AS sc"). Select("sc.*, COALESCE(ts.record_size, rs.record_size, 0) AS session_record_size"). Joins("LEFT JOIN sys_terminal_sessions AS ts ON ts.id = sc.session_id AND COALESCE(sc.session_type, 'terminal') = 'terminal'"). Joins("LEFT JOIN sys_rdp_sessions AS rs ON rs.id = sc.session_id AND sc.session_type = 'rdp'") diff --git a/backend/controller/sftp_handler.go b/backend/controller/sftp_handler.go index 14824b3..2154545 100644 --- a/backend/controller/sftp_handler.go +++ b/backend/controller/sftp_handler.go @@ -35,8 +35,8 @@ func (h *SFTPHandler) sftpClient(serverID uint) (*sftp.Client, *ssh.Client, erro } sshConfig := &ssh.ClientConfig{ - User: server.Username, - Timeout: 10 * time.Second, + User: server.Username, + Timeout: 10 * time.Second, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } diff --git a/backend/controller/site.go b/backend/controller/site.go index dfcc926..635f265 100644 --- a/backend/controller/site.go +++ b/backend/controller/site.go @@ -267,4 +267,4 @@ func (h *SiteHandler) GetSiteStats(c *gin.Context) { "availability": availability, "probe_count": totalRecent, }}) -} \ No newline at end of file +} diff --git a/backend/controller/site_view.go b/backend/controller/site_view.go index 8dbfe69..62aa50c 100644 --- a/backend/controller/site_view.go +++ b/backend/controller/site_view.go @@ -12,23 +12,23 @@ import ( // SiteTaskView 前端拨测任务视图(字段与前端对齐)// Logan.Li type SiteTaskView struct { - ID uint `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - Type string `json:"type"` - FrequencySec int `json:"frequency_sec"` - TimeoutSec int `json:"timeout_sec"` - ExpectedStatusCode int `json:"expected_status_code"` - Enabled bool `json:"enabled"` - LastStatus string `json:"last_status,omitempty"` - LastResponseTimeMs float64 `json:"last_response_time_ms,omitempty"` - LastCheckedAt *string `json:"last_checked_at,omitempty"` - SuccessRate float64 `json:"success_rate"` - AlertStatus string `json:"alert_status"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - SSLDaysRemaining *int `json:"ssl_days_remaining,omitempty"` - SSLCertExpiry *string `json:"ssl_cert_expiry,omitempty"` + ID uint `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` + FrequencySec int `json:"frequency_sec"` + TimeoutSec int `json:"timeout_sec"` + ExpectedStatusCode int `json:"expected_status_code"` + Enabled bool `json:"enabled"` + LastStatus string `json:"last_status,omitempty"` + LastResponseTimeMs float64 `json:"last_response_time_ms,omitempty"` + LastCheckedAt *string `json:"last_checked_at,omitempty"` + SuccessRate float64 `json:"success_rate"` + AlertStatus string `json:"alert_status"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + SSLDaysRemaining *int `json:"ssl_days_remaining,omitempty"` + SSLCertExpiry *string `json:"ssl_cert_expiry,omitempty"` } func toSiteTaskView(task model.SiteTask) SiteTaskView { @@ -220,4 +220,4 @@ func parseCreateTaskRequest(raw map[string]interface{}) model.SiteTask { task.DNSResolver = strings.TrimSpace(v) } return task -} \ No newline at end of file +} diff --git a/backend/controller/snmp.go b/backend/controller/snmp.go index 5ea6285..f23a599 100644 --- a/backend/controller/snmp.go +++ b/backend/controller/snmp.go @@ -50,16 +50,16 @@ func (h *SNMPHandler) ListDevices(c *gin.Context) { // POST /api/v1/snmp/devices func (h *SNMPHandler) CreateDevice(c *gin.Context) { var req struct { - Name string `json:"name" binding:"required"` - IP string `json:"ip" binding:"required"` - Port int `json:"port"` - SnmpVersion string `json:"snmp_version"` - Version string `json:"version"` - Community string `json:"community"` - TemplateID uint `json:"template_id"` - DeviceType string `json:"device_type"` - Vendor string `json:"vendor"` - Description string `json:"description"` + Name string `json:"name" binding:"required"` + IP string `json:"ip" binding:"required"` + Port int `json:"port"` + SnmpVersion string `json:"snmp_version"` + Version string `json:"version"` + Community string `json:"community"` + TemplateID uint `json:"template_id"` + DeviceType string `json:"device_type"` + Vendor string `json:"vendor"` + Description string `json:"description"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) @@ -172,8 +172,8 @@ func (h *SNMPHandler) GetSNMPStats(c *gin.Context) { h.db.Model(&model.SNMPDevice{}).Where("status = ?", "online").Count(&online) c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ - "total": total, - "online": online, + "total": total, + "online": online, "abnormal": total - online, }}) } diff --git a/backend/controller/snmp_collect.go b/backend/controller/snmp_collect.go index 000a899..60c1b4c 100644 --- a/backend/controller/snmp_collect.go +++ b/backend/controller/snmp_collect.go @@ -198,4 +198,4 @@ func (h *SNMPHandler) ListDeviceMetrics(c *gin.Context) { func (h *SNMPHandler) StopCollector() { close(h.stopCh) h.wg.Wait() -} \ No newline at end of file +} diff --git a/backend/controller/snmp_metrics_history.go b/backend/controller/snmp_metrics_history.go index 51a6596..abcc8dc 100644 --- a/backend/controller/snmp_metrics_history.go +++ b/backend/controller/snmp_metrics_history.go @@ -260,7 +260,7 @@ func snmpMetricLabel(name, index string) string { "h3c_fan_speed": "H3C 风扇", "hw_fan_speed": "华为 风扇", "ruijie_fan_speed": "锐捷 风扇", "dell_fan_speed": "Dell 风扇", "inspur_fan_speed": "浪潮 风扇", "ent_sensor_value": "实体传感器", - "if_in_octets": "入方向流量", "if_out_octets": "出方向流量", + "if_in_octets": "入方向流量", "if_out_octets": "出方向流量", } label := labels[name] if label == "" { @@ -270,4 +270,4 @@ func snmpMetricLabel(name, index string) string { label += " #" + index } return label -} \ No newline at end of file +} diff --git a/backend/controller/system_task.go b/backend/controller/system_task.go index c3d235d..a124a0e 100644 --- a/backend/controller/system_task.go +++ b/backend/controller/system_task.go @@ -32,7 +32,7 @@ func (h *SystemTaskHandler) ListSystemTasks(c *gin.Context) { c.JSON(http.StatusOK, model.Response{ Code: 200, Message: "success", - Data: model.ListResponse{List: list, Total: int64(len(list))}, + Data: model.ListResponse{List: list, Total: int64(len(list))}, }) } @@ -50,4 +50,4 @@ func (h *SystemTaskHandler) RunSystemTask(c *gin.Context) { Message: "执行完成", Data: item, }) -} \ No newline at end of file +} diff --git a/backend/controller/terminal_session.go b/backend/controller/terminal_session.go index 977db7f..af4cbb4 100644 --- a/backend/controller/terminal_session.go +++ b/backend/controller/terminal_session.go @@ -2,7 +2,6 @@ package controller import ( "fmt" - "log" "net/http" "strconv" "strings" @@ -265,7 +264,7 @@ func (h *TerminalSessionHandler) LogReplayView(c *gin.Context) { return } uid := middleware.GetUserID(c) - auditLog := model.AuditLog{ + log := model.AuditLog{ UserID: &uid, Username: middleware.GetUsername(c), IP: c.ClientIP(), @@ -277,9 +276,7 @@ func (h *TerminalSessionHandler) LogReplayView(c *gin.Context) { Request: fmt.Sprintf("回放终端会话 #%d(%s → %s/%s)", id, session.Username, session.ServerName, session.ServerIP), Status: 200, } - if err := h.db.Create(&auditLog).Error; err != nil { - log.Printf("[TerminalAudit] 记录回放审计日志失败 (session_id=%d, user=%s): %v", id, session.Username, err) - } + _ = h.db.Create(&log).Error // 落库失败不阻断回放 c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"}) } diff --git a/backend/controller/tool_config.go b/backend/controller/tool_config.go index 9bc8c40..ecb5490 100644 --- a/backend/controller/tool_config.go +++ b/backend/controller/tool_config.go @@ -105,11 +105,11 @@ func (h *ToolConfigHandler) UpdateTool(c *gin.Context) { } var req struct { - DisplayName *string `json:"display_name"` - Description *string `json:"description"` + DisplayName *string `json:"display_name"` + Description *string `json:"description"` InstallMethod *model.ToolInstallMethod `json:"install_method"` - Enabled *bool `json:"enabled"` - SortOrder *int `json:"sort_order"` + Enabled *bool `json:"enabled"` + SortOrder *int `json:"sort_order"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) @@ -356,4 +356,4 @@ func getPresetVersions(toolName string) []map[string]string { default: return nil } -} \ No newline at end of file +} diff --git a/backend/controller/trace_proxy.go b/backend/controller/trace_proxy.go index 227dee9..36c336f 100644 --- a/backend/controller/trace_proxy.go +++ b/backend/controller/trace_proxy.go @@ -34,37 +34,37 @@ type jaegerServicesResp struct { } type jaegerTraceResp struct { - Data []jaegerTrace `json:"data"` - Total int `json:"total"` + Data []jaegerTrace `json:"data"` + Total int `json:"total"` } type jaegerTrace struct { - TraceID string `json:"traceID"` - Spans []jaegerSpan `json:"spans"` + TraceID string `json:"traceID"` + Spans []jaegerSpan `json:"spans"` Processes map[string]jaegerProc `json:"processes"` } type jaegerSpan struct { - TraceID string `json:"traceID"` - SpanID string `json:"spanID"` - OperationName string `json:"operationName"` - References []jaegerRef `json:"references"` - StartTime int64 `json:"startTime"` // microseconds - Duration int64 `json:"duration"` // microseconds - Tags []jaegerTag `json:"tags"` - ProcessID string `json:"processID"` - Logs []jaegerLog `json:"logs"` + TraceID string `json:"traceID"` + SpanID string `json:"spanID"` + OperationName string `json:"operationName"` + References []jaegerRef `json:"references"` + StartTime int64 `json:"startTime"` // microseconds + Duration int64 `json:"duration"` // microseconds + Tags []jaegerTag `json:"tags"` + ProcessID string `json:"processID"` + Logs []jaegerLog `json:"logs"` } type jaegerRef struct { - RefType string `json:"refType"` - TraceID string `json:"traceID"` - SpanID string `json:"spanID"` + RefType string `json:"refType"` + TraceID string `json:"traceID"` + SpanID string `json:"spanID"` } type jaegerProc struct { - ServiceName string `json:"serviceName"` - Tags []jaegerTag `json:"tags"` + ServiceName string `json:"serviceName"` + Tags []jaegerTag `json:"tags"` } type jaegerTag struct { @@ -74,8 +74,8 @@ type jaegerTag struct { } type jaegerLog struct { - Timestamp int64 `json:"timestamp"` - Fields []jaegerTag `json:"fields"` + Timestamp int64 `json:"timestamp"` + Fields []jaegerTag `json:"fields"` } // ---- 前端期望的格式 ---- @@ -115,7 +115,7 @@ type serviceMetric struct { ServiceName string `json:"serviceName"` RequestCount int `json:"requestCount"` ErrorRate float64 `json:"errorRate"` - AvgDuration float64 `json:"avgDuration"` // ms + AvgDuration float64 `json:"avgDuration"` // ms P95Duration float64 `json:"p95Duration"` P99Duration float64 `json:"p99Duration"` } diff --git a/backend/controller/user.go b/backend/controller/user.go index b3e6d54..5a21b9f 100644 --- a/backend/controller/user.go +++ b/backend/controller/user.go @@ -4,8 +4,8 @@ import ( "net/http" "time" - "ezops/middleware" "ezops/config" + "ezops/middleware" "ezops/model" "github.com/gin-gonic/gin" @@ -62,20 +62,31 @@ func parseUserAgent(ua string) (browser, os string) { } // Browser switch { - case containsAny(ua, "Edg/"): browser = "Edge" - case containsAny(ua, "Chrome"): browser = "Chrome" - case containsAny(ua, "Safari") && !containsAny(ua, "Chrome"): browser = "Safari" - case containsAny(ua, "Firefox"): browser = "Firefox" - case containsAny(ua, "curl"): browser = "curl" - case containsAny(ua, "Python"): browser = "Python" + case containsAny(ua, "Edg/"): + browser = "Edge" + case containsAny(ua, "Chrome"): + browser = "Chrome" + case containsAny(ua, "Safari") && !containsAny(ua, "Chrome"): + browser = "Safari" + case containsAny(ua, "Firefox"): + browser = "Firefox" + case containsAny(ua, "curl"): + browser = "curl" + case containsAny(ua, "Python"): + browser = "Python" } // OS switch { - case containsAny(ua, "Windows"): os = "Windows" - case containsAny(ua, "Mac OS"): os = "macOS" - case containsAny(ua, "Linux") || containsAny(ua, "X11"): os = "Linux" - case containsAny(ua, "Android"): os = "Android" - case containsAny(ua, "iPhone") || containsAny(ua, "iPad"): os = "iOS" + case containsAny(ua, "Windows"): + os = "Windows" + case containsAny(ua, "Mac OS"): + os = "macOS" + case containsAny(ua, "Linux") || containsAny(ua, "X11"): + os = "Linux" + case containsAny(ua, "Android"): + os = "Android" + case containsAny(ua, "iPhone") || containsAny(ua, "iPad"): + os = "iOS" } return } @@ -363,7 +374,7 @@ func collectUserPermissionCodes(db *gorm.DB, userID uint, isAdmin bool) []string if isAdmin { return []string{"*"} } - // raw SQL: user → iam_user_roles → iam_roles → role_permissions → iam_permissions + // raw SQL: user → iam_user_roles → iam_roles → iam_role_permissions → iam_permissions // 逐层 JOIN,任一层缺失自然返回空 type permRow struct { Code string @@ -373,7 +384,7 @@ func collectUserPermissionCodes(db *gorm.DB, userID uint, isAdmin bool) []string SELECT DISTINCT p.code FROM iam_user_roles ur JOIN iam_roles r ON r.id = ur.role_id AND r.status = 1 - JOIN role_permissions rp ON rp.role_id = r.id + JOIN iam_role_permissions rp ON rp.role_id = r.id JOIN iam_permissions p ON p.id = rp.permission_id AND p.status = 1 WHERE ur.user_id = ? `, userID).Scan(&rows).Error diff --git a/backend/controller/user_admin.go b/backend/controller/user_admin.go index ba2498a..3264def 100644 --- a/backend/controller/user_admin.go +++ b/backend/controller/user_admin.go @@ -396,6 +396,7 @@ func (h *UserHandler) ListRoles(c *gin.Context) { Code: 200, Data: model.ListResponse{List: roles, Total: int64(len(roles))}, }) } + // GET /api/v1/admin/users/import-template func (h *UserHandler) ImportTemplate(c *gin.Context) { // UTF-8 BOM for Excel compatibility diff --git a/backend/controller/user_api_token.go b/backend/controller/user_api_token.go index 602a66f..2474c6c 100644 --- a/backend/controller/user_api_token.go +++ b/backend/controller/user_api_token.go @@ -3,7 +3,6 @@ package controller import ( "crypto/rand" "fmt" - "log" "math/big" "net/http" "time" @@ -137,9 +136,7 @@ func (h *UserHandler) ensureAPIKeyMeta(user *model.User) { user.ApiAccessKeyDurationMonths = defaultAPIKeyMonths } if len(patches) > 0 { - if err := h.db.Model(&model.User{}).Where("id = ?", user.ID).Updates(patches).Error; err != nil { - log.Printf("[UserApiKey] 补全 API 密钥默认值失败 (user_id=%d): %v", user.ID, err) - } + _ = h.db.Model(&model.User{}).Where("id = ?", user.ID).Updates(patches).Error } } @@ -276,7 +273,9 @@ func (h *UserHandler) UpdateApiAccessKey(c *gin.Context) { // ExtendApiAccessKey 延长有效期(兼容旧接口)// Logan.Li func (h *UserHandler) ExtendApiAccessKey(c *gin.Context) { userID := middleware.GetUserID(c) - var req struct{ DurationMonths int `json:"duration_months"` } + var req struct { + DurationMonths int `json:"duration_months"` + } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误"}) return @@ -314,4 +313,4 @@ func (h *UserHandler) RevokeApiAccessKey(c *gin.Context) { return } c.JSON(http.StatusOK, model.Response{Code: 200, Message: "访问密钥已删除"}) -} \ No newline at end of file +} diff --git a/backend/controller/webshell.go b/backend/controller/webshell.go index dff6ca4..036e7f2 100644 --- a/backend/controller/webshell.go +++ b/backend/controller/webshell.go @@ -60,10 +60,10 @@ func newAsciicastRecorder() *asciicastRecorder { func (r *asciicastRecorder) writeHeader() { h, _ := json.Marshal(map[string]interface{}{ - "version": 2, - "width": 80, - "height": 24, - "timestamp": r.startTime.Unix(), + "version": 2, + "width": 80, + "height": 24, + "timestamp": r.startTime.Unix(), "aiops_input_recorded": false, "env": map[string]string{ "SHELL": "/bin/bash", diff --git a/backend/controller/ws_pipeline_log.go b/backend/controller/ws_pipeline_log.go index cf20829..ed3805a 100644 --- a/backend/controller/ws_pipeline_log.go +++ b/backend/controller/ws_pipeline_log.go @@ -9,8 +9,8 @@ import ( "time" "ezops/middleware" - "ezops/pkg/wshub" ezredis "ezops/pkg/redis" + "ezops/pkg/wshub" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" @@ -53,12 +53,16 @@ func (h *WSPipelineLogHandler) StreamLog(c *gin.Context) { // 首次发送:已有日志 raw, _ := h.cache.Get(ctx, logKey) + lastLen := 0 if raw != "" { ws.WriteJSON(map[string]interface{}{"type": "init", "logs": raw}) + var entries []LogEntry + if err := json.Unmarshal([]byte(raw), &entries); err == nil { + lastLen = len(entries) + } } // 轮询新日志(500ms 间隔) - lastLen := 0 ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() @@ -125,7 +129,7 @@ func (h *WSPipelineLogHandler) GetLogHistory(c *gin.Context) { // 先查 Redis(执行中) logKey := fmt.Sprintf("pipeline:run:%d:logs", uint(runID)) ctx := c.Request.Context() - + raw, _ := h.cache.Get(ctx, logKey) if raw != "" && !strings.HasPrefix(raw, "[") { raw = "[]" @@ -137,7 +141,7 @@ func (h *WSPipelineLogHandler) GetLogHistory(c *gin.Context) { // 查 DB(已完成) var logs string - h.db.Table("ops_pipeline_runs").Where("id = ?", uint(runID)).Select("logs").Scan(&logs) + h.db.Table("deploy_pipeline_runs").Where("id = ?", uint(runID)).Select("logs").Scan(&logs) if logs == "" { logs = "[]" } diff --git a/backend/controller/zabbix_datasource.go b/backend/controller/zabbix_datasource.go index b6f0854..5ea8a28 100644 --- a/backend/controller/zabbix_datasource.go +++ b/backend/controller/zabbix_datasource.go @@ -113,24 +113,48 @@ func (h *ZabbixDSHandler) Update(c *gin.Context) { } updates := map[string]interface{}{} - if req.Name != nil { updates["name"] = *req.Name } - if req.URL != nil { updates["url"] = *req.URL } - if req.Username != nil { updates["username"] = *req.Username } - if req.Password != nil && *req.Password != "" { updates["password"] = *req.Password } - if req.TrendsAfter != nil { updates["trends_after"] = *req.TrendsAfter } - if req.TrendsRange != nil { updates["trends_range"] = *req.TrendsRange } - if req.TrendsCacheTTL != nil { updates["trends_cache_ttl"] = *req.TrendsCacheTTL } - if req.TrendsTimeout != nil { updates["trends_timeout"] = *req.TrendsTimeout } - if req.DirectDB != nil { updates["direct_db"] = *req.DirectDB } - if req.DisableAcknowledges != nil { updates["disable_acknowledges"] = *req.DisableAcknowledges } - if req.DisableDataAlignment != nil { updates["disable_data_alignment"] = *req.DisableDataAlignment } + if req.Name != nil { + updates["name"] = *req.Name + } + if req.URL != nil { + updates["url"] = *req.URL + } + if req.Username != nil { + updates["username"] = *req.Username + } + if req.Password != nil && *req.Password != "" { + updates["password"] = *req.Password + } + if req.TrendsAfter != nil { + updates["trends_after"] = *req.TrendsAfter + } + if req.TrendsRange != nil { + updates["trends_range"] = *req.TrendsRange + } + if req.TrendsCacheTTL != nil { + updates["trends_cache_ttl"] = *req.TrendsCacheTTL + } + if req.TrendsTimeout != nil { + updates["trends_timeout"] = *req.TrendsTimeout + } + if req.DirectDB != nil { + updates["direct_db"] = *req.DirectDB + } + if req.DisableAcknowledges != nil { + updates["disable_acknowledges"] = *req.DisableAcknowledges + } + if req.DisableDataAlignment != nil { + updates["disable_data_alignment"] = *req.DisableDataAlignment + } if req.IsDefault != nil { updates["is_default"] = *req.IsDefault if *req.IsDefault { h.db.Model(&model.ZabbixDataSource{}).Where("is_default = ? AND id != ?", true, id).Update("is_default", false) } } - if req.Status != nil { updates["status"] = *req.Status } + if req.Status != nil { + updates["status"] = *req.Status + } if len(updates) > 0 { if err := h.db.Model(&ds).Updates(updates).Error; err != nil { diff --git a/backend/controller/zabbix_proxy.go b/backend/controller/zabbix_proxy.go index 5cfca39..c16c2c2 100644 --- a/backend/controller/zabbix_proxy.go +++ b/backend/controller/zabbix_proxy.go @@ -28,7 +28,7 @@ type ZabbixHostSummary struct { Hostname string `json:"hostname"` Name string `json:"name"` IP string `json:"ip"` - Available string `json:"available"` // 0=未知 1=可用 2=不可用 + Available string `json:"available"` // 0=未知 1=可用 2=不可用 CPUUsage float64 `json:"cpu_usage"` MemoryUsage float64 `json:"memory_usage"` DiskUsage float64 `json:"disk_usage"` @@ -71,7 +71,7 @@ func (h *ZabbixProxyHandler) Connect(c *gin.Context) { // 获取主机列表(含接口和组信息) hosts, err := client.GetHosts(ctx, &zabbix.GetHostsParams{ - Status: 0, // 只获取被监控的主机 + Status: 0, // 只获取被监控的主机 SelectInterfaces: []string{"interfaceid", "hostid", "main", "type", "useip", "ip", "dns", "port"}, SelectGroups: []string{"groupid", "name"}, Output: []string{"hostid", "host", "name", "status", "available", "description"}, @@ -85,17 +85,17 @@ func (h *ZabbixProxyHandler) Connect(c *gin.Context) { // 为每台主机查询监控项 var summaries []ZabbixHostSummary itemKeys := []string{ - "system.cpu.util[,idle]", // CPU 空闲率 - "system.cpu.util", // CPU 使用率(备用) - "vm.memory.size[total]", // 总内存 bytes - "vm.memory.size[available]", // 可用内存 bytes - "vfs.fs.size[/,total]", // 总磁盘 bytes - "vfs.fs.size[/,used]", // 已用磁盘 bytes - "system.cpu.load[all,avg5]", // 5分钟负载 - "net.if.in[ens18]", // 网络流入 - "net.if.out[ens18]", // 网络流出 - "system.cpu.num", // CPU 数量 - "system.uptime", // 运行时间 + "system.cpu.util[,idle]", // CPU 空闲率 + "system.cpu.util", // CPU 使用率(备用) + "vm.memory.size[total]", // 总内存 bytes + "vm.memory.size[available]", // 可用内存 bytes + "vfs.fs.size[/,total]", // 总磁盘 bytes + "vfs.fs.size[/,used]", // 已用磁盘 bytes + "system.cpu.load[all,avg5]", // 5分钟负载 + "net.if.in[ens18]", // 网络流入 + "net.if.out[ens18]", // 网络流出 + "system.cpu.num", // CPU 数量 + "system.uptime", // 运行时间 } for _, host := range hosts { @@ -185,9 +185,9 @@ func (h *ZabbixProxyHandler) Connect(c *gin.Context) { Code: 200, Message: "success", Data: gin.H{ - "version": version, - "hosts": summaries, - "total": len(summaries), + "version": version, + "hosts": summaries, + "total": len(summaries), }, }) } diff --git a/backend/embed.go b/backend/embed.go index 83b97d1..0d86eac 100644 --- a/backend/embed.go +++ b/backend/embed.go @@ -68,6 +68,13 @@ func setupFrontend(r *gin.Engine) { return } + if strings.HasPrefix(cleanPath, "assets/") { + // 构建哈希资源缺失时返回真实 404,避免 SPA fallback 把 index.html 当 JS 模块返回。 // Logan.Li + c.Header("Cache-Control", "no-store, no-cache, must-revalidate") + c.String(http.StatusNotFound, "static asset not found") + return + } + // SPA fallback → 直接返回 index.html 字节(避免 http.FileServer 的 301 redirect) indexBytes, err := fs.ReadFile(staticFS, "index.html") if err != nil { diff --git a/backend/main.go b/backend/main.go index 630f32d..aeec060 100644 --- a/backend/main.go +++ b/backend/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "net/http" @@ -14,6 +15,7 @@ import ( "ezops/config" "ezops/controller" + "ezops/migrations" "ezops/model" "ezops/pkg/appdir" "ezops/pkg/auditcleaner" @@ -22,7 +24,7 @@ import ( "ezops/pkg/database" "ezops/pkg/logger" ezredis "ezops/pkg/redis" - snmppkg "ezops/pkg/snmp" + "ezops/pkg/retention" "ezops/pkg/systask" "ezops/router" "ezops/seed" @@ -45,7 +47,7 @@ var ( flagConfig = flag.String("config", "", "配置文件路径 (默认: config.yaml)") flagMode = flag.String("mode", "", "运行模式: local (SQLite+redka), development, production, release") flagLogLevel = flag.String("log-level", "", "日志级别: debug, info, warn, error") - flagSkipAutoMigrate = flag.Bool("skip-auto-migrate", false, "跳过启动时的数据库自动迁移(仅用于已完成迁移的故障恢复)") + flagSkipAutoMigrate = flag.Bool("skip-auto-migrate", false, "已废弃:服务启动不再执行自动迁移") // Logan.Li // 数据库参数 flagDBDriver = flag.String("db-driver", "", "数据库驱动: sqlite, mysql, postgres (默认: sqlite)") @@ -83,7 +85,6 @@ var ( // @license.name Apache 2.0 // @license.url http://www.apache.org/licenses/LICENSE-2.0.html -// @host localhost:8080 // @BasePath /api/v1 // @securityDefinitions.apikey Bearer @@ -106,6 +107,9 @@ func main() { case "version", "-v", "--version": printVersion() os.Exit(0) + case "migrate": + runMigrationCLI(os.Args[2:]) + return } } @@ -145,11 +149,9 @@ func main() { log.Fatal("数据库连接失败", zap.Error(err)) } - // 自动迁移;大 SQLite 库异常迁移时可显式跳过,先恢复已存在的服务。 // Logan.Li + // 数据库结构只能通过 `aiops migrate up` 显式变更,避免启动期重建表和 WAL 写放大。 // Logan.Li if *flagSkipAutoMigrate { - log.Warn("已跳过数据库自动迁移,仅限已完成迁移的故障恢复场景") - } else if err := database.AutoMigrate(db); err != nil { - log.Fatal("数据库迁移失败", zap.Error(err)) + log.Warn("--skip-auto-migrate 已废弃;服务启动始终不会执行数据库迁移") } // 初始化种子数据 @@ -222,55 +224,31 @@ func main() { }) defer cronSched.Stop() - // 定期清理旧的 agent 上报数据(保留 7 天,防止表膨胀影响查询性能)// Logan.Li + // 每日分批清理 Agent、指标、SNMP、拨测与旧日志,执行结果写入系统任务状态。 // Logan.Li go func() { ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() - runAgentCleanup := func() { + runRetentionCleanup := func() { start := time.Now() - cleanupSQL := database.CleanupAgentReportsSQL(cfg.Database.Driver) - r := db.Exec(cleanupSQL) - msg := fmt.Sprintf("清理 %d 条记录", r.RowsAffected) - status := "success" - if r.Error != nil { - status = "failed" - msg = r.Error.Error() - log.Warn("Agent 上报清理失败", zap.Error(r.Error)) - } else if r.RowsAffected > 0 { - log.Info("清理过期的 agent 上报数据", zap.Int64("rows", r.RowsAffected)) + result, err := retention.Run(db, cfg.Retention) + if err != nil { + systask.Record("retention_cleanup", "failed", err.Error(), time.Since(start)) + log.Warn("高频数据保留清理失败", zap.Error(err)) + return } - systask.Record("agent_report_cleanup", status, msg, time.Since(start)) + message, marshalErr := json.Marshal(result) + if marshalErr != nil { + systask.Record("retention_cleanup", "failed", marshalErr.Error(), time.Since(start)) + log.Warn("高频数据保留结果序列化失败", zap.Error(marshalErr)) + return + } + systask.Record("retention_cleanup", "success", string(message), time.Since(start)) + log.Info("高频数据保留清理完成", zap.Int64("rows", result.Total)) } time.Sleep(2 * time.Minute) - runAgentCleanup() + runRetentionCleanup() for range ticker.C { - runAgentCleanup() - } - }() - - // 定期清理超期 SNMP 采集指标(保留 7 天) // Logan.Li - go func() { - ticker := time.NewTicker(24 * time.Hour) - defer ticker.Stop() - runSNMPCleanup := func() { - start := time.Now() - cleanupSQL := database.CleanupSNMPMetricsSQL(cfg.Database.Driver, snmppkg.MetricsRetentionDays) - r := db.Exec(cleanupSQL) - msg := fmt.Sprintf("清理 %d 条 SNMP 指标(保留 %d 天)", r.RowsAffected, snmppkg.MetricsRetentionDays) - status := "success" - if r.Error != nil { - status = "failed" - msg = r.Error.Error() - log.Warn("SNMP 指标清理失败", zap.Error(r.Error)) - } else if r.RowsAffected > 0 { - log.Info("清理过期的 SNMP 指标", zap.Int64("rows", r.RowsAffected)) - } - systask.Record("snmp_metrics_cleanup", status, msg, time.Since(start)) - } - time.Sleep(3 * time.Minute) - runSNMPCleanup() - for range ticker.C { - runSNMPCleanup() + runRetentionCleanup() } }() @@ -378,6 +356,83 @@ func main() { log.Info("服务已退出") } +// runMigrationCLI 执行独立的数据库迁移命令,不启动 HTTP 服务或定时任务。 // Logan.Li +func runMigrationCLI(args []string) { + action, flagArgs, err := parseMigrationArgs(args) + if err != nil { + fmt.Fprintf(os.Stderr, "迁移命令错误: %v\n用法: aiops migrate [全局参数]\n", err) + os.Exit(2) + } + + // 复用既有全局配置参数解析,确保 migrate 与服务使用同一数据库连接。 // Logan.Li + os.Args = append([]string{os.Args[0]}, flagArgs...) + if err := flag.CommandLine.Parse(flagArgs); err != nil { + fmt.Fprintf(os.Stderr, "迁移参数错误: %v\n", err) + os.Exit(2) + } + cfg := loadConfig() + db, err := database.New(cfg.Database) + if err != nil { + fmt.Fprintf(os.Stderr, "数据库连接失败: %v\n", err) + os.Exit(1) + } + + switch action { + case "up": + applied, err := migrations.Up(db) + if err != nil { + fmt.Fprintf(os.Stderr, "迁移失败: %v\n", err) + os.Exit(1) + } + if len(applied) == 0 { + fmt.Println("迁移已是最新状态") + return + } + fmt.Printf("已应用迁移: %s\n", strings.Join(applied, ", ")) + case "baseline": + if err := migrations.Baseline(db); err != nil { + fmt.Fprintf(os.Stderr, "基线登记失败: %v\n", err) + os.Exit(1) + } + fmt.Printf("已登记基线: %s(未修改业务表)\n", migrations.BaselineVersion) + case "status": + rows, err := migrations.Status(db) + if err != nil { + fmt.Fprintf(os.Stderr, "读取迁移状态失败: %v\n", err) + os.Exit(1) + } + for _, row := range rows { + status := "pending" + appliedAt := "-" + if row.Applied { + status = "applied" + appliedAt = row.AppliedAt.Format(time.RFC3339) + } + fmt.Printf("%-30s %-8s %-25s %s\n", row.Version, status, appliedAt, row.Description) + } + } +} + +// parseMigrationArgs 提取动作并保留其他全局参数,允许 `migrate up --config ...`。 // Logan.Li +func parseMigrationArgs(args []string) (string, []string, error) { + if len(args) == 0 { + return "", nil, fmt.Errorf("缺少动作") + } + var action string + flagArgs := make([]string, 0, len(args)-1) + for _, arg := range args { + if action == "" && (arg == "up" || arg == "status" || arg == "baseline") { + action = arg + continue + } + flagArgs = append(flagArgs, arg) + } + if action == "" { + return "", nil, fmt.Errorf("动作必须是 up、status 或 baseline") + } + return action, flagArgs, nil +} + // loadConfig 加载配置(CLI flags 优先于 config.yaml)// Logan.Li func loadConfig() *config.Config { viper.SetConfigName("config") @@ -414,6 +469,14 @@ func loadConfig() *config.Config { viper.SetDefault("jwt.secret", "") // 生产环境必须通过配置或 JWT_SECRET 环境变量设置 viper.SetDefault("jwt.expire_hour", 24) + viper.SetDefault("agent.enforce_auth", false) + viper.SetDefault("retention.agent_reports_days", 7) + viper.SetDefault("retention.metric_history_days", 30) + viper.SetDefault("retention.metric_aggregate_days", 365) + viper.SetDefault("retention.snmp_samples_days", 30) + viper.SetDefault("retention.site_results_days", 30) + viper.SetDefault("retention.log_entries_days", 30) + viper.SetDefault("retention.cleanup_batch_size", 10000) viper.SetDefault("websocket.read_buffer_size", 1024) viper.SetDefault("websocket.write_buffer_size", 1024) diff --git a/backend/middleware/agent_auth.go b/backend/middleware/agent_auth.go new file mode 100644 index 0000000..3b7b4b8 --- /dev/null +++ b/backend/middleware/agent_auth.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "net/http" + "strings" + + "ezops/model" + "ezops/pkg/agentauth" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const agentCredentialContextKey = "agentCredential" + +// AgentAuth 在兼容阶段识别新 Token,强制阶段拒绝旧的匿名和用户 JWT 调用。 // Logan.Li +func AgentAuth(db *gorm.DB, enforce bool) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + if enforce { + c.AbortWithStatusJSON(http.StatusUnauthorized, model.Response{Code: 401, Message: "未提供 Agent 凭证"}) + } + return + } + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" && strings.HasPrefix(parts[1], "agt_") { + credential, err := agentauth.Authenticate(db, parts[1]) + if err == nil { + c.Set(agentCredentialContextKey, credential) + return + } + c.AbortWithStatusJSON(http.StatusUnauthorized, model.Response{Code: 401, Message: "无效 Agent 凭证"}) + return + } + if enforce { + c.AbortWithStatusJSON(http.StatusUnauthorized, model.Response{Code: 401, Message: "无效 Agent 凭证"}) + } + } +} + +// RequireAgentHostname 校验请求中主机名与凭证绑定主机一致。 // Logan.Li +func RequireAgentHostname(c *gin.Context, hostname string) bool { + credential, exists := c.Get(agentCredentialContextKey) + if !exists { + return true + } + agentCredential, ok := credential.(*model.AgentCredential) + if !ok || agentCredential.Hostname != hostname { + c.AbortWithStatusJSON(http.StatusForbidden, model.Response{Code: 403, Message: "Agent 凭证与主机不匹配"}) + return false + } + return true +} diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index 8bfb1ee..e74da6d 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -1,7 +1,6 @@ package middleware import ( - "log" "net/http" "strings" "time" @@ -123,9 +122,7 @@ func tryAPIKeyAuth(c *gin.Context, db *gorm.DB, tokenString string) bool { return false } now := time.Now() - if err := db.Model(&model.User{}).Where("id = ?", user.ID).Update("api_access_key_last_used_at", now).Error; err != nil { - log.Printf("[Auth] 更新 API 密钥最后使用时间失败 (user_id=%d): %v", user.ID, err) - } + _ = db.Model(&model.User{}).Where("id = ?", user.ID).Update("api_access_key_last_used_at", now).Error c.Set("userID", user.ID) c.Set("username", user.Username) c.Set("isAdmin", user.IsAdmin) @@ -174,4 +171,4 @@ func GetIsAdmin(c *gin.Context) bool { return false } return isAdmin.(bool) -} \ No newline at end of file +} diff --git a/backend/middleware/automation_access.go b/backend/middleware/automation_access.go index f3cbb74..64d9bd7 100644 --- a/backend/middleware/automation_access.go +++ b/backend/middleware/automation_access.go @@ -46,7 +46,7 @@ func collectPermissionCodes(db *gorm.DB, userID uint) []string { SELECT DISTINCT p.code AS code FROM iam_user_roles ur JOIN iam_roles r ON r.id = ur.role_id AND r.status = 1 - JOIN role_permissions rp ON rp.role_id = r.id + JOIN iam_role_permissions rp ON rp.role_id = r.id JOIN iam_permissions p ON p.id = rp.permission_id AND p.status = 1 WHERE ur.user_id = ? `, userID).Scan(&rows) @@ -57,4 +57,4 @@ func collectPermissionCodes(db *gorm.DB, userID uint) []string { } } return codes -} \ No newline at end of file +} diff --git a/backend/middleware/pipeline_access.go b/backend/middleware/pipeline_access.go index a35ab80..ee9d812 100644 --- a/backend/middleware/pipeline_access.go +++ b/backend/middleware/pipeline_access.go @@ -3,6 +3,7 @@ package middleware import ( "net/http" "regexp" + "strconv" "ezops/model" @@ -28,67 +29,75 @@ func PipelineAccess(db *gorm.DB) gin.HandlerFunc { return } - // 注入 context,后续 handlers 统一从这里读 - c.Set("pipelineID", pipeline.ID) - c.Set("pipelineCode", code) - - // 2) 管理员直接放行(跳过权限检查) - if isAdmin, _ := c.Get("isAdmin"); isAdmin == true { + if authorizePipelineAccess(c, db, &pipeline) { c.Next() - return } - - username, _ := c.Get("username") - usernameStr, _ := username.(string) - if usernameStr == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未认证"}) - return - } - - pipelineID := pipeline.ID - - // 3) 检查是否直接是流水线成员 - var directCount int64 - db.Model(&model.DeployPipelineMember{}). - Where("pipeline_id = ? AND username = ?", pipelineID, usernameStr). - Count(&directCount) - if directCount > 0 { - c.Next() - return - } - - // 4) 检查是否属于授权给该流水线的用户组 - var groupCount int64 - db.Table("deploy_pipeline_members"). - Joins("JOIN deploy_user_group_members ON deploy_user_group_members.group_id = deploy_pipeline_members.group_id"). - Where("deploy_pipeline_members.pipeline_id = ? AND deploy_user_group_members.username = ?", pipelineID, usernameStr). - Count(&groupCount) - if groupCount > 0 { - c.Next() - return - } - - // 5) Jenkins-style Item Role — regex pattern matching on pipeline code - var itemRoles []model.ItemRole - db.Table("iam_item_roles"). - Joins("JOIN iam_user_item_roles ON iam_user_item_roles.item_role_id = iam_item_roles.id"). - Where("iam_item_roles.status = 1 AND iam_user_item_roles.user_id = ?", - func() uint { uid, _ := c.Get("userID"); return uid.(uint) }()). - Find(&itemRoles) - for _, ir := range itemRoles { - if pipelineMatchesItemRole(pipeline, ir.Pattern) { - c.Next() - return - } - } - - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ - "code": 403, - "message": "无权访问此流水线,请联系流水线 Owner 添加你为成员", - }) } } +// PipelineRunAccess 通过运行记录反查流水线并校验访问权限,防止按 runId 越权读取日志或工作区。 // Logan.Li +func PipelineRunAccess(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + runID, err := strconv.ParseUint(c.Param("runId"), 10, 32) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": 400, "message": "无效的运行记录 ID"}) + return + } + + var run model.PipelineRun + if err := db.Select("id", "pipeline_id").Take(&run, uint(runID)).Error; err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"code": 404, "message": "流水线运行记录不存在"}) + return + } + var pipeline model.Pipeline + if err := db.Take(&pipeline, run.PipelineID).Error; err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"code": 404, "message": "关联流水线不存在"}) + return + } + if authorizePipelineAccess(c, db, &pipeline) { + c.Next() + } + } +} + +// authorizePipelineAccess 集中复用流水线成员、用户组与 Item Role 授权规则。 // Logan.Li +func authorizePipelineAccess(c *gin.Context, db *gorm.DB, pipeline *model.Pipeline) bool { + c.Set("pipelineID", pipeline.ID) + c.Set("pipelineCode", pipeline.Code) + if isAdmin, _ := c.Get("isAdmin"); isAdmin == true { + return true + } + username, _ := c.Get("username") + usernameStr, _ := username.(string) + userID, _ := c.Get("userID") + userIDUint, ok := userID.(uint) + if usernameStr == "" || !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未认证"}) + return false + } + + var directCount int64 + db.Model(&model.DeployPipelineMember{}).Where("pipeline_id = ? AND username = ?", pipeline.ID, usernameStr).Count(&directCount) + if directCount > 0 { + return true + } + var groupCount int64 + db.Table("deploy_pipeline_members").Joins("JOIN deploy_user_group_members ON deploy_user_group_members.group_id = deploy_pipeline_members.group_id").Where("deploy_pipeline_members.pipeline_id = ? AND deploy_user_group_members.username = ?", pipeline.ID, usernameStr).Count(&groupCount) + if groupCount > 0 { + return true + } + + var itemRoles []model.ItemRole + db.Table("iam_item_roles").Joins("JOIN iam_user_item_roles ON iam_user_item_roles.item_role_id = iam_item_roles.id").Where("iam_item_roles.status = 1 AND iam_user_item_roles.user_id = ?", userIDUint).Find(&itemRoles) + for _, ir := range itemRoles { + if pipelineMatchesItemRole(*pipeline, ir.Pattern) { + return true + } + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权访问此流水线,请联系流水线 Owner 添加你为成员"}) + return false +} + // GetAccessiblePipelineIDs 获取当前用户有权访问的流水线 ID 列表 // 包括:直接成员 + 用户组 + Item Role (regex) func GetAccessiblePipelineIDs(db *gorm.DB, username string, userID uint) ([]uint, bool) { diff --git a/backend/migrations/0001_agent_credentials.mysql.sql b/backend/migrations/0001_agent_credentials.mysql.sql new file mode 100644 index 0000000..601d89d --- /dev/null +++ b/backend/migrations/0001_agent_credentials.mysql.sql @@ -0,0 +1,15 @@ +-- Agent 主机绑定凭证:只保存 bcrypt 哈希,明文 Token 仅在签发时出现。 // Logan.Li +CREATE TABLE IF NOT EXISTS monitor_agent_credentials ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + hostname VARCHAR(255) NOT NULL UNIQUE, + token_prefix VARCHAR(32) NOT NULL UNIQUE, + token_hash VARCHAR(255) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active', + issued_by VARCHAR(128) NOT NULL DEFAULT '', + last_used_at DATETIME NULL, + expires_at DATETIME NULL, + rotated_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_monitor_agent_credentials_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/migrations/0001_agent_credentials.postgres.sql b/backend/migrations/0001_agent_credentials.postgres.sql new file mode 100644 index 0000000..f17a398 --- /dev/null +++ b/backend/migrations/0001_agent_credentials.postgres.sql @@ -0,0 +1,15 @@ +-- Agent 主机绑定凭证:只保存 bcrypt 哈希,明文 Token 仅在签发时出现。 // Logan.Li +CREATE TABLE IF NOT EXISTS monitor_agent_credentials ( + id BIGSERIAL PRIMARY KEY, + hostname VARCHAR(255) NOT NULL UNIQUE, + token_prefix VARCHAR(32) NOT NULL UNIQUE, + token_hash VARCHAR(255) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active', + issued_by VARCHAR(128) NOT NULL DEFAULT '', + last_used_at TIMESTAMP NULL, + expires_at TIMESTAMP NULL, + rotated_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_monitor_agent_credentials_status ON monitor_agent_credentials(status); diff --git a/backend/migrations/0001_agent_credentials.sql b/backend/migrations/0001_agent_credentials.sql new file mode 100644 index 0000000..f43d686 --- /dev/null +++ b/backend/migrations/0001_agent_credentials.sql @@ -0,0 +1,15 @@ +-- Agent 主机绑定凭证:只保存 bcrypt 哈希,明文 Token 仅在签发时出现。 // Logan.Li +CREATE TABLE IF NOT EXISTS monitor_agent_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname VARCHAR(255) NOT NULL UNIQUE, + token_prefix VARCHAR(32) NOT NULL UNIQUE, + token_hash VARCHAR(255) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active', + issued_by VARCHAR(128) NOT NULL DEFAULT '', + last_used_at DATETIME, + expires_at DATETIME, + rotated_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_monitor_agent_credentials_status ON monitor_agent_credentials(status); diff --git a/backend/migrations/0001_agent_credentials.sqlite.sql b/backend/migrations/0001_agent_credentials.sqlite.sql new file mode 100644 index 0000000..f43d686 --- /dev/null +++ b/backend/migrations/0001_agent_credentials.sqlite.sql @@ -0,0 +1,15 @@ +-- Agent 主机绑定凭证:只保存 bcrypt 哈希,明文 Token 仅在签发时出现。 // Logan.Li +CREATE TABLE IF NOT EXISTS monitor_agent_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname VARCHAR(255) NOT NULL UNIQUE, + token_prefix VARCHAR(32) NOT NULL UNIQUE, + token_hash VARCHAR(255) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active', + issued_by VARCHAR(128) NOT NULL DEFAULT '', + last_used_at DATETIME, + expires_at DATETIME, + rotated_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_monitor_agent_credentials_status ON monitor_agent_credentials(status); diff --git a/backend/migrations/0003_billing_settings.mysql.sql b/backend/migrations/0003_billing_settings.mysql.sql new file mode 100644 index 0000000..6f81ce9 --- /dev/null +++ b/backend/migrations/0003_billing_settings.mysql.sql @@ -0,0 +1,8 @@ +-- 云账单轻量汇率设置,只维护 USD/CNY。 // Logan.Li +CREATE TABLE IF NOT EXISTS asset_cloud_billing_settings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + usd_cny_rate DECIMAL(12,6) NOT NULL DEFAULT 7.2, + updated_by VARCHAR(128) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/migrations/0003_billing_settings.postgres.sql b/backend/migrations/0003_billing_settings.postgres.sql new file mode 100644 index 0000000..5ddef2f --- /dev/null +++ b/backend/migrations/0003_billing_settings.postgres.sql @@ -0,0 +1,8 @@ +-- 云账单轻量汇率设置,只维护 USD/CNY。 // Logan.Li +CREATE TABLE IF NOT EXISTS asset_cloud_billing_settings ( + id BIGSERIAL PRIMARY KEY, + usd_cny_rate DECIMAL(12,6) NOT NULL DEFAULT 7.2, + updated_by VARCHAR(128) NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); diff --git a/backend/migrations/0003_billing_settings.sqlite.sql b/backend/migrations/0003_billing_settings.sqlite.sql new file mode 100644 index 0000000..8adf6eb --- /dev/null +++ b/backend/migrations/0003_billing_settings.sqlite.sql @@ -0,0 +1,8 @@ +-- 云账单轻量汇率设置,只维护 USD/CNY。 // Logan.Li +CREATE TABLE IF NOT EXISTS asset_cloud_billing_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + usd_cny_rate DECIMAL(12,6) NOT NULL DEFAULT 7.2, + updated_by VARCHAR(128) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); diff --git a/backend/migrations/migrations.go b/backend/migrations/migrations.go new file mode 100644 index 0000000..682a951 --- /dev/null +++ b/backend/migrations/migrations.go @@ -0,0 +1,256 @@ +// Package migrations 提供仅由命令行显式触发的版本化数据库迁移。 // Logan.Li +package migrations + +import ( + "embed" + "errors" + "fmt" + "io/fs" + "sort" + "strings" + "time" + + "ezops/pkg/database" + + "gorm.io/gorm" +) + +const ( + // BaselineVersion 标识当前模型集合的初始基线,不应在已有库上自动执行。 // Logan.Li + BaselineVersion = "0000_baseline_schema" + migrationTable = "sys_schema_migrations" +) + +//go:embed *.sql +var migrationFiles embed.FS + +// Migration 是一个不可变、更名即视为兼容性破坏的数据库变更单元。 // Logan.Li +type Migration struct { + Version string + Description string + Apply func(*gorm.DB) error +} + +// StatusRow 描述一个迁移版本的执行状态。 // Logan.Li +type StatusRow struct { + Version string + Description string + Applied bool + AppliedAt *time.Time +} + +// All 返回按版本排序的所有迁移。新迁移只能追加,禁止修改已发布版本。 // Logan.Li +func All() []Migration { + return []Migration{ + { + Version: BaselineVersion, + Description: "新数据库初始结构基线", + Apply: database.BootstrapSchema, + }, + { + Version: "0001_agent_credentials", + Description: "Agent 主机绑定凭证", + Apply: func(db *gorm.DB) error { + return executeDialectSQL(db, "0001_agent_credentials") + }, + }, + { + Version: "0002_legacy_schema_patches", + Description: "历史库字段兼容补丁", + Apply: database.ApplyLegacySchemaPatches, + }, + { + Version: "0003_billing_settings", + Description: "云账单汇率设置", + Apply: func(db *gorm.DB) error { + return executeDialectSQL(db, "0003_billing_settings") + }, + }, + } +} + +// Up 执行尚未应用的迁移。已有业务表但未完成基线登记时拒绝执行,防止误触发结构重建。 // Logan.Li +func Up(db *gorm.DB) ([]string, error) { + // 旧业务库缺少迁移记录表时必须零写入拒绝,避免误操作留下半初始化痕迹。 // Logan.Li + if hasExistingApplicationSchema(db) && !db.Migrator().HasTable(migrationTable) { + return nil, fmt.Errorf("检测到已有业务库但缺少 %s;请在备份与维护窗口确认后执行 `aiops migrate baseline`", migrationTable) + } + if err := ensureMigrationTable(db); err != nil { + return nil, err + } + applied, err := appliedVersions(db) + if err != nil { + return nil, err + } + if _, baselineApplied := applied[BaselineVersion]; !baselineApplied && hasExistingApplicationSchema(db) { + return nil, fmt.Errorf("检测到已有业务库但未登记 %s;请在备份与维护窗口确认后执行 `aiops migrate baseline`", BaselineVersion) + } + + completed := make([]string, 0) + for _, migration := range All() { + if _, alreadyApplied := applied[migration.Version]; alreadyApplied { + continue + } + if err := migration.Apply(db); err != nil { + return completed, fmt.Errorf("迁移 %s(%s)失败: %w", migration.Version, migration.Description, err) + } + if err := recordApplied(db, migration.Version); err != nil { + return completed, fmt.Errorf("登记迁移 %s 失败: %w", migration.Version, err) + } + completed = append(completed, migration.Version) + } + return completed, nil +} + +// Baseline 仅为已经存在且已人工核验的旧数据库写入初始基线记录,不修改业务表。 // Logan.Li +func Baseline(db *gorm.DB) error { + if err := ensureMigrationTable(db); err != nil { + return err + } + applied, err := appliedVersions(db) + if err != nil { + return err + } + if _, baselineApplied := applied[BaselineVersion]; baselineApplied { + return nil + } + if !hasExistingApplicationSchema(db) { + return errors.New("数据库为空,不能执行 baseline;请改用 `aiops migrate up`") + } + if err := validateLegacyBaseline(db); err != nil { + return err + } + return recordApplied(db, BaselineVersion) +} + +// Status 返回每个已知迁移的状态;只读查询,记录表不存在时全部显示 pending。 // Logan.Li +func Status(db *gorm.DB) ([]StatusRow, error) { + applied := map[string]time.Time{} + if db.Migrator().HasTable(migrationTable) { + var err error + applied, err = appliedVersions(db) + if err != nil { + return nil, err + } + } + rows := make([]StatusRow, 0, len(All())) + for _, migration := range All() { + row := StatusRow{ + Version: migration.Version, + Description: migration.Description, + } + if appliedAt, ok := applied[migration.Version]; ok { + row.Applied = true + row.AppliedAt = &appliedAt + } + rows = append(rows, row) + } + return rows, nil +} + +func ensureMigrationTable(db *gorm.DB) error { + sql := `CREATE TABLE IF NOT EXISTS sys_schema_migrations ( +version VARCHAR(128) PRIMARY KEY, +applied_at DATETIME NOT NULL +)` + if db.Dialector.Name() == "mysql" { + sql = `CREATE TABLE IF NOT EXISTS sys_schema_migrations ( +version VARCHAR(128) PRIMARY KEY, +applied_at DATETIME NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4` + } else if db.Dialector.Name() == "postgres" { + sql = `CREATE TABLE IF NOT EXISTS sys_schema_migrations ( +version VARCHAR(128) PRIMARY KEY, +applied_at TIMESTAMP NOT NULL +)` + } + if err := db.Exec(sql).Error; err != nil { + return fmt.Errorf("创建迁移记录表失败: %w", err) + } + return nil +} + +func appliedVersions(db *gorm.DB) (map[string]time.Time, error) { + type appliedMigration struct { + Version string + AppliedAt time.Time + } + var records []appliedMigration + if err := db.Table(migrationTable).Select("version, applied_at").Find(&records).Error; err != nil { + return nil, fmt.Errorf("读取迁移记录失败: %w", err) + } + result := make(map[string]time.Time, len(records)) + for _, record := range records { + result[record.Version] = record.AppliedAt + } + return result, nil +} + +func recordApplied(db *gorm.DB, version string) error { + return db.Exec("INSERT INTO sys_schema_migrations (version, applied_at) VALUES (?, ?)", version, time.Now()).Error +} + +func hasExistingApplicationSchema(db *gorm.DB) bool { + return db.Migrator().HasTable("iam_users") || db.Migrator().HasTable("asset_servers") +} + +func validateLegacyBaseline(db *gorm.DB) error { + requiredTables := []string{"iam_users", "iam_roles", "asset_servers", "deploy_pipelines", "monitor_agent_reports"} + for _, table := range requiredTables { + if !db.Migrator().HasTable(table) { + return fmt.Errorf("旧库缺少基线校验表 %s;请先恢复完整数据库或编写专项迁移", table) + } + } + return nil +} + +func executeDialectSQL(db *gorm.DB, prefix string) error { + files, err := fs.Glob(migrationFiles, prefix+".*.sql") + if err != nil { + return fmt.Errorf("查找迁移脚本失败: %w", err) + } + sort.Strings(files) + dialect := db.Dialector.Name() + fileName := prefix + "." + dialect + ".sql" + if !contains(files, fileName) { + return fmt.Errorf("迁移 %s 不支持数据库驱动 %s", prefix, dialect) + } + content, err := migrationFiles.ReadFile(fileName) + if err != nil { + return fmt.Errorf("读取迁移脚本失败: %w", err) + } + statements := splitStatements(string(content)) + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + return fmt.Errorf("执行 %s 失败: %w", fileName, err) + } + } + return nil +} + +func splitStatements(content string) []string { + parts := strings.Split(content, ";") + statements := make([]string, 0, len(parts)) + for _, part := range parts { + withoutComments := make([]string, 0) + for _, line := range strings.Split(part, "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "--") { + withoutComments = append(withoutComments, line) + } + } + if statement := strings.TrimSpace(strings.Join(withoutComments, "\n")); statement != "" { + statements = append(statements, statement) + } + } + return statements +} + +func contains(items []string, expected string) bool { + for _, item := range items { + if item == expected { + return true + } + } + return false +} diff --git a/backend/migrations/migrations_test.go b/backend/migrations/migrations_test.go new file mode 100644 index 0000000..069212b --- /dev/null +++ b/backend/migrations/migrations_test.go @@ -0,0 +1,146 @@ +package migrations + +import ( + "sort" + "testing" + "time" + + "ezops/model" + "ezops/pkg/database" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func newTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("创建测试数据库失败: %v", err) + } + return db +} + +// TestUpInitializesFreshDatabase 验证全新数据库仅通过显式迁移完成初始化。 // Logan.Li +func TestUpInitializesFreshDatabase(t *testing.T) { + db := newTestDB(t) + applied, err := Up(db) + if err != nil { + t.Fatalf("初始化迁移失败: %v", err) + } + if len(applied) != len(All()) { + t.Fatalf("迁移数量不正确: got %d want %d", len(applied), len(All())) + } + if !db.Migrator().HasTable("monitor_agent_credentials") { + t.Fatal("未创建 Agent 凭证表") + } + if !db.Migrator().HasTable("asset_cloud_billing_settings") { + t.Fatal("未创建云账单设置表") + } + + applied, err = Up(db) + if err != nil { + t.Fatalf("重复执行迁移失败: %v", err) + } + if len(applied) != 0 { + t.Fatalf("重复执行不应应用迁移: %v", applied) + } +} + +// TestBaselineProtectsExistingDatabase 验证旧库未登记基线时不会被隐式重建。 // Logan.Li +func TestBaselineProtectsExistingDatabase(t *testing.T) { + db := newTestDB(t) + if err := db.Exec("CREATE TABLE iam_users (id INTEGER PRIMARY KEY)").Error; err != nil { + t.Fatalf("创建测试表失败: %v", err) + } + if _, err := Up(db); err == nil { + t.Fatal("已有业务库未登记基线时应拒绝迁移") + } + if db.Migrator().HasTable(migrationTable) { + t.Fatal("拒绝旧业务库迁移时不应创建迁移记录表") + } +} + +// TestStatusSupportsExistingMigrationTable 验证兼容线上已有的两列迁移记录表。 // Logan.Li +func TestStatusSupportsExistingMigrationTable(t *testing.T) { + db := newTestDB(t) + if err := db.Exec("CREATE TABLE sys_schema_migrations (version TEXT PRIMARY KEY, applied_at DATETIME NOT NULL)").Error; err != nil { + t.Fatalf("创建旧版迁移记录表失败: %v", err) + } + if err := db.Exec("INSERT INTO sys_schema_migrations (version, applied_at) VALUES (?, ?)", "0001_agent_credentials", time.Now()).Error; err != nil { + t.Fatalf("写入旧版迁移记录失败: %v", err) + } + rows, err := Status(db) + if err != nil { + t.Fatalf("读取兼容迁移状态失败: %v", err) + } + if !rows[1].Applied || rows[1].Version != "0001_agent_credentials" { + t.Fatalf("未识别旧版 0001 迁移记录: %+v", rows) + } +} + +// TestBaselineDoesNotAlterProductionLikeSchema 验证旧库登记基线不修改既有业务结构,且已执行的 0001 不会重跑。 // Logan.Li +func TestBaselineDoesNotAlterProductionLikeSchema(t *testing.T) { + db := newTestDB(t) + if err := database.BootstrapSchema(db); err != nil { + t.Fatalf("创建生产库模拟结构失败: %v", err) + } + if err := db.AutoMigrate(&model.AgentCredential{}); err != nil { + t.Fatalf("创建已上线 Agent 凭证表失败: %v", err) + } + if err := db.Exec("CREATE TABLE sys_schema_migrations (version TEXT PRIMARY KEY, applied_at DATETIME NOT NULL)").Error; err != nil { + t.Fatalf("创建迁移记录表失败: %v", err) + } + if err := db.Exec("INSERT INTO sys_schema_migrations (version, applied_at) VALUES (?, ?)", "0001_agent_credentials", time.Now()).Error; err != nil { + t.Fatalf("写入已执行迁移记录失败: %v", err) + } + + before := schemaDefinitions(t, db) + if err := Baseline(db); err != nil { + t.Fatalf("旧库登记基线失败: %v", err) + } + if after := schemaDefinitions(t, db); !sameStrings(before, after) { + t.Fatalf("baseline 修改了既有业务表结构\nbefore=%v\nafter=%v", before, after) + } + + applied, err := Up(db) + if err != nil { + t.Fatalf("旧库执行后续迁移失败: %v", err) + } + if len(applied) != 2 || applied[0] != "0002_legacy_schema_patches" || applied[1] != "0003_billing_settings" { + t.Fatalf("旧库不应重跑 0001,实际执行: %v", applied) + } +} + +// schemaDefinitions 读取 SQLite 表定义,供基线非破坏性回归测试使用。 // Logan.Li +func schemaDefinitions(t *testing.T, db *gorm.DB) []string { + t.Helper() + type schemaRow struct { + Name string + SQL string + } + var rows []schemaRow + if err := db.Raw("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&rows).Error; err != nil { + t.Fatalf("读取表定义失败: %v", err) + } + definitions := make([]string, 0, len(rows)) + for _, row := range rows { + definitions = append(definitions, row.Name+":"+row.SQL) + } + return definitions +} + +// sameStrings 比较排序后的结构定义,避免 SQLite 返回顺序影响断言。 // Logan.Li +func sameStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + sort.Strings(left) + sort.Strings(right) + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/backend/model/agent.go b/backend/model/agent.go index ff27fe5..4c9ea37 100644 --- a/backend/model/agent.go +++ b/backend/model/agent.go @@ -4,11 +4,27 @@ import ( "time" ) +// AgentCredential 每台 Agent 独立凭证,只保存 bcrypt 哈希,明文仅在签发时返回。 // Logan.Li +type AgentCredential struct { + ID uint `gorm:"primarykey" json:"id"` + Hostname string `gorm:"size:255;uniqueIndex;not null" json:"hostname"` + TokenPrefix string `gorm:"size:32;uniqueIndex;not null" json:"token_prefix"` + TokenHash string `gorm:"size:255;not null" json:"-"` + Status string `gorm:"size:16;default:active;index" json:"status"` + IssuedBy string `gorm:"size:128" json:"issued_by"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *time.Time `json:"expires_at"` + RotatedAt time.Time `json:"rotated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (AgentCredential) TableName() string { return "monitor_agent_credentials" } + // AgentReport Agent 上报的原始指标数据 // Logan.Li type AgentReport struct { ID uint `gorm:"primarykey" json:"id"` Hostname string `gorm:"size:255;index;not null" json:"hostname"` - IP string `gorm:"size:64;index" json:"ip"` // Agent 上报的本机 IP,用于资产别名关联。 // Logan.Li CPUUsage float64 `gorm:"type:decimal(6,2)" json:"cpu_usage"` CPUIOwait float64 `gorm:"type:decimal(6,2)" json:"cpu_iowait"` CPUCores int `json:"cpu_cores"` @@ -206,7 +222,7 @@ type AgentPortPolicy struct { } func (AgentPortPolicy) TableName() string { - return "monitor_agent_port_policy" + return "monitor_agent_port_policies" } // AgentPortPolicyDTO 前端交互用的端口策略格式(含已解析的数组) diff --git a/backend/model/agent_cli.go b/backend/model/agent_cli.go deleted file mode 100644 index 2593a08..0000000 --- a/backend/model/agent_cli.go +++ /dev/null @@ -1,55 +0,0 @@ -// agent_cli.go - Agent CLI 会话与消息持久化模型 // Logan.Li -// 记录 AI 运维 Agent 的每次任务(关联主机 + 用户)及其 ReAct 推理过程 -// (思考/工具调用/输出/结果),供前端历史回放与审计。 -package model - -import "time" - -// AgentCLISession Agent CLI 任务会话 // Logan.Li -type AgentCLISession struct { - ID uint `gorm:"primarykey" json:"id"` - UserID uint `gorm:"index" json:"user_id"` // 发起用户 - Mode string `gorm:"size:16;default:host;index" json:"mode"` // host | kubernetes - ServerID uint `gorm:"index" json:"server_id"` // 主机模式目标主机 - ServerName string `gorm:"size:128" json:"server_name"` // 主机名快照 - ServerHost string `gorm:"size:64" json:"server_host"` // 主机 IP 快照 - ClusterID uint `gorm:"index" json:"cluster_id"` // K8s 模式集群 ID - ClusterName string `gorm:"size:128" json:"cluster_name"` // 集群名快照 - Namespace string `gorm:"size:128" json:"namespace"` // K8s 命名空间 - PodName string `gorm:"size:128" json:"pod_name"` // 可选目标 Pod - Container string `gorm:"size:128" json:"container"` // 可选目标容器 - 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"` -} - -// TableName 表名 // Logan.Li -func (AgentCLISession) TableName() string { return "agent_cli_sessions" } - -// AgentCLIMessage Agent CLI 任务消息(ReAct 推理过程的每一步) // Logan.Li -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"` -} - -// TableName 表名 // Logan.Li -func (AgentCLIMessage) TableName() string { return "agent_cli_messages" } diff --git a/backend/model/agent_task.go b/backend/model/agent_task.go index b5a2db5..21384eb 100644 --- a/backend/model/agent_task.go +++ b/backend/model/agent_task.go @@ -2,25 +2,24 @@ package model import ( "time" - ) // AgentTask Agent 远程任务 // Logan.Li type AgentTask struct { - ID uint `gorm:"primarykey" json:"id"` - TaskID string `gorm:"size:36;uniqueIndex;not null" json:"task_id"` // UUID - Hostname string `gorm:"size:255;index;not null" json:"hostname"` // 目标主机 - Type string `gorm:"size:32;not null" json:"type"` // command / script / file - Command string `gorm:"type:text" json:"command"` // 命令或脚本内容 - Timeout int `gorm:"default:60" json:"timeout"` // 超时秒数 - Status string `gorm:"size:20;default:pending;index" json:"status"` // pending/running/success/failed/timeout - Result string `gorm:"type:text" json:"result"` // 执行输出 - ExitCode int `gorm:"default:-1" json:"exit_code"` // 退出码 - CreatedBy string `gorm:"size:255" json:"created_by"` // 创建人 - CreatedAt time.Time `json:"created_at"` - PickedAt *time.Time `json:"picked_at"` // Agent 拉取时间 - FinishedAt *time.Time `json:"finished_at"` // 完成时间 - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + TaskID string `gorm:"size:36;uniqueIndex;not null" json:"task_id"` // UUID + Hostname string `gorm:"size:255;index;not null" json:"hostname"` // 目标主机 + Type string `gorm:"size:32;not null" json:"type"` // command / script / file + Command string `gorm:"type:text" json:"command"` // 命令或脚本内容 + Timeout int `gorm:"default:60" json:"timeout"` // 超时秒数 + Status string `gorm:"size:20;default:pending;index" json:"status"` // pending/running/success/failed/timeout + Result string `gorm:"type:text" json:"result"` // 执行输出 + ExitCode int `gorm:"default:-1" json:"exit_code"` // 退出码 + CreatedBy string `gorm:"size:255" json:"created_by"` // 创建人 + CreatedAt time.Time `json:"created_at"` + PickedAt *time.Time `json:"picked_at"` // Agent 拉取时间 + FinishedAt *time.Time `json:"finished_at"` // 完成时间 + UpdatedAt time.Time `json:"updated_at"` } func (AgentTask) TableName() string { @@ -29,17 +28,17 @@ func (AgentTask) TableName() string { // AgentRule 告警规则(基于 Agent 数据) // Logan.Li type AgentRule struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:255;not null" json:"name"` - Hostname string `gorm:"size:255" json:"hostname"` // 空表示全部主机 - Metric string `gorm:"size:32;not null" json:"metric"` // cpu/mem/disk/offline/port_down/tcp_time_wait/tcp_close_wait/service_process/fd - Operator string `gorm:"size:10;default:>" json:"operator"` // > >= < <= == - Threshold float64 `gorm:"type:decimal(10,2)" json:"threshold"` - Port int `json:"port"` // port_down 专用 - Enabled bool `gorm:"default:true" json:"enabled"` - LastTriggered *time.Time `json:"last_triggered"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:255;not null" json:"name"` + Hostname string `gorm:"size:255" json:"hostname"` // 空表示全部主机 + Metric string `gorm:"size:32;not null" json:"metric"` // cpu/mem/disk/offline/port_down/tcp_time_wait/tcp_close_wait/service_process/fd + Operator string `gorm:"size:10;default:>" json:"operator"` // > >= < <= == + Threshold float64 `gorm:"type:decimal(10,2)" json:"threshold"` + Port int `json:"port"` // port_down 专用 + Enabled bool `gorm:"default:true" json:"enabled"` + LastTriggered *time.Time `json:"last_triggered"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (AgentRule) TableName() string { @@ -48,18 +47,18 @@ func (AgentRule) TableName() string { // AgentAlert Agent 告警事件 // Logan.Li type AgentAlert struct { - ID uint `gorm:"primarykey" json:"id"` - RuleName string `gorm:"size:255" json:"rule_name"` - Hostname string `gorm:"size:255;index" json:"hostname"` - Metric string `gorm:"size:32" json:"metric"` - Severity string `gorm:"size:20;default:warning" json:"severity"` // critical/warning/info - Message string `gorm:"type:text" json:"message"` - Value float64 `gorm:"type:decimal(10,2)" json:"value"` - Threshold float64 `gorm:"type:decimal(10,2)" json:"threshold"` - Resolved bool `gorm:"default:false;index" json:"resolved"` - ResolvedAt *time.Time `json:"resolved_at"` - CreatedAt time.Time `gorm:"index" json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + RuleName string `gorm:"size:255" json:"rule_name"` + Hostname string `gorm:"size:255;index" json:"hostname"` + Metric string `gorm:"size:32" json:"metric"` + Severity string `gorm:"size:20;default:warning" json:"severity"` // critical/warning/info + Message string `gorm:"type:text" json:"message"` + Value float64 `gorm:"type:decimal(10,2)" json:"value"` + Threshold float64 `gorm:"type:decimal(10,2)" json:"threshold"` + Resolved bool `gorm:"default:false;index" json:"resolved"` + ResolvedAt *time.Time `json:"resolved_at"` + CreatedAt time.Time `gorm:"index" json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (AgentAlert) TableName() string { diff --git a/backend/model/ai_runtime.go b/backend/model/ai_runtime.go new file mode 100644 index 0000000..c6b4d28 --- /dev/null +++ b/backend/model/ai_runtime.go @@ -0,0 +1,172 @@ +// ai_runtime.go - AI 运行、工具能力和执行审计统一模型 // Logan.Li +package model + +import "time" + +// AIRun 统一记录 Agent CLI、告警分析、智能助手、MCP/技能调用发起的 AI 任务。 // Logan.Li +type AIRun struct { + ID uint `gorm:"primarykey" json:"id"` + RunID string `gorm:"size:64;uniqueIndex;not null" json:"run_id"` // 对外 trace/run 标识 // Logan.Li + ParentRunID string `gorm:"size:64;index" json:"parent_run_id"` // 子任务关联 // Logan.Li + UserID uint `gorm:"index" json:"user_id"` // 发起用户 // Logan.Li + Feature string `gorm:"size:64;index;not null" json:"feature"` // agent_cli/alert_analysis/assistant/mcp // Logan.Li + Mode string `gorm:"size:32;index" json:"mode"` // host/kubernetes/aiops/general // Logan.Li + Title string `gorm:"size:255" json:"title"` // 任务标题 // Logan.Li + Status string `gorm:"size:32;index;default:running" json:"status"` // running/done/error/aborted // Logan.Li + SubjectType string `gorm:"size:64;index" json:"subject_type"` // host/k8s_cluster/namespace/pod/aiops_module/mcp_server/skill // Logan.Li + SubjectID string `gorm:"size:128;index" json:"subject_id"` // 资源 ID 或稳定键 // Logan.Li + SubjectName string `gorm:"size:255" json:"subject_name"` // 资源快照名称 // Logan.Li + SubjectScope string `gorm:"size:255;index" json:"subject_scope"` // prod/ns/app 等范围键 // Logan.Li + Provider string `gorm:"size:50" json:"provider"` // LLM 提供商 // Logan.Li + Model string `gorm:"size:100" json:"model"` // 模型名 // Logan.Li + SafetyMode string `gorm:"size:32" json:"safety_mode"` // strict/balanced/permissive // Logan.Li + ContextPackID string `gorm:"size:64;index" json:"context_pack_id"` // 上下文包引用 // Logan.Li + PromptTokens int `json:"prompt_tokens"` // 输入 token // Logan.Li + CompletionTokens int `json:"completion_tokens"` // 输出 token // Logan.Li + TotalTokens int `json:"total_tokens"` // 总 token // Logan.Li + Rounds int `json:"rounds"` // Agent 轮次 // Logan.Li + Duration int64 `json:"duration"` // 毫秒 // Logan.Li + Error string `gorm:"type:text" json:"error,omitempty"` // 错误摘要 // Logan.Li + StartedAt time.Time `gorm:"index" json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (AIRun) TableName() string { return "ai_runs" } + +// AIRunMessage 统一 AI 对话消息,负责回放,不承载工具审计明细。 // Logan.Li +type AIRunMessage struct { + ID uint `gorm:"primarykey" json:"id"` + RunID string `gorm:"size:64;index;not null" json:"run_id"` + Round int `gorm:"index" json:"round"` + Seq int `gorm:"index" json:"seq"` + Role string `gorm:"size:32;index" json:"role"` // system/user/assistant/tool // Logan.Li + Type string `gorm:"size:32;index" json:"type"` // message/thought/tool_call/tool_result/done/error/status // Logan.Li + Content string `gorm:"type:text" json:"content"` // 脱敏内容 // Logan.Li + ToolName string `gorm:"size:128;index" json:"tool_name"` // 可选工具名 // Logan.Li + ToolCallID string `gorm:"size:128;index" json:"tool_call_id"` // LLM 工具调用 ID // Logan.Li + SpanID string `gorm:"size:64;index" json:"span_id"` // 关联 span // Logan.Li + Tokens int `json:"tokens"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + +func (AIRunMessage) TableName() string { return "ai_run_messages" } + +// AIRunSpan 统一 AI trace span,记录 LLM、工具、guardrail、确认、审批等事件。 // Logan.Li +type AIRunSpan struct { + ID uint `gorm:"primarykey" json:"id"` + RunID string `gorm:"size:64;index;not null" json:"run_id"` + SpanID string `gorm:"size:64;uniqueIndex;not null" json:"span_id"` + ParentSpanID string `gorm:"size:64;index" json:"parent_span_id"` + Kind string `gorm:"size:32;index;not null" json:"kind"` // llm/tool/guardrail/confirm/approval/context // Logan.Li + Name string `gorm:"size:128;index" json:"name"` // 工具名或步骤名 // Logan.Li + CapabilityID uint `gorm:"index" json:"capability_id"` // 能力注册表 ID // Logan.Li + ToolSource string `gorm:"size:32;index" json:"tool_source"` // builtin/mcp/skill/shell/kubernetes/aiops // Logan.Li + Input string `gorm:"type:text" json:"input"` // 脱敏输入 // Logan.Li + Output string `gorm:"type:text" json:"output"` // 脱敏输出摘要 // Logan.Li + OutputSize int `json:"output_size"` // 脱敏后输出长度 // Logan.Li + Risk string `gorm:"size:32;index" json:"risk"` // safe/low/medium/high/critical // Logan.Li + Decision string `gorm:"size:32;index" json:"decision"` // auto/approved/rejected/denied/escalated // Logan.Li + Status string `gorm:"size:32;index" json:"status"` // running/success/error/skipped // Logan.Li + Error string `gorm:"type:text" json:"error"` // 错误摘要 // Logan.Li + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + Duration int64 `json:"duration"` // 毫秒 // Logan.Li + StartedAt time.Time `gorm:"index" json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func (AIRunSpan) TableName() string { return "ai_run_spans" } + +// AICapability 注册 AIOps 内置工具、MCP 工具、Skill、Shell/K8s 工具的统一能力目录。 // Logan.Li +type AICapability struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;uniqueIndex;not null" json:"name"` // 稳定工具名 // Logan.Li + DisplayName string `gorm:"size:128" json:"display_name"` + Source string `gorm:"size:32;index;not null" json:"source"` // builtin/mcp/skill/shell/kubernetes/aiops // Logan.Li + Category string `gorm:"size:64;index" json:"category"` // asset/k8s/monitor/alert/change/security // Logan.Li + RiskLevel string `gorm:"size:32;index;default:safe" json:"risk_level"` + Schema string `gorm:"type:text" json:"schema"` // JSON Schema // Logan.Li + Config string `gorm:"type:text" json:"config"` // MCP server/skill 配置快照 // Logan.Li + Description string `gorm:"type:text" json:"description"` + Enabled bool `gorm:"default:true;index" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (AICapability) TableName() string { return "ai_capabilities" } + +// AICapabilityPolicy 描述工具能力在用户、角色、资源范围上的准入和审批策略。 // Logan.Li +type AICapabilityPolicy struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + CapabilityID uint `gorm:"index" json:"capability_id"` + SubjectType string `gorm:"size:32;index" json:"subject_type"` // user/role/all // Logan.Li + SubjectID string `gorm:"size:64;index" json:"subject_id"` + ResourceType string `gorm:"size:64;index" json:"resource_type"` // host/k8s_cluster/namespace/pod/aiops_module/mcp_server/skill // Logan.Li + ResourcePattern string `gorm:"size:255" json:"resource_pattern"` // 正则或通配符 // Logan.Li + Effect string `gorm:"size:16;index;default:allow" json:"effect"` // allow/deny // Logan.Li + RequireConfirm bool `gorm:"default:false" json:"require_confirm"` + RequireApproval bool `gorm:"default:false" json:"require_approval"` + MaxRisk string `gorm:"size:32;default:high" json:"max_risk"` + Enabled bool `gorm:"default:true;index" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (AICapabilityPolicy) TableName() string { return "ai_capability_policies" } + +// OpsSession 统一人机执行会话,覆盖 SSH/RDP/Pod/kubectl/Agent CLI。 // Logan.Li +type OpsSession struct { + ID uint `gorm:"primarykey" json:"id"` + SessionID string `gorm:"size:64;uniqueIndex;not null" json:"session_id"` + RunID string `gorm:"size:64;index" json:"run_id"` // AI 发起时关联 ai_runs // Logan.Li + UserID uint `gorm:"index" json:"user_id"` + SessionType string `gorm:"size:32;index;not null" json:"session_type"` // ssh/rdp/pod_exec/kubectl/agent_cli // Logan.Li + ActorType string `gorm:"size:32;index;default:user" json:"actor_type"` // user/ai/system // Logan.Li + TargetType string `gorm:"size:64;index" json:"target_type"` // host/k8s_cluster/pod // Logan.Li + TargetID string `gorm:"size:128;index" json:"target_id"` + TargetName string `gorm:"size:255" json:"target_name"` + ClientIP string `gorm:"size:64" json:"client_ip"` + Status string `gorm:"size:32;index" json:"status"` // active/closed/failed // Logan.Li + RecordRef string `gorm:"size:255" json:"record_ref"` // 录制对象存储或分片引用 // Logan.Li + RecordSize int `json:"record_size"` + Duration int64 `json:"duration"` // 秒 // Logan.Li + Error string `gorm:"type:text" json:"error,omitempty"` + StartedAt time.Time `gorm:"index" json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (OpsSession) TableName() string { return "ai_ops_sessions" } + +// OpsCommand 统一命令/动作审计,覆盖人执行和 AI 工具执行。 // Logan.Li +type OpsCommand struct { + ID uint `gorm:"primarykey" json:"id"` + CommandID string `gorm:"size:64;uniqueIndex;not null" json:"command_id"` + SessionID string `gorm:"size:64;index" json:"session_id"` + RunID string `gorm:"size:64;index" json:"run_id"` + SpanID string `gorm:"size:64;index" json:"span_id"` + UserID uint `gorm:"index" json:"user_id"` + ActorType string `gorm:"size:32;index;default:user" json:"actor_type"` // user/ai/system // Logan.Li + TargetType string `gorm:"size:64;index" json:"target_type"` + TargetID string `gorm:"size:128;index" json:"target_id"` + CommandType string `gorm:"size:32;index" json:"command_type"` // shell/k8s_action/mcp/skill/api // Logan.Li + Command string `gorm:"type:text" json:"command"` // 脱敏命令或动作参数 // Logan.Li + Risk string `gorm:"size:32;index" json:"risk"` + Decision string `gorm:"size:32;index" json:"decision"` + Status string `gorm:"size:32;index" json:"status"` + ExitCode int `gorm:"default:-1" json:"exit_code"` + OutputRef string `gorm:"size:255" json:"output_ref"` // 大输出引用 // Logan.Li + OutputSize int `json:"output_size"` + Duration int64 `json:"duration"` // 毫秒 // Logan.Li + Error string `gorm:"type:text" json:"error"` + ExecutedAt time.Time `gorm:"index" json:"executed_at"` + CreatedAt time.Time `json:"created_at"` +} + +func (OpsCommand) TableName() string { return "ai_ops_commands" } diff --git a/backend/model/alert.go b/backend/model/alert.go index e6cfce4..e4300be 100644 --- a/backend/model/alert.go +++ b/backend/model/alert.go @@ -56,41 +56,41 @@ const ( // AlertRule 告警规则(增强版) type AlertRule struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:规则名称" json:"name"` - Description string `gorm:"type:varchar(500);comment:规则描述" json:"description,omitempty"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:规则名称" json:"name"` + Description string `gorm:"type:varchar(500);comment:规则描述" json:"description,omitempty"` // ====== 原 V1 兼容字段 ====== - Type string `gorm:"type:varchar(32);comment:规则类型: metric/log/event" json:"type,omitempty"` - Source string `gorm:"type:varchar(64);comment:数据源: prometheus/zabbix/custom/loki/elasticsearch" json:"source,omitempty"` - Expression string `gorm:"type:text;comment:PromQL/Zabbix/LogQL/Lucene表达式" json:"expression,omitempty"` - DatasourceID uint `gorm:"index;comment:日志/指标数据源ID" json:"datasource_id,omitempty"` - Severity string `gorm:"type:varchar(32);default:'warning';comment:告警级别" json:"severity"` - Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"` - Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"` + Type string `gorm:"type:varchar(32);comment:规则类型: metric/log/event" json:"type,omitempty"` + Source string `gorm:"type:varchar(64);comment:数据源: prometheus/zabbix/custom/loki/elasticsearch" json:"source,omitempty"` + Expression string `gorm:"type:text;comment:PromQL/Zabbix/LogQL/Lucene表达式" json:"expression,omitempty"` + DatasourceID uint `gorm:"index;comment:日志/指标数据源ID" json:"datasource_id,omitempty"` + Severity string `gorm:"type:varchar(32);default:'warning';comment:告警级别" json:"severity"` + Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"` + Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"` // ====== V2 增强字段 ====== - Status AlertRuleStatus `gorm:"type:varchar(16);default:'enabled';comment:规则状态" json:"status,omitempty"` - TargetType string `gorm:"type:varchar(32);comment:目标类型" json:"target_type,omitempty"` - TargetID uint `gorm:"index;comment:目标ID(0表示全部)" json:"target_id,omitempty"` - TargetTags string `gorm:"type:varchar(256);comment:目标标签筛选" json:"target_tags,omitempty"` - Conditions string `gorm:"type:text;comment:触发条件JSON数组" json:"conditions,omitempty"` - AggWindow int `gorm:"default:300;comment:聚合窗口(秒)" json:"agg_window,omitempty"` - AggFunction string `gorm:"type:varchar(16);default:'avg';comment:聚合函数" json:"agg_function,omitempty"` - RecoverCount int `gorm:"default:1;comment:恢复次数" json:"recover_count,omitempty"` - RecoverWindow int `gorm:"default:300;comment:恢复检查窗口(秒)" json:"recover_window,omitempty"` - Interval int `gorm:"default:60;comment:评估间隔(秒)" json:"interval,omitempty"` - CheckTimes int `gorm:"default:1;comment:连续触发次数" json:"check_times,omitempty"` - NotifyMode string `gorm:"type:varchar(16);default:'every';comment:通知模式: every/once/upgrade" json:"notify_mode,omitempty"` - ChannelIDs string `gorm:"type:varchar(256);comment:通知渠道ID列表JSON" json:"channel_ids,omitempty"` - NotifyChannels string `gorm:"type:text;comment:通知渠道JSON(兼容V1)" json:"notify_channels,omitempty"` - TemplateID uint `gorm:"comment:通知模板ID" json:"template_id,omitempty"` - UpgradeID uint `gorm:"comment:升级策略ID" json:"upgrade_id,omitempty"` - SilenceID uint `gorm:"comment:静默规则ID" json:"silence_id,omitempty"` - Labels string `gorm:"type:text;comment:标签JSON" json:"labels,omitempty"` - Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"` - CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by,omitempty"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` + Status AlertRuleStatus `gorm:"type:varchar(16);default:'enabled';comment:规则状态" json:"status,omitempty"` + TargetType string `gorm:"type:varchar(32);comment:目标类型" json:"target_type,omitempty"` + TargetID uint `gorm:"index;comment:目标ID(0表示全部)" json:"target_id,omitempty"` + TargetTags string `gorm:"type:varchar(256);comment:目标标签筛选" json:"target_tags,omitempty"` + Conditions string `gorm:"type:text;comment:触发条件JSON数组" json:"conditions,omitempty"` + AggWindow int `gorm:"default:300;comment:聚合窗口(秒)" json:"agg_window,omitempty"` + AggFunction string `gorm:"type:varchar(16);default:'avg';comment:聚合函数" json:"agg_function,omitempty"` + RecoverCount int `gorm:"default:1;comment:恢复次数" json:"recover_count,omitempty"` + RecoverWindow int `gorm:"default:300;comment:恢复检查窗口(秒)" json:"recover_window,omitempty"` + Interval int `gorm:"default:60;comment:评估间隔(秒)" json:"interval,omitempty"` + CheckTimes int `gorm:"default:1;comment:连续触发次数" json:"check_times,omitempty"` + NotifyMode string `gorm:"type:varchar(16);default:'every';comment:通知模式: every/once/upgrade" json:"notify_mode,omitempty"` + ChannelIDs string `gorm:"type:varchar(256);comment:通知渠道ID列表JSON" json:"channel_ids,omitempty"` + NotifyChannels string `gorm:"type:text;comment:通知渠道JSON(兼容V1)" json:"notify_channels,omitempty"` + TemplateID uint `gorm:"comment:通知模板ID" json:"template_id,omitempty"` + UpgradeID uint `gorm:"comment:升级策略ID" json:"upgrade_id,omitempty"` + SilenceID uint `gorm:"comment:静默规则ID" json:"silence_id,omitempty"` + Labels string `gorm:"type:text;comment:标签JSON" json:"labels,omitempty"` + Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"` + CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by,omitempty"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` } func (AlertRule) TableName() string { @@ -112,40 +112,40 @@ const ( // AlertEvent 告警事件(增强版) type AlertEvent struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - RuleID uint `gorm:"not null;index:idx_rule_time;comment:规则ID" json:"rule_id"` - RuleName string `gorm:"type:varchar(128);comment:规则名称" json:"rule_name,omitempty"` - Severity string `gorm:"type:varchar(32);comment:告警级别" json:"severity,omitempty"` - Status string `gorm:"type:varchar(32);default:'firing';comment:事件状态" json:"status"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + RuleID uint `gorm:"not null;index:idx_rule_time;comment:规则ID" json:"rule_id"` + RuleName string `gorm:"type:varchar(128);comment:规则名称" json:"rule_name,omitempty"` + Severity string `gorm:"type:varchar(32);comment:告警级别" json:"severity,omitempty"` + Status string `gorm:"type:varchar(32);default:'firing';comment:事件状态" json:"status"` // ====== 原V1兼容字段 ====== - Target string `gorm:"type:varchar(255);comment:目标标签" json:"target,omitempty"` - Message string `gorm:"type:text;comment:告警详情" json:"message,omitempty"` - Value float64 `gorm:"comment:触发值" json:"value,omitempty"` + Target string `gorm:"type:varchar(255);comment:目标标签" json:"target,omitempty"` + Message string `gorm:"type:text;comment:告警详情" json:"message,omitempty"` + Value float64 `gorm:"comment:触发值" json:"value,omitempty"` // ====== V2增强字段 ====== - TargetID uint `gorm:"index;comment:目标ID" json:"target_id,omitempty"` - TargetName string `gorm:"type:varchar(128);comment:目标名称" json:"target_name,omitempty"` - TargetIP string `gorm:"type:varchar(64);comment:目标IP" json:"target_ip,omitempty"` - Title string `gorm:"type:varchar(256);comment:告警标题" json:"title,omitempty"` - MetricName string `gorm:"type:varchar(64);comment:指标名称" json:"metric_name,omitempty"` - Threshold float64 `gorm:"comment:阈值" json:"threshold,omitempty"` - Operator string `gorm:"type:varchar(8);comment:运算符" json:"operator,omitempty"` - Unit string `gorm:"type:varchar(32);comment:单位" json:"unit,omitempty"` - Labels string `gorm:"type:text;comment:标签JSON" json:"labels,omitempty"` - Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"` - StartedAt time.Time `gorm:"not null;index:idx_rule_time;comment:触发时间" json:"started_at"` - ResolvedAt *time.Time `gorm:"comment:恢复时间" json:"resolved_at,omitempty"` - Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"` - LastEvalAt time.Time `gorm:"comment:最后评估时间" json:"last_eval_at,omitempty"` - NotifyCount int `gorm:"default:0;comment:通知次数" json:"notify_count,omitempty"` - LastNotifyAt *time.Time `gorm:"comment:最近通知时间" json:"last_notify_at,omitempty"` - UpgradeLevel int `gorm:"default:0;comment:当前升级级别" json:"upgrade_level,omitempty"` - Acknowledged bool `gorm:"default:false;comment:是否已确认" json:"acknowledged"` - AcknowledgedBy *uint `gorm:"comment:确认人ID" json:"acknowledged_by,omitempty"` - AcknowledgedAt *time.Time `gorm:"comment:确认时间" json:"acknowledged_at,omitempty"` - AckComment string `gorm:"type:varchar(256);comment:确认备注" json:"ack_comment,omitempty"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` + TargetID uint `gorm:"index;comment:目标ID" json:"target_id,omitempty"` + TargetName string `gorm:"type:varchar(128);comment:目标名称" json:"target_name,omitempty"` + TargetIP string `gorm:"type:varchar(64);comment:目标IP" json:"target_ip,omitempty"` + Title string `gorm:"type:varchar(256);comment:告警标题" json:"title,omitempty"` + MetricName string `gorm:"type:varchar(64);comment:指标名称" json:"metric_name,omitempty"` + Threshold float64 `gorm:"comment:阈值" json:"threshold,omitempty"` + Operator string `gorm:"type:varchar(8);comment:运算符" json:"operator,omitempty"` + Unit string `gorm:"type:varchar(32);comment:单位" json:"unit,omitempty"` + Labels string `gorm:"type:text;comment:标签JSON" json:"labels,omitempty"` + Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"` + StartedAt time.Time `gorm:"not null;index:idx_rule_time;comment:触发时间" json:"started_at"` + ResolvedAt *time.Time `gorm:"comment:恢复时间" json:"resolved_at,omitempty"` + Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"` + LastEvalAt time.Time `gorm:"comment:最后评估时间" json:"last_eval_at,omitempty"` + NotifyCount int `gorm:"default:0;comment:通知次数" json:"notify_count,omitempty"` + LastNotifyAt *time.Time `gorm:"comment:最近通知时间" json:"last_notify_at,omitempty"` + UpgradeLevel int `gorm:"default:0;comment:当前升级级别" json:"upgrade_level,omitempty"` + Acknowledged bool `gorm:"default:false;comment:是否已确认" json:"acknowledged"` + AcknowledgedBy *uint `gorm:"comment:确认人ID" json:"acknowledged_by,omitempty"` + AcknowledgedAt *time.Time `gorm:"comment:确认时间" json:"acknowledged_at,omitempty"` + AckComment string `gorm:"type:varchar(256);comment:确认备注" json:"ack_comment,omitempty"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` // 关联 Rule *AlertRule `json:"rule,omitempty" gorm:"foreignKey:RuleID"` @@ -160,14 +160,14 @@ func (AlertEvent) TableName() string { // ============================================ type AlertCondition struct { - Metric string `json:"metric"` - Operator Operator `json:"operator"` - Threshold float64 `json:"threshold"` - Unit string `json:"unit,omitempty"` - Index string `json:"index,omitempty"` - LogicOp string `json:"logic_op,omitempty"` - AggFunc AggFunction `json:"agg_func,omitempty"` - Window int `json:"window,omitempty"` + Metric string `json:"metric"` + Operator Operator `json:"operator"` + Threshold float64 `json:"threshold"` + Unit string `json:"unit,omitempty"` + Index string `json:"index,omitempty"` + LogicOp string `json:"logic_op,omitempty"` + AggFunc AggFunction `json:"agg_func,omitempty"` + Window int `json:"window,omitempty"` } func (AlertCondition) TableName() string { @@ -184,21 +184,21 @@ type AlertConditionGroup struct { // ============================================ type AlertNotifyTemplate struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:模板名称" json:"name"` - Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` - ChannelType string `gorm:"type:varchar(32);not null;comment:适用渠道类型: email/sms/webhook/dingtalk/wecom/feishu" json:"channel_type"` - TitleTemplate string `gorm:"type:varchar(256);comment:标题模板" json:"title_template,omitempty"` - BodyTemplate string `gorm:"type:text;comment:正文模板" json:"body_template,omitempty"` - Variables string `gorm:"type:text;comment:可用变量说明JSON" json:"variables,omitempty"` - RecoverTitleTemplate string `gorm:"type:varchar(256);comment:恢复标题模板" json:"recover_title_template,omitempty"` - RecoverBodyTemplate string `gorm:"type:text;comment:恢复正文模板" json:"recover_body_template,omitempty"` - IsDefault bool `gorm:"default:false;comment:是否默认模板" json:"is_default"` - Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` - CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:模板名称" json:"name"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + ChannelType string `gorm:"type:varchar(32);not null;comment:适用渠道类型: email/sms/webhook/dingtalk/wecom/feishu" json:"channel_type"` + TitleTemplate string `gorm:"type:varchar(256);comment:标题模板" json:"title_template,omitempty"` + BodyTemplate string `gorm:"type:text;comment:正文模板" json:"body_template,omitempty"` + Variables string `gorm:"type:text;comment:可用变量说明JSON" json:"variables,omitempty"` + RecoverTitleTemplate string `gorm:"type:varchar(256);comment:恢复标题模板" json:"recover_title_template,omitempty"` + RecoverBodyTemplate string `gorm:"type:text;comment:恢复正文模板" json:"recover_body_template,omitempty"` + IsDefault bool `gorm:"default:false;comment:是否默认模板" json:"is_default"` + Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` + CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` } func (AlertNotifyTemplate) TableName() string { @@ -249,12 +249,12 @@ func (AlertUpgrade) TableName() string { } type AlertUpgradeRule struct { - Level int `json:"level"` - Duration int `json:"duration"` - Severity string `json:"severity"` + Level int `json:"level"` + Duration int `json:"duration"` + Severity string `json:"severity"` ChannelIDs []string `json:"channel_ids"` - NotifyUsers []uint `json:"notify_users"` - RepeatInterval int `json:"repeat_interval"` + NotifyUsers []uint `json:"notify_users"` + RepeatInterval int `json:"repeat_interval"` } type AlertUpgradeRecord struct { @@ -321,19 +321,19 @@ type AlertSilenceCheckResult struct { // NotifyChannel 通知渠道模型 type NotifyChannel struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:64;not null" json:"name"` - Type string `gorm:"size:32;not null" json:"type"` // email, sms, webhook, dingtalk, wechat - Config string `gorm:"type:text" json:"config"` // JSON - Enabled bool `gorm:"default:true" json:"enabled"` - TestStatus string `gorm:"size:32" json:"test_status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + Type string `gorm:"size:32;not null" json:"type"` // email, sms, webhook, dingtalk, wechat + Config string `gorm:"type:text" json:"config"` // JSON + Enabled bool `gorm:"default:true" json:"enabled"` + TestStatus string `gorm:"size:32" json:"test_status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 func (NotifyChannel) TableName() string { - return "monitor_notify_channels" + return "monitor_alert_channels" } // MetricData 监控指标数据 @@ -383,14 +383,14 @@ type UpdateAlertRuleRequest struct { // AlertStats 告警统计 type AlertStats struct { - Total int64 `json:"total"` - Firing int64 `json:"firing"` - Resolved int64 `json:"resolved"` - Critical int64 `json:"critical"` - Warning int64 `json:"warning"` - Info int64 `json:"info"` - TodayCount int64 `json:"today_count"` - WeekCount int64 `json:"week_count"` + Total int64 `json:"total"` + Firing int64 `json:"firing"` + Resolved int64 `json:"resolved"` + Critical int64 `json:"critical"` + Warning int64 `json:"warning"` + Info int64 `json:"info"` + TodayCount int64 `json:"today_count"` + WeekCount int64 `json:"week_count"` } // AcknowledgeAlertRequest 确认告警请求 @@ -491,7 +491,7 @@ type FeishuWebhookConfig struct { // FeishuCardMessage 飞书卡片消息 type FeishuCardMessage struct { - MsgType string `json:"msg_type"` + MsgType string `json:"msg_type"` Content FeishuCardContent `json:"content"` } @@ -499,6 +499,7 @@ type FeishuCardContent struct { Title string `json:"title"` Content [][]FeishuCardElement `json:"content"` } + // FeishuCardElement 飞书卡片元素 type FeishuCardElement struct { Tag string `json:"tag"` diff --git a/backend/model/alert_notify_policy.go b/backend/model/alert_notify_policy.go index a2cf85a..9d031fa 100644 --- a/backend/model/alert_notify_policy.go +++ b/backend/model/alert_notify_policy.go @@ -31,4 +31,4 @@ type AlertNotifyPolicy struct { UpdatedAt time.Time `json:"updated_at"` } -func (AlertNotifyPolicy) TableName() string { return "monitor_alert_notify_policies" } \ No newline at end of file +func (AlertNotifyPolicy) TableName() string { return "monitor_alert_notify_policies" } diff --git a/backend/model/alert_notify_settings.go b/backend/model/alert_notify_settings.go index 94ce018..d5515da 100644 --- a/backend/model/alert_notify_settings.go +++ b/backend/model/alert_notify_settings.go @@ -12,4 +12,4 @@ type AlertNotifySettings struct { UpdatedAt time.Time `json:"updated_at"` } -func (AlertNotifySettings) TableName() string { return "monitor_alert_notify_settings" } \ No newline at end of file +func (AlertNotifySettings) TableName() string { return "monitor_alert_notify_settings" } diff --git a/backend/model/approval.go b/backend/model/approval.go index 3f3e528..825aa5a 100644 --- a/backend/model/approval.go +++ b/backend/model/approval.go @@ -2,44 +2,43 @@ package model import ( "time" - ) // ApprovalTemplate 审批模板模型 type ApprovalTemplate struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` - Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` - Description string `gorm:"size:500" json:"description"` - Type string `gorm:"size:32;not null" json:"type"` // release, server_apply, app_deploy, permission_request - FormSchema string `gorm:"type:text" json:"form_schema"` // JSON form fields config - FlowConfig string `gorm:"type:text" json:"flow_config"` // JSON approval flow steps - Category string `gorm:"size:32;default:resource" json:"category"` // resource, app, permission - Enabled bool `gorm:"default:true" json:"enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Description string `gorm:"size:500" json:"description"` + Type string `gorm:"size:32;not null" json:"type"` // release, server_apply, app_deploy, permission_request + FormSchema string `gorm:"type:text" json:"form_schema"` // JSON form fields config + FlowConfig string `gorm:"type:text" json:"flow_config"` // JSON approval flow steps + Category string `gorm:"size:32;default:resource" json:"category"` // resource, app, permission + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 func (ApprovalTemplate) TableName() string { - return "sys_approval_templates" + return "approval_templates" } // ApprovalProcess 审批流程模型 type ApprovalProcess struct { - ID uint `gorm:"primarykey" json:"id"` - TemplateID uint `json:"template_id"` - Title string `gorm:"size:255;not null" json:"title"` - Applicant uint `json:"applicant"` - Status string `gorm:"size:32;default:pending" json:"status"` // pending, approved, rejected - FormData string `gorm:"type:text" json:"form_data"` // JSON - CurrentStep int `gorm:"default:0" json:"current_step"` - TotalSteps int `json:"total_steps"` - ApprovedAt *time.Time `json:"approved_at"` - RejectedAt *time.Time `json:"rejected_at"` - RejectReason string `gorm:"size:500" json:"reject_reason"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + TemplateID uint `json:"template_id"` + Title string `gorm:"size:255;not null" json:"title"` + Applicant uint `json:"applicant"` + Status string `gorm:"size:32;default:pending" json:"status"` // pending, approved, rejected + FormData string `gorm:"type:text" json:"form_data"` // JSON + CurrentStep int `gorm:"default:0" json:"current_step"` + TotalSteps int `json:"total_steps"` + ApprovedAt *time.Time `json:"approved_at"` + RejectedAt *time.Time `json:"rejected_at"` + RejectReason string `gorm:"size:500" json:"reject_reason"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Template *ApprovalTemplate `gorm:"foreignKey:TemplateID" json:"template,omitempty"` @@ -54,16 +53,16 @@ func (ApprovalProcess) TableName() string { // ApprovalStep 审批步骤模型 type ApprovalStep struct { - ID uint `gorm:"primarykey" json:"id"` - ProcessID uint `json:"process_id"` - Step int `json:"step"` - StepType string `gorm:"size:32;default:audit" json:"step_type"` // audit, execute - Approver uint `json:"approver"` - Status string `gorm:"size:32;default:pending" json:"status"` // pending, approved, rejected - Comment string `gorm:"size:500" json:"comment"` - ApprovedAt *time.Time `json:"approved_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + ProcessID uint `json:"process_id"` + Step int `json:"step"` + StepType string `gorm:"size:32;default:audit" json:"step_type"` // audit, execute + Approver uint `json:"approver"` + Status string `gorm:"size:32;default:pending" json:"status"` // pending, approved, rejected + Comment string `gorm:"size:500" json:"comment"` + ApprovedAt *time.Time `json:"approved_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 ApproverUser *User `gorm:"foreignKey:Approver" json:"approver_user,omitempty"` @@ -94,15 +93,15 @@ type CreateApprovalProcessRequest struct { // ApproveRequest 审批请求 type ApproveRequest struct { - Status string `json:"status"` + Status string `json:"status"` Comment string `json:"comment"` } // ApprovalStats 审批统计 type ApprovalStats struct { - Pending int64 `json:"pending"` - Approved int64 `json:"approved"` - Rejected int64 `json:"rejected"` - Total int64 `json:"total"` + Pending int64 `json:"pending"` + Approved int64 `json:"approved"` + Rejected int64 `json:"rejected"` + Total int64 `json:"total"` MyPending int64 `json:"my_pending"` } diff --git a/backend/model/approval_workflow.go b/backend/model/approval_workflow.go index 0cbdc84..9495867 100644 --- a/backend/model/approval_workflow.go +++ b/backend/model/approval_workflow.go @@ -17,52 +17,52 @@ type ApprovalWorkflow struct { } func (ApprovalWorkflow) TableName() string { - return "sys_approval_workflows" + return "approval_workflows" } // ApprovalWorkflowStep 审批流程步骤 type ApprovalWorkflowStep struct { - ID uint `gorm:"primarykey" json:"id"` - WorkflowID uint `json:"workflow_id"` - StepNumber int `gorm:"not null" json:"step_number"` - Name string `gorm:"size:128;not null" json:"name"` - StepType string `gorm:"size:32;not null" json:"step_type"` // submit, audit, execute - ApproverIDs string `gorm:"type:text" json:"approver_ids"` // JSON array of user IDs - IsRequired bool `gorm:"default:true" json:"is_required"` - TimeoutHours int `gorm:"default:24" json:"timeout_hours"` - AutoApprove bool `gorm:"default:false" json:"auto_approve_on_timeout"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + WorkflowID uint `json:"workflow_id"` + StepNumber int `gorm:"not null" json:"step_number"` + Name string `gorm:"size:128;not null" json:"name"` + StepType string `gorm:"size:32;not null" json:"step_type"` // submit, audit, execute + ApproverIDs string `gorm:"type:text" json:"approver_ids"` // JSON array of user IDs + IsRequired bool `gorm:"default:true" json:"is_required"` + TimeoutHours int `gorm:"default:24" json:"timeout_hours"` + AutoApprove bool `gorm:"default:false" json:"auto_approve_on_timeout"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (ApprovalWorkflowStep) TableName() string { - return "sys_approval_workflow_steps" + return "approval_workflow_steps" } // CreateWorkflowRequest 创建审批流程请求 type CreateWorkflowRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Category string `json:"category"` - Steps []CreateWorkflowStepReq `json:"steps"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Category string `json:"category"` + Steps []CreateWorkflowStepReq `json:"steps"` } type CreateWorkflowStepReq struct { - StepNumber int `json:"step_number"` - Name string `json:"name" binding:"required"` - StepType string `json:"step_type" binding:"required,oneof=submit audit execute"` - ApproverIDs string `json:"approver_ids"` - IsRequired bool `json:"is_required"` - TimeoutHours int `json:"timeout_hours"` - AutoApproveTimeout bool `json:"auto_approve_on_timeout"` + StepNumber int `json:"step_number"` + Name string `json:"name" binding:"required"` + StepType string `json:"step_type" binding:"required,oneof=submit audit execute"` + ApproverIDs string `json:"approver_ids"` + IsRequired bool `json:"is_required"` + TimeoutHours int `json:"timeout_hours"` + AutoApproveTimeout bool `json:"auto_approve_on_timeout"` } // UpdateWorkflowRequest 更新审批流程请求 type UpdateWorkflowRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Category *string `json:"category,omitempty"` - IsActive *bool `json:"is_active,omitempty"` - IsDefault *bool `json:"is_default,omitempty"` - Steps []CreateWorkflowStepReq `json:"steps,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Category *string `json:"category,omitempty"` + IsActive *bool `json:"is_active,omitempty"` + IsDefault *bool `json:"is_default,omitempty"` + Steps []CreateWorkflowStepReq `json:"steps,omitempty"` } diff --git a/backend/model/audit.go b/backend/model/audit.go index 55034c9..565a3fa 100644 --- a/backend/model/audit.go +++ b/backend/model/audit.go @@ -25,7 +25,7 @@ type AuditLog struct { // TableName 表名 func (AuditLog) TableName() string { - return "sys_audit_logs" + return "audit_operation_logs" } // AuditLogQuery 审计日志查询 @@ -48,11 +48,11 @@ type AuditLogListResponse struct { // AuditStats 审计统计 type AuditStats struct { - TodayCount int64 `json:"today_count"` - WeekCount int64 `json:"week_count"` - MonthCount int64 `json:"month_count"` - TopModules []ModuleStat `json:"top_modules"` - TopUsers []UserStat `json:"top_users"` + TodayCount int64 `json:"today_count"` + WeekCount int64 `json:"week_count"` + MonthCount int64 `json:"month_count"` + TopModules []ModuleStat `json:"top_modules"` + TopUsers []UserStat `json:"top_users"` } // ModuleStat 模块统计 diff --git a/backend/model/audit_setting.go b/backend/model/audit_setting.go index 7b2866c..56a9c46 100644 --- a/backend/model/audit_setting.go +++ b/backend/model/audit_setting.go @@ -20,7 +20,7 @@ type AuditSettings struct { UpdatedAt time.Time `json:"updated_at"` } -func (AuditSettings) TableName() string { return "sys_audit_settings" } +func (AuditSettings) TableName() string { return "audit_settings" } // AllowedAuditRetentionDays 允许的日志保留天数 // Logan.Li var AllowedAuditRetentionDays = []int{7, 15, 30, 90, 180, 365} diff --git a/backend/model/automation.go b/backend/model/automation.go index db85718..0e2cbea 100644 --- a/backend/model/automation.go +++ b/backend/model/automation.go @@ -2,30 +2,29 @@ package model import ( "time" - ) // CronJob 定时任务模型 type CronJob struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` - Description string `gorm:"size:500" json:"description"` - CronExpr string `gorm:"size:64;not null" json:"cron_expr"` - Type string `gorm:"size:32;not null" json:"type"` // shell, python, command - Content string `gorm:"type:text" json:"content"` - ScriptID *uint `json:"script_id"` - TargetType string `gorm:"size:32;default:server" json:"target_type"` // server, group, tag - Targets string `gorm:"type:text" json:"targets"` // JSON - Timeout int `gorm:"default:300" json:"timeout"` - Enabled bool `gorm:"default:true" json:"enabled"` - LastRunAt *time.Time `json:"last_run_at"` - LastStatus string `gorm:"size:32" json:"last_status"` - RunCount int `gorm:"default:0" json:"run_count"` - SuccessCount int `gorm:"default:0" json:"success_count"` - FailCount int `gorm:"default:0" json:"fail_count"` - NotifyOn string `gorm:"size:32;default:failure" json:"notify_on"` // always, failure, success - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Description string `gorm:"size:500" json:"description"` + CronExpr string `gorm:"size:64;not null" json:"cron_expr"` + Type string `gorm:"size:32;not null" json:"type"` // shell, python, command + Content string `gorm:"type:text" json:"content"` + ScriptID *uint `json:"script_id"` + TargetType string `gorm:"size:32;default:server" json:"target_type"` // server, group, tag + Targets string `gorm:"type:text" json:"targets"` // JSON + Timeout int `gorm:"default:300" json:"timeout"` + Enabled bool `gorm:"default:true" json:"enabled"` + LastRunAt *time.Time `json:"last_run_at"` + LastStatus string `gorm:"size:32" json:"last_status"` + RunCount int `gorm:"default:0" json:"run_count"` + SuccessCount int `gorm:"default:0" json:"success_count"` + FailCount int `gorm:"default:0" json:"fail_count"` + NotifyOn string `gorm:"size:32;default:failure" json:"notify_on"` // always, failure, success + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Script *Script `json:"script,omitempty"` @@ -38,20 +37,20 @@ func (CronJob) TableName() string { // Script 脚本库模型 type Script struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` - Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` - Description string `gorm:"size:500" json:"description"` - Type string `gorm:"size:32;default:shell" json:"type"` // shell, python - Content string `gorm:"type:text;not null" json:"content"` - Params string `gorm:"type:text" json:"params"` // JSON - Timeout int `gorm:"default:300" json:"timeout"` - Version int `gorm:"default:1" json:"version"` - RunCount int `gorm:"default:0" json:"run_count"` - CreatedBy uint `json:"created_by"` - UpdatedBy uint `json:"updated_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Description string `gorm:"size:500" json:"description"` + Type string `gorm:"size:32;default:shell" json:"type"` // shell, python + Content string `gorm:"type:text;not null" json:"content"` + Params string `gorm:"type:text" json:"params"` // JSON + Timeout int `gorm:"default:300" json:"timeout"` + Version int `gorm:"default:1" json:"version"` + RunCount int `gorm:"default:0" json:"run_count"` + CreatedBy uint `json:"created_by"` + UpdatedBy uint `json:"updated_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 @@ -61,21 +60,21 @@ func (Script) TableName() string { // TaskExecution 任务执行记录(cron / script,不含 Ansible;Ansible 走 AnsibleJob) type TaskExecution struct { - ID uint `gorm:"primarykey" json:"id"` - TaskType string `gorm:"size:32;not null" json:"task_type"` // cron, script - TaskID uint `json:"task_id"` - TaskName string `gorm:"size:128" json:"task_name"` - Status string `gorm:"size:32;default:pending" json:"status"` // pending, running, success, failed, cancelled - TargetType string `gorm:"size:32" json:"target_type"` - Targets string `gorm:"type:text" json:"targets"` - Output string `gorm:"type:longtext" json:"output"` - Error string `gorm:"type:text" json:"error"` - Duration int `json:"duration"` - StartedAt *time.Time `json:"started_at"` - FinishedAt *time.Time `json:"finished_at"` - CreatedBy uint `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + TaskType string `gorm:"size:32;not null" json:"task_type"` // cron, script + TaskID uint `json:"task_id"` + TaskName string `gorm:"size:128" json:"task_name"` + Status string `gorm:"size:32;default:pending" json:"status"` // pending, running, success, failed, cancelled + TargetType string `gorm:"size:32" json:"target_type"` + Targets string `gorm:"type:text" json:"targets"` + Output string `gorm:"type:longtext" json:"output"` + Error string `gorm:"type:text" json:"error"` + Duration int `json:"duration"` + StartedAt *time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` + CreatedBy uint `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 @@ -85,16 +84,16 @@ func (TaskExecution) TableName() string { // CreateCronJobRequest 创建定时任务请求 type CreateCronJobRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - CronExpr string `json:"cron_expr" binding:"required"` - Type string `json:"type" binding:"required,oneof=shell python powershell command bash"` - Content string `json:"content"` - ScriptID *uint `json:"script_id"` - TargetType string `json:"target_type" binding:"omitempty,oneof=server group tag"` - Targets []uint `json:"targets"` - Timeout int `json:"timeout"` - NotifyOn string `json:"notify_on" binding:"omitempty,oneof=always failure success"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + CronExpr string `json:"cron_expr" binding:"required"` + Type string `json:"type" binding:"required,oneof=shell python powershell command bash"` + Content string `json:"content"` + ScriptID *uint `json:"script_id"` + TargetType string `json:"target_type" binding:"omitempty,oneof=server group tag"` + Targets []uint `json:"targets"` + Timeout int `json:"timeout"` + NotifyOn string `json:"notify_on" binding:"omitempty,oneof=always failure success"` } // CreateScriptRequest 创建脚本请求 @@ -117,9 +116,9 @@ type ExecuteScriptRequest struct { // AutomationStats 自动化统计 type AutomationStats struct { - CronJobCount int64 `json:"cron_job_count"` - ScriptCount int64 `json:"script_count"` - PlaybookCount int64 `json:"playbook_count"` - TodayExecutions int64 `json:"today_executions"` - SuccessRate float64 `json:"success_rate"` + CronJobCount int64 `json:"cron_job_count"` + ScriptCount int64 `json:"script_count"` + PlaybookCount int64 `json:"playbook_count"` + TodayExecutions int64 `json:"today_executions"` + SuccessRate float64 `json:"success_rate"` } diff --git a/backend/model/billing.go b/backend/model/billing.go index 57743cb..6946772 100644 --- a/backend/model/billing.go +++ b/backend/model/billing.go @@ -22,9 +22,21 @@ type CloudBilling struct { } func (CloudBilling) TableName() string { - return "asset_cloud_billings" + return "asset_cloud_billing_records" } +// BillingSettings 保存轻量云账单换算设置。 // Logan.Li +type BillingSettings struct { + ID uint `gorm:"primarykey" json:"id"` + USDCNYRate float64 `gorm:"type:decimal(12,6);default:7.2" json:"usd_cny_rate"` + UpdatedBy string `gorm:"size:128" json:"updated_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName 指定云账单设置表名。 // Logan.Li +func (BillingSettings) TableName() string { return "asset_cloud_billing_settings" } + // BillingAlertRule 账单预警规则 type BillingAlertRule struct { ID uint `gorm:"primarykey" json:"id"` @@ -41,19 +53,24 @@ type BillingAlertRule struct { } func (BillingAlertRule) TableName() string { - return "monitor_billing_alert_rules" + return "asset_cloud_billing_alert_rules" } // BillingStats 账单统计 type BillingStats struct { - Currency string `json:"currency"` - TodayCost float64 `json:"today_cost"` - MonthCost float64 `json:"month_cost"` - YearCost float64 `json:"year_cost"` - YesterdayCost float64 `json:"yesterday_cost"` - MonthTrend []DailyCost `json:"month_trend"` - ProviderDist []ProviderCost `json:"provider_dist"` - Alerts []BillingAlert `json:"alerts"` + TodayCost float64 `json:"today_cost"` + MonthCost float64 `json:"month_cost"` + YearCost float64 `json:"year_cost"` + YesterdayCost float64 `json:"yesterday_cost"` + MonthTrend []DailyCost `json:"month_trend"` + ProviderDist []ProviderCost `json:"provider_dist"` + ProviderTrend []ProviderDailyCost `json:"provider_trend"` + TopServices []ServiceCost `json:"top_services"` + Alerts []BillingAlert `json:"alerts"` + Currency string `json:"currency"` + ExchangeRates map[string]float64 `json:"exchange_rates"` + RateSource string `json:"rate_source"` + DataThrough string `json:"data_through"` } // DailyCost 每日成本 @@ -69,6 +86,19 @@ type ProviderCost struct { Percent float64 `json:"percent"` } +// ProviderDailyCost 表示指定云厂商按报告币种折算后的日费用。 // Logan.Li +type ProviderDailyCost struct { + Provider string `json:"provider"` + Date string `json:"date"` + Cost float64 `json:"cost"` +} + +// ServiceCost 表示本月服务费用排行。 // Logan.Li +type ServiceCost struct { + Service string `json:"service"` + Cost float64 `json:"cost"` +} + // BillingAlert 账单预警 type BillingAlert struct { RuleID uint `json:"rule_id"` diff --git a/backend/model/build_node.go b/backend/model/build_node.go index ecdb8f7..1651ad2 100644 --- a/backend/model/build_node.go +++ b/backend/model/build_node.go @@ -32,7 +32,7 @@ type BuildMasterConfig struct { UpdatedAt time.Time `json:"updated_at"` } -func (BuildMasterConfig) TableName() string { return "deploy_build_master_config" } +func (BuildMasterConfig) TableName() string { return "deploy_build_master_configs" } // UpdateBuildMasterRequest 更新 Master 并发数 // Logan.Li type UpdateBuildMasterRequest struct { @@ -58,4 +58,4 @@ type UpdateBuildNodeRequest struct { MaxExecutors int `json:"max_executors"` WorkspacePath string `json:"workspace_path"` Enabled *bool `json:"enabled"` -} \ No newline at end of file +} diff --git a/backend/model/cicd.go b/backend/model/cicd.go index 5ee6ca9..1c4cd46 100644 --- a/backend/model/cicd.go +++ b/backend/model/cicd.go @@ -2,23 +2,23 @@ package model import ( "time" - ) // Pipeline CI/CD流水线模型 type Pipeline struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` - Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` - JobName string `gorm:"size:128;uniqueIndex;comment:Job名称(webhook URL标识, 如 cex-admin-server)" json:"job_name,omitempty"` - Description string `gorm:"size:500" json:"description"` - Type string `gorm:"size:32;default:deploy" json:"type"` // build, deploy, test - Source string `gorm:"size:255" json:"source"` // Git仓库地址 - Branch string `gorm:"size:64;default:main" json:"branch"` - Dockerfile string `gorm:"size:255" json:"dockerfile"` - Stages string `gorm:"type:text" json:"stages"` // JSON - Variables string `gorm:"type:text" json:"variables"` // JSON - Enabled bool `gorm:"default:true" json:"enabled"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + JobName string `gorm:"size:128;uniqueIndex;comment:Job名称(webhook URL标识, 如 cex-admin-server)" json:"job_name,omitempty"` + Description string `gorm:"size:500" json:"description"` + Type string `gorm:"size:32;default:deploy" json:"type"` // build, deploy, test + CIProvider string `gorm:"column:ci_provider;size:32;default:aiops;index;comment:流水线执行引擎 aiops/jenkins" json:"ci_provider"` + Source string `gorm:"size:255" json:"source"` // Git仓库地址 + Branch string `gorm:"size:64;default:main" json:"branch"` + Dockerfile string `gorm:"size:255" json:"dockerfile"` + Stages string `gorm:"type:text" json:"stages"` // JSON + Variables string `gorm:"type:text" json:"variables"` // JSON + Enabled bool `gorm:"default:true" json:"enabled"` // 部署模式 DeployMode string `gorm:"size:16;default:branch;comment:部署模式 branch/tag" json:"deploy_mode"` @@ -36,16 +36,12 @@ type Pipeline struct { DeployScript string `gorm:"type:text;comment:SSH部署后执行命令" json:"deploy_script,omitempty"` // K8s 部署目标 - K8sClusterID *uint `json:"k8s_cluster_id,omitempty"` // FK → kubernetes_clusters + K8sClusterID *uint `json:"k8s_cluster_id,omitempty"` // FK → k8s_clusters K8sNamespace string `gorm:"size:128;comment:K8s部署命名空间" json:"k8s_namespace,omitempty"` K8sDeployment string `gorm:"size:128;comment:K8s Deployment 名称" json:"k8s_deployment,omitempty"` K8sContainerName string `gorm:"size:128;comment:K8s更新镜像目标容器" json:"k8s_container_name,omitempty"` - // 仓库和镜像仓库关联(旧字段,待废弃) - RepoID *uint `json:"repo_id"` // FK → deploy_pipeline_repos - RegistryID *uint `json:"registry_id"` // FK → deploy_pipeline_registries - - // 凭证关联(直接关联凭证 ID) + // 凭证关联(直接关联统一凭证 ID)// Logan.Li GitCredentialID *uint `json:"git_credential_id"` // FK → iam_credentials (Git Token) RegistryCredentialID *uint `json:"registry_credential_id"` // FK → iam_credentials (Docker Password) @@ -62,6 +58,15 @@ type Pipeline struct { // 缓存配置 CachePaths string `gorm:"type:text;comment:缓存路径(逗号分隔)" json:"cache_paths,omitempty"` + // Jenkins 外部流水线映射,ci_provider=jenkins 时生效。 // Logan.Li + JenkinsJobName string `gorm:"size:255;index;comment:Jenkins Job 名称或 folder/job 路径" json:"jenkins_job_name,omitempty"` + JenkinsJobURL string `gorm:"size:500;comment:Jenkins Job URL" json:"jenkins_job_url,omitempty"` + JenkinsView string `gorm:"size:128;index;comment:Jenkins 主视图名称" json:"jenkins_view,omitempty"` + JenkinsViewURL string `gorm:"size:500;comment:Jenkins 主视图 URL" json:"jenkins_view_url,omitempty"` + JenkinsViews string `gorm:"type:text;comment:Jenkins 视图列表 JSON" json:"jenkins_views,omitempty"` + JenkinsParams string `gorm:"type:text;comment:Jenkins 参数定义 JSON" json:"jenkins_params,omitempty"` + JenkinsSteps string `gorm:"type:text;comment:Jenkins 构建步骤只读摘要 JSON" json:"jenkins_steps,omitempty"` + LastRunAt *time.Time `json:"last_run_at"` LastStatus string `gorm:"size:32" json:"last_status"` RunCount int `gorm:"default:0" json:"run_count"` @@ -71,39 +76,39 @@ type Pipeline struct { UpdatedAt time.Time `json:"updated_at"` // 关联 - Repo *PipelineRepo `json:"repo,omitempty" gorm:"foreignKey:RepoID"` - Registry *PipelineRegistry `json:"registry,omitempty" gorm:"foreignKey:RegistryID"` - Environment *DeployEnvironment `json:"environment,omitempty" gorm:"foreignKey:EnvironmentID"` + Environment *DeployEnvironment `json:"environment,omitempty" gorm:"foreignKey:EnvironmentID"` } // TableName 表名 func (Pipeline) TableName() string { - return "ops_pipelines" + return "deploy_pipelines" } // PipelineRun 流水线运行记录(CI 执行) type PipelineRun struct { - ID uint `gorm:"primarykey" json:"id"` - PipelineID uint `gorm:"not null" json:"pipeline_id"` - BuildNumber int `json:"build_number"` - Status string `gorm:"size:32;default:pending" json:"status"` // pending, running, success, failed, cancelled - Branch string `gorm:"size:64" json:"branch"` - CommitID string `gorm:"size:64" json:"commit_id"` - CommitMsg string `gorm:"size:500" json:"commit_msg"` - Author string `gorm:"size:64" json:"author"` - ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签 - ArtifactURL string `gorm:"size:500" json:"artifact_url"` // 构建产物地址 - WorkspacePath string `gorm:"size:500" json:"workspace_path"` // 工作区路径 - BuildNodeID *uint `json:"build_node_id,omitempty"` // 实际执行节点(0/nil=Master) - BuildNodeName string `gorm:"size:64" json:"build_node_name,omitempty"` - Duration int `json:"duration"` // 秒 - Stages string `gorm:"type:text" json:"stages"` // JSON - Logs string `gorm:"type:longtext" json:"logs"` - TriggeredBy string `gorm:"size:64" json:"triggered_by"` - StartedAt *time.Time `json:"started_at"` - FinishedAt *time.Time `json:"finished_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + PipelineID uint `gorm:"not null" json:"pipeline_id"` + BuildNumber int `json:"build_number"` + Status string `gorm:"size:32;default:pending" json:"status"` // pending, running, success, failed, cancelled + Branch string `gorm:"size:64" json:"branch"` + CommitID string `gorm:"size:64" json:"commit_id"` + CommitMsg string `gorm:"size:500" json:"commit_msg"` + Author string `gorm:"size:64" json:"author"` + ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签 + ArtifactURL string `gorm:"size:500" json:"artifact_url"` // 构建产物地址 + WorkspacePath string `gorm:"size:500" json:"workspace_path"` // 工作区路径 + BuildNodeID *uint `json:"build_node_id,omitempty"` // 实际执行节点(0/nil=Master) + BuildNodeName string `gorm:"size:64" json:"build_node_name,omitempty"` + Duration int `json:"duration"` // 秒 + Stages string `gorm:"type:text" json:"stages"` // JSON + Logs string `gorm:"type:longtext" json:"logs"` + TriggeredBy string `gorm:"size:64" json:"triggered_by"` + ExternalRunID string `gorm:"size:128;index;comment:外部 CI 构建号/队列号" json:"external_run_id,omitempty"` + ExternalURL string `gorm:"size:500;comment:外部 CI 构建 URL" json:"external_url,omitempty"` + StartedAt *time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Pipeline *Pipeline `json:"pipeline,omitempty"` @@ -111,34 +116,53 @@ type PipelineRun struct { // TableName 表名 func (PipelineRun) TableName() string { - return "ops_pipeline_runs" + return "deploy_pipeline_runs" +} + +// JenkinsConfig 保存 Jenkins 外部 CI 接入配置,密钥加密后落库。 // Logan.Li +type JenkinsConfig struct { + ID uint `gorm:"primarykey" json:"id"` + BaseURL string `gorm:"size:255;not null" json:"base_url"` + Username string `gorm:"size:128" json:"username"` + Token string `gorm:"type:text" json:"-"` + AuthType string `gorm:"size:32;default:token" json:"auth_type"` + Enabled bool `gorm:"default:true" json:"enabled"` + RefMode string `gorm:"size:16;default:both;comment:Jenkins 分支/Tag 选择模式 branch/tag/both" json:"ref_mode"` + RefParam string `gorm:"size:64;default:SELECT;comment:Jenkins 分支或 Tag 参数名" json:"ref_param"` + LastSyncAt *time.Time `json:"last_sync_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (JenkinsConfig) TableName() string { + return "deploy_jenkins_configs" } // AppRelease 应用发布模型(CD:从 CI 产物部署到目标环境) type AppRelease struct { - ID uint `gorm:"primarykey" json:"id"` - PipelineID *uint `json:"pipeline_id"` // 来源流水线 - PipelineRunID *uint `json:"pipeline_run_id"` // 来源 CI 执行记录 - AppName string `gorm:"size:128;not null" json:"app_name"` - Version string `gorm:"size:64;not null" json:"version"` - Environment string `gorm:"size:32;not null" json:"environment"` // dev, test, staging, prod - DeployType string `gorm:"size:32;default:server" json:"deploy_type"` // server, kubernetes - Status string `gorm:"size:32;default:pending" json:"status"` // pending, approved, rejected, deploying, success, failed, rolled_back - CommitID string `gorm:"size:64" json:"commit_id"` - CommitMsg string `gorm:"size:500" json:"commit_msg"` - Author string `gorm:"size:64" json:"author"` - Approver string `gorm:"size:64" json:"approver"` - ApprovedAt *time.Time `json:"approved_at"` - DeployedAt *time.Time `json:"deployed_at"` - RollbackAt *time.Time `json:"rollback_at"` - Duration int `json:"duration"` - ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签 - K8sClusterID *uint `json:"k8s_cluster_id"` // Kubernetes 部署目标集群 - K8sNamespace string `gorm:"size:128" json:"k8s_namespace"` // Kubernetes 部署目标命名空间 - Servers string `gorm:"type:text" json:"servers"` // JSON(传统服务器部署目标) - Description string `gorm:"size:500" json:"description"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + PipelineID *uint `json:"pipeline_id"` // 来源流水线 + PipelineRunID *uint `json:"pipeline_run_id"` // 来源 CI 执行记录 + AppName string `gorm:"size:128;not null" json:"app_name"` + Version string `gorm:"size:64;not null" json:"version"` + Environment string `gorm:"size:32;not null" json:"environment"` // dev, test, staging, prod + DeployType string `gorm:"size:32;default:server" json:"deploy_type"` // server, kubernetes + Status string `gorm:"size:32;default:pending" json:"status"` // pending, approved, rejected, deploying, success, failed, rolled_back + CommitID string `gorm:"size:64" json:"commit_id"` + CommitMsg string `gorm:"size:500" json:"commit_msg"` + Author string `gorm:"size:64" json:"author"` + Approver string `gorm:"size:64" json:"approver"` + ApprovedAt *time.Time `json:"approved_at"` + DeployedAt *time.Time `json:"deployed_at"` + RollbackAt *time.Time `json:"rollback_at"` + Duration int `json:"duration"` + ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签 + K8sClusterID *uint `json:"k8s_cluster_id"` // Kubernetes 部署目标集群 + K8sNamespace string `gorm:"size:128" json:"k8s_namespace"` // Kubernetes 部署目标命名空间 + Servers string `gorm:"type:text" json:"servers"` // JSON(传统服务器部署目标) + Description string `gorm:"size:500" json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 @@ -150,9 +174,10 @@ func (AppRelease) TableName() string { type CreatePipelineRequest struct { Name string `json:"name" binding:"required"` Code string `json:"code" binding:"required"` - JobName string `json:"job_name"` // Webhook Job名称 + JobName string `json:"job_name"` // Webhook Job名称 Description string `json:"description"` Type string `json:"type" binding:"omitempty,oneof=build deploy test"` + CIProvider string `json:"ci_provider"` Source string `json:"source"` Branch string `json:"branch"` Dockerfile string `json:"dockerfile"` @@ -160,9 +185,9 @@ type CreatePipelineRequest struct { Variables string `json:"variables"` // 部署模式 - DeployMode string `json:"deploy_mode"` // branch / tag - DeployTarget string `json:"deploy_target"` // ssh / k8s - ServerIDs string `json:"server_ids"` // SSH 服务器ID JSON [1,2,3] + DeployMode string `json:"deploy_mode"` // branch / tag + DeployTarget string `json:"deploy_target"` // ssh / k8s + ServerIDs string `json:"server_ids"` // SSH 服务器ID JSON [1,2,3] DeployRemotePath string `json:"deploy_remote_path"` DeployScript string `json:"deploy_script"` K8sClusterID *uint `json:"k8s_cluster_id"` @@ -170,12 +195,10 @@ type CreatePipelineRequest struct { K8sDeployment string `json:"k8s_deployment"` K8sContainerName string `json:"k8s_container_name"` - RepoID *uint `json:"repo_id"` - RegistryID *uint `json:"registry_id"` - // 凭证关联 + // 凭证关联 // Logan.Li GitCredentialID *uint `json:"git_credential_id"` RegistryCredentialID *uint `json:"registry_credential_id"` - EnvironmentID *uint `json:"environment_id"` + EnvironmentID *uint `json:"environment_id"` // 触发配置 TriggerMode string `json:"trigger_mode"` @@ -187,6 +210,13 @@ type CreatePipelineRequest struct { // 缓存路径 CachePaths string `json:"cache_paths"` + JenkinsJobName string `json:"jenkins_job_name"` + JenkinsJobURL string `json:"jenkins_job_url"` + JenkinsView string `json:"jenkins_view"` + JenkinsViewURL string `json:"jenkins_view_url"` + JenkinsViews string `json:"jenkins_views"` + JenkinsParams string `json:"jenkins_params"` + // 并发控制 MaxExecutors int `json:"max_executors"` @@ -196,17 +226,18 @@ type CreatePipelineRequest struct { // UpdatePipelineRequest 更新流水线请求 type UpdatePipelineRequest struct { - Name string `json:"name"` - JobName string `json:"job_name"` - Description string `json:"description"` - Type string `json:"type"` - Source string `json:"source"` - Branch string `json:"branch"` - Dockerfile string `json:"dockerfile"` - Stages string `json:"stages"` - Variables string `json:"variables"` - DeployMode string `json:"deploy_mode"` - DeployTarget string `json:"deploy_target"` + Name string `json:"name"` + JobName string `json:"job_name"` + Description string `json:"description"` + Type string `json:"type"` + CIProvider string `json:"ci_provider"` + Source string `json:"source"` + Branch string `json:"branch"` + Dockerfile string `json:"dockerfile"` + Stages string `json:"stages"` + Variables string `json:"variables"` + DeployMode string `json:"deploy_mode"` + DeployTarget string `json:"deploy_target"` ServerIDs string `json:"server_ids"` DeployRemotePath string `json:"deploy_remote_path"` DeployScript string `json:"deploy_script"` @@ -214,12 +245,10 @@ type UpdatePipelineRequest struct { K8sNamespace string `json:"k8s_namespace"` K8sDeployment string `json:"k8s_deployment"` K8sContainerName string `json:"k8s_container_name"` - RepoID *uint `json:"repo_id"` - RegistryID *uint `json:"registry_id"` - // 凭证关联 + // 凭证关联 // Logan.Li GitCredentialID *uint `json:"git_credential_id"` RegistryCredentialID *uint `json:"registry_credential_id"` - EnvironmentID *uint `json:"environment_id"` + EnvironmentID *uint `json:"environment_id"` // 触发配置 TriggerMode string `json:"trigger_mode"` WatchBranches string `json:"watch_branches"` @@ -227,8 +256,14 @@ type UpdatePipelineRequest struct { CronExpr string `json:"cron_expr"` WebhookToken string `json:"webhook_token"` // 缓存 - CachePaths string `json:"cache_paths"` - Enabled *bool `json:"enabled"` + CachePaths string `json:"cache_paths"` + Enabled *bool `json:"enabled"` + JenkinsJobName string `json:"jenkins_job_name"` + JenkinsJobURL string `json:"jenkins_job_url"` + JenkinsView string `json:"jenkins_view"` + JenkinsViewURL string `json:"jenkins_view_url"` + JenkinsViews string `json:"jenkins_views"` + JenkinsParams string `json:"jenkins_params"` // 并发控制 MaxExecutors int `json:"max_executors"` @@ -240,10 +275,27 @@ type UpdatePipelineRequest struct { // TriggerPipelineRequest 触发流水线请求 type TriggerPipelineRequest struct { Branch string `json:"branch"` - Tag string `json:"tag"` // 发布 Tag + Tag string `json:"tag"` // 发布 Tag Variables map[string]string `json:"variables"` } +// JenkinsConfigRequest 保存 Jenkins 接入配置。Token 为空时保留旧密钥。 // Logan.Li +type JenkinsConfigRequest struct { + BaseURL string `json:"base_url" binding:"required"` + Username string `json:"username"` + Token string `json:"token"` + AuthType string `json:"auth_type"` + Enabled *bool `json:"enabled"` + RefMode string `json:"ref_mode"` + RefParam string `json:"ref_param"` +} + +// JenkinsSyncRequest 同步 Jenkins Jobs 到 AIOps 流水线。 // Logan.Li +type JenkinsSyncRequest struct { + Prefix string `json:"prefix"` + Overwrite bool `json:"overwrite"` +} + // CreateAppReleaseRequest 创建应用发布请求 type CreateAppReleaseRequest struct { PipelineID *uint `json:"pipeline_id"` @@ -263,10 +315,10 @@ type CreateAppReleaseRequest struct { // PipelineStats 流水线统计 type PipelineStats struct { - Total int64 `json:"total"` - Success int64 `json:"success"` - Failed int64 `json:"failed"` - Running int64 `json:"running"` - TodayCount int64 `json:"today_count"` - SuccessRate float64 `json:"success_rate"` + Total int64 `json:"total"` + Success int64 `json:"success"` + Failed int64 `json:"failed"` + Running int64 `json:"running"` + TodayCount int64 `json:"today_count"` + SuccessRate float64 `json:"success_rate"` } diff --git a/backend/model/common.go b/backend/model/common.go index 532b308..da02bdd 100644 --- a/backend/model/common.go +++ b/backend/model/common.go @@ -80,10 +80,10 @@ type OpsOverviewStats struct { // CostStats 成本统计 type CostStats struct { - TotalCost float64 `json:"total_cost"` - MonthCost float64 `json:"month_cost"` - CloudCosts []CloudCost `json:"cloud_costs"` - MonthTrend []MonthTrend `json:"month_trend"` + TotalCost float64 `json:"total_cost"` + MonthCost float64 `json:"month_cost"` + CloudCosts []CloudCost `json:"cloud_costs"` + MonthTrend []MonthTrend `json:"month_trend"` } // CloudCost 云平台成本 @@ -115,8 +115,8 @@ type ServerResource struct { // DomainCert 域名证书 type DomainCert struct { - ID uint `json:"id"` - Name string `json:"name"` - ExpireDate string `json:"expire_date"` - DaysUntilExpiry int `json:"days_until_expiry"` + ID uint `json:"id"` + Name string `json:"name"` + ExpireDate string `json:"expire_date"` + DaysUntilExpiry int `json:"days_until_expiry"` } diff --git a/backend/model/custom_dashboard.go b/backend/model/custom_dashboard.go deleted file mode 100644 index 91061ee..0000000 --- a/backend/model/custom_dashboard.go +++ /dev/null @@ -1,31 +0,0 @@ -package model - -import ( - "time" -) - -// CustomDashboard 自定义监控仪表盘(面板配置存 JSON) // Logan.Li -type CustomDashboard struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:仪表盘名称" json:"name"` - Description string `gorm:"type:varchar(512);comment:描述" json:"description"` - Datasource string `gorm:"type:varchar(32);default:'prometheus';comment:数据源类型" json:"datasource"` - DatasourceURL string `gorm:"type:varchar(512);comment:数据源URL(留空则用默认Prometheus)" json:"datasource_url"` - // Panels 存储面板配置 JSON 数组: [{id,title,type,query,width,height,x,y,options}] - Panels string `gorm:"type:longtext;comment:面板配置JSON" json:"panels"` - // Tags 标签(JSON 数组字符串) - Tags string `gorm:"type:varchar(512);comment:标签JSON" json:"tags,omitempty"` - // 归属 - CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by"` - IsDefault bool `gorm:"default:false;index;comment:是否默认仪表盘" json:"is_default"` - IsFavorite bool `gorm:"default:false;comment:是否收藏" json:"is_favorite"` - // 时间戳 - CreatedAt time.Time `gorm:"not null;index;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` -} - -// TableName 指定表名 -func (CustomDashboard) TableName() string { - return "monitor_custom_dashboards" -} diff --git a/backend/model/deploy.go b/backend/model/deploy.go index ab81096..cc1d84f 100644 --- a/backend/model/deploy.go +++ b/backend/model/deploy.go @@ -2,91 +2,27 @@ package model import "time" -// ============================================ -// PipelineRepo Git 仓库认证配置 -// ============================================ - -// RepoAuthType 仓库认证方式 -type RepoAuthType string - -const ( - RepoAuthToken RepoAuthType = "token" // Personal Access Token - RepoAuthSSH RepoAuthType = "ssh" // SSH Key - RepoAuthPassword RepoAuthType = "password" // 用户名+密码 -) - -// PipelineRepo Git 仓库认证配置表 -type PipelineRepo struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:配置名称" json:"name"` - Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` - URL string `gorm:"type:varchar(512);not null;comment:仓库 URL" json:"url"` - AuthType RepoAuthType `gorm:"type:varchar(16);default:'token';comment:认证方式: token/ssh/password" json:"auth_type"` - CredentialID *uint `gorm:"index;comment:凭据ID(关联 iam_credentials)" json:"credential_id,omitempty"` - Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"` - CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` -} - -// TableName 表名 -func (PipelineRepo) TableName() string { - return "deploy_pipeline_repos" -} - -// ============================================ -// PipelineRegistry 镜像仓库认证配置 -// ============================================ - -// RegistryAuthType 镜像仓库认证方式 -type RegistryAuthType string - -const ( - RegistryAuthToken RegistryAuthType = "token" // Access Token - RegistryAuthPassword RegistryAuthType = "password" // 用户名+密码 -) - -// PipelineRegistry 镜像仓库认证配置表 -type PipelineRegistry struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:配置名称" json:"name"` - Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` - RegistryURL string `gorm:"type:varchar(512);not null;comment:镜像仓库地址" json:"registry_url"` - Namespace string `gorm:"type:varchar(128);comment:默认命名空间" json:"namespace,omitempty"` - AuthType RegistryAuthType `gorm:"type:varchar(16);default:'password';comment:认证方式: token/password" json:"auth_type"` - CredentialID *uint `gorm:"index;comment:凭据ID(关联 iam_credentials)" json:"credential_id,omitempty"` - Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"` - CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` -} - -// TableName 表名 -func (PipelineRegistry) TableName() string { - return "deploy_pipeline_registries" -} - // ============================================ // PipelineStage 流水线阶段定义(JSON) // ============================================ // PipelineStage 一个阶段包含多个任务 type PipelineStage struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` // source/build/deploy/test/custom + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // source/build/deploy/test/custom Tasks []PipelineTask `json:"tasks"` } // PipelineTask 阶段内的任务 type PipelineTask struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` // build/deploy/test/custom - Icon string `json:"icon,omitempty"` - Config map[string]string `json:"config,omitempty"` - Script string `json:"script,omitempty"` - ScriptType string `json:"script_type,omitempty"` // shell/python + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // build/deploy/test/custom + Icon string `json:"icon,omitempty"` + Config map[string]string `json:"config,omitempty"` + Script string `json:"script,omitempty"` + ScriptType string `json:"script_type,omitempty"` // shell/python } // ============================================ diff --git a/backend/model/deploy_tool.go b/backend/model/deploy_tool.go index 390a550..6dddf18 100644 --- a/backend/model/deploy_tool.go +++ b/backend/model/deploy_tool.go @@ -3,8 +3,8 @@ package model import "time" // ============================================ -// DeployTool 工具配置(懒加载/预装/禁用) -// 按 deploy_ 前缀,与 PipelineRepo/Registry 同属发布配置域 +// DeployTool 工具配置(懒加载/预装/禁用)// Logan.Li +// 按 deploy_ 前缀归属发布配置域。// Logan.Li // ============================================ // ToolInstallMethod 安装方式 @@ -395,29 +395,29 @@ echo "[Node.js] Installed: $(/usr/local/bin/node --version) / npm $("$NVM_DIR/ve }, // ──── Git — 版本控制(懒加载, U4 镜像瘦身)// Logan.Li ──── { - Name: "git", - DisplayName: "Git", - Description: "版本控制工具,流水线代码拉取使用(首次使用自动安装)", - Category: "build", - InstallMethod: ToolLazy, - BinaryName: "git", - InstallPath: "/usr/bin/git", - CheckCommand: "git --version 2>/dev/null", + Name: "git", + DisplayName: "Git", + Description: "版本控制工具,流水线代码拉取使用(首次使用自动安装)", + Category: "build", + InstallMethod: ToolLazy, + BinaryName: "git", + InstallPath: "/usr/bin/git", + CheckCommand: "git --version 2>/dev/null", InstallScript: `apk add --no-cache git`, - SortOrder: 6, + SortOrder: 6, }, // ──── Docker CLI — 容器构建(懒加载, U4 镜像瘦身)// Logan.Li ──── { - Name: "docker-cli", - DisplayName: "Docker CLI", - Description: "Docker 命令行工具,流水线镜像构建/推送使用(首次使用自动安装)", - Category: "deploy", - InstallMethod: ToolLazy, - BinaryName: "docker", - InstallPath: "/usr/bin/docker", - CheckCommand: "docker --version 2>/dev/null", - InstallScript: `apk add --no-cache docker-cli`, - SortOrder: 7, + Name: "docker-cli", + DisplayName: "Docker CLI", + Description: "Docker 命令行工具,流水线镜像构建/推送使用(首次使用自动安装)", + Category: "deploy", + InstallMethod: ToolLazy, + BinaryName: "docker", + InstallPath: "/usr/bin/docker", + CheckCommand: "docker --version 2>/dev/null", + InstallScript: `apk add --no-cache docker-cli`, + SortOrder: 7, }, } } diff --git a/backend/model/kubernetes.go b/backend/model/kubernetes.go index 7efbdb2..1a3b6cb 100644 --- a/backend/model/kubernetes.go +++ b/backend/model/kubernetes.go @@ -2,61 +2,60 @@ package model import ( "time" - ) // KubernetesCluster K8s集群模型 type KubernetesCluster struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` - Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` - Version string `gorm:"size:32" json:"version"` - Status string `gorm:"size:32;default:healthy" json:"status"` // healthy, warning, error - ConfigType int `gorm:"default:1" json:"config_type"` // 0=token, 1=kubeconfig - APIServer string `gorm:"size:255" json:"api_server"` - APIEndpoint string `gorm:"size:255" json:"api_endpoint"` - Token string `gorm:"type:text" json:"-"` - KubeConfig string `gorm:"type:text" json:"-"` - NodeCount int `gorm:"default:0" json:"node_count"` - PodCount int `gorm:"default:0" json:"pod_count"` - ServiceCount int `gorm:"default:0" json:"service_count"` - DeploymentCount int `gorm:"default:0" json:"deployment_count"` - IngressCount int `gorm:"default:0" json:"ingress_count"` // 路由/Ingress 数量 // Logan.Li - CPUUsage float64 `json:"cpu_usage"` - MemoryUsage float64 `json:"memory_usage"` - Description string `gorm:"size:500" json:"description"` - Labels string `gorm:"type:text" json:"labels"` // JSON - LastSyncAt *time.Time `json:"last_sync_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Version string `gorm:"size:32" json:"version"` + Status string `gorm:"size:32;default:healthy" json:"status"` // healthy, warning, error + ConfigType int `gorm:"default:1" json:"config_type"` // 0=token, 1=kubeconfig + APIServer string `gorm:"size:255" json:"api_server"` + APIEndpoint string `gorm:"size:255" json:"api_endpoint"` + Token string `gorm:"type:text" json:"-"` + KubeConfig string `gorm:"type:text" json:"-"` + NodeCount int `gorm:"default:0" json:"node_count"` + PodCount int `gorm:"default:0" json:"pod_count"` + ServiceCount int `gorm:"default:0" json:"service_count"` + DeploymentCount int `gorm:"default:0" json:"deployment_count"` + IngressCount int `gorm:"default:0" json:"ingress_count"` // 路由/Ingress 数量 // Logan.Li + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + Description string `gorm:"size:500" json:"description"` + Labels string `gorm:"type:text" json:"labels"` // JSON + LastSyncAt *time.Time `json:"last_sync_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 func (KubernetesCluster) TableName() string { - return "kubernetes_clusters" + return "k8s_clusters" } // KubernetesNode K8s节点模型 type KubernetesNode struct { - ID uint `gorm:"primarykey" json:"id"` - ClusterID uint `gorm:"not null" json:"cluster_id"` - Name string `gorm:"size:128;not null" json:"name"` - IP string `gorm:"size:64" json:"ip"` - Role string `gorm:"size:32" json:"role"` // master, worker - Status string `gorm:"size:32" json:"status"` - OS string `gorm:"size:64" json:"os"` - KubeletVer string `gorm:"size:32" json:"kubelet_version"` - CPU int `json:"cpu"` - Memory int64 `json:"memory"` - Disk int64 `json:"disk"` - CPUUsage float64 `json:"cpu_usage"` - MemoryUsage float64 `json:"memory_usage"` - DiskUsage float64 `json:"disk_usage"` - PodCount int `json:"pod_count"` - MaxPods int `json:"max_pods"` - Labels string `gorm:"type:text" json:"labels"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + ClusterID uint `gorm:"not null" json:"cluster_id"` + Name string `gorm:"size:128;not null" json:"name"` + IP string `gorm:"size:64" json:"ip"` + Role string `gorm:"size:32" json:"role"` // master, worker + Status string `gorm:"size:32" json:"status"` + OS string `gorm:"size:64" json:"os"` + KubeletVer string `gorm:"size:32" json:"kubelet_version"` + CPU int `json:"cpu"` + Memory int64 `json:"memory"` + Disk int64 `json:"disk"` + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + DiskUsage float64 `json:"disk_usage"` + PodCount int `json:"pod_count"` + MaxPods int `json:"max_pods"` + Labels string `gorm:"type:text" json:"labels"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Cluster *KubernetesCluster `json:"cluster,omitempty"` @@ -64,25 +63,25 @@ type KubernetesNode struct { // TableName 表名 func (KubernetesNode) TableName() string { - return "kubernetes_nodes" + return "k8s_nodes" } // KubernetesPod K8s Pod模型 type KubernetesPod struct { - ID uint `gorm:"primarykey" json:"id"` - ClusterID uint `gorm:"not null" json:"cluster_id"` - Namespace string `gorm:"size:64;not null" json:"namespace"` - Name string `gorm:"size:128;not null" json:"name"` - Status string `gorm:"size:32" json:"status"` // Running, Pending, Succeeded, Failed - NodeName string `gorm:"size:128" json:"node_name"` - IP string `gorm:"size:64" json:"ip"` - RestartCount int `json:"restart_count"` - Image string `gorm:"size:255" json:"image"` - CPUUsage float64 `json:"cpu_usage"` - MemoryUsage float64 `json:"memory_usage"` - Labels string `gorm:"type:text" json:"labels"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + ClusterID uint `gorm:"not null" json:"cluster_id"` + Namespace string `gorm:"size:64;not null" json:"namespace"` + Name string `gorm:"size:128;not null" json:"name"` + Status string `gorm:"size:32" json:"status"` // Running, Pending, Succeeded, Failed + NodeName string `gorm:"size:128" json:"node_name"` + IP string `gorm:"size:64" json:"ip"` + RestartCount int `json:"restart_count"` + Image string `gorm:"size:255" json:"image"` + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + Labels string `gorm:"type:text" json:"labels"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Cluster *KubernetesCluster `json:"cluster,omitempty"` @@ -90,23 +89,23 @@ type KubernetesPod struct { // TableName 表名 func (KubernetesPod) TableName() string { - return "kubernetes_pods" + return "k8s_pods" } // KubernetesDeployment K8s Deployment模型 type KubernetesDeployment struct { - ID uint `gorm:"primarykey" json:"id"` - ClusterID uint `gorm:"not null" json:"cluster_id"` - Namespace string `gorm:"size:64;not null" json:"namespace"` - Name string `gorm:"size:128;not null" json:"name"` - Replicas int `json:"replicas"` - AvailableReplicas int `json:"available_replicas"` - ReadyReplicas int `json:"ready_replicas"` - Image string `gorm:"size:255" json:"image"` - Strategy string `gorm:"size:32" json:"strategy"` - Labels string `gorm:"type:text" json:"labels"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + ClusterID uint `gorm:"not null" json:"cluster_id"` + Namespace string `gorm:"size:64;not null" json:"namespace"` + Name string `gorm:"size:128;not null" json:"name"` + Replicas int `json:"replicas"` + AvailableReplicas int `json:"available_replicas"` + ReadyReplicas int `json:"ready_replicas"` + Image string `gorm:"size:255" json:"image"` + Strategy string `gorm:"size:32" json:"strategy"` + Labels string `gorm:"type:text" json:"labels"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Cluster *KubernetesCluster `json:"cluster,omitempty"` @@ -114,23 +113,23 @@ type KubernetesDeployment struct { // TableName 表名 func (KubernetesDeployment) TableName() string { - return "kubernetes_deployments" + return "k8s_deployments" } // KubernetesService K8s Service模型 type KubernetesService struct { - ID uint `gorm:"primarykey" json:"id"` - ClusterID uint `gorm:"not null" json:"cluster_id"` - Namespace string `gorm:"size:64;not null" json:"namespace"` - Name string `gorm:"size:128;not null" json:"name"` - Type string `gorm:"size:32" json:"type"` // ClusterIP, NodePort, LoadBalancer - ClusterIP string `gorm:"size:64" json:"cluster_ip"` - ExternalIP string `gorm:"size:255" json:"external_ip"` - Ports string `gorm:"type:text" json:"ports"` // JSON - Selector string `gorm:"type:text" json:"selector"` - Labels string `gorm:"type:text" json:"labels"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + ClusterID uint `gorm:"not null" json:"cluster_id"` + Namespace string `gorm:"size:64;not null" json:"namespace"` + Name string `gorm:"size:128;not null" json:"name"` + Type string `gorm:"size:32" json:"type"` // ClusterIP, NodePort, LoadBalancer + ClusterIP string `gorm:"size:64" json:"cluster_ip"` + ExternalIP string `gorm:"size:255" json:"external_ip"` + Ports string `gorm:"type:text" json:"ports"` // JSON + Selector string `gorm:"type:text" json:"selector"` + Labels string `gorm:"type:text" json:"labels"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Cluster *KubernetesCluster `json:"cluster,omitempty"` @@ -138,22 +137,22 @@ type KubernetesService struct { // TableName 表名 func (KubernetesService) TableName() string { - return "kubernetes_services" + return "k8s_services" } // KubernetesIngress K8s Ingress模型 type KubernetesIngress struct { - ID uint `gorm:"primarykey" json:"id"` - ClusterID uint `gorm:"not null" json:"cluster_id"` - Namespace string `gorm:"size:64;not null" json:"namespace"` - Name string `gorm:"size:128;not null" json:"name"` - Hosts string `gorm:"type:text" json:"hosts"` // JSON - Backend string `gorm:"size:255" json:"backend"` - TLS bool `json:"tls"` - TLSHosts string `gorm:"type:text" json:"tls_hosts"` - Labels string `gorm:"type:text" json:"labels"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + ClusterID uint `gorm:"not null" json:"cluster_id"` + Namespace string `gorm:"size:64;not null" json:"namespace"` + Name string `gorm:"size:128;not null" json:"name"` + Hosts string `gorm:"type:text" json:"hosts"` // JSON + Backend string `gorm:"size:255" json:"backend"` + TLS bool `json:"tls"` + TLSHosts string `gorm:"type:text" json:"tls_hosts"` + Labels string `gorm:"type:text" json:"labels"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Cluster *KubernetesCluster `json:"cluster,omitempty"` @@ -161,7 +160,7 @@ type KubernetesIngress struct { // TableName 表名 func (KubernetesIngress) TableName() string { - return "kubernetes_ingresses" + return "k8s_ingresses" } // CreateKubernetesClusterRequest 创建K8s集群请求 @@ -169,7 +168,7 @@ type CreateKubernetesClusterRequest struct { Name string `json:"name" binding:"required"` Code string `json:"code" binding:"required"` Version string `json:"version"` - ConfigType int `json:"config_type"` // 0=token, 1=kubeconfig + ConfigType int `json:"config_type"` // 0=token, 1=kubeconfig APIServer string `json:"api_server"` APIEndpoint string `json:"api_endpoint"` Token string `json:"token"` @@ -201,5 +200,5 @@ type TopologyLayout struct { // TableName 指定表名 func (TopologyLayout) TableName() string { - return "kubernetes_topology_layouts" + return "k8s_topology_layouts" } diff --git a/backend/model/llm.go b/backend/model/llm.go index db5e867..fff6c3e 100644 --- a/backend/model/llm.go +++ b/backend/model/llm.go @@ -3,10 +3,10 @@ package model import "time" // LLMConfig 大语言模型配置(单行配置,类似 LDAPConfig) // Logan.Li -// 支持 Anthropic Messages 协议与主流 OpenAI 兼容云服务、API 网关及本地模型。 // Logan.Li +// 支持 OpenAI 兼容接口(OpenAI / DeepSeek / 智谱GLM / 通义千问 / Moonshot / Kimi / 小米MiMo / Anthropic(需代理) / Ollama / 自定义) type LLMConfig struct { ID uint `gorm:"primarykey" json:"id"` - Provider string `gorm:"size:50;not null;default:openai" json:"provider"` // 供应商标识,包含云服务、网关与本地模型 // Logan.Li + Provider string `gorm:"size:50;not null;default:openai" json:"provider"` // deepseek/glm/glm-coding/kimi/moonshot/minimax/minimax-coding/mimo/siliconflow/openai/anthropic/ollama/vllm/custom Model string `gorm:"size:100;not null;default:gpt-4o-mini" json:"model"` // 模型名,如 gpt-4o-mini / deepseek-v4-flash APIKey string `gorm:"size:500" json:"api_key"` // API Key(GET 时会被脱敏) Endpoint string `gorm:"size:500" json:"endpoint"` // 基础地址,如 https://api.openai.com/v1 diff --git a/backend/model/log_datasource.go b/backend/model/log_datasource.go index 496b507..ac65b0d 100644 --- a/backend/model/log_datasource.go +++ b/backend/model/log_datasource.go @@ -78,4 +78,4 @@ type TestLogDSReq struct { Token string `json:"token"` Headers string `json:"headers"` SkipTLSVerify bool `json:"skip_tls_verify"` -} \ No newline at end of file +} diff --git a/backend/model/log_scope_role.go b/backend/model/log_scope_role.go index 567dea8..c6fdc0c 100644 --- a/backend/model/log_scope_role.go +++ b/backend/model/log_scope_role.go @@ -4,33 +4,33 @@ import "time" // LogScopeRole 日志查询范围权限(正则匹配 ES 索引 / Loki label) // Logan.Li type LogScopeRole struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:64;not null" json:"name"` - Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` - Engine string `gorm:"size:32;default:*;comment:引擎 loki/elasticsearch/*" json:"engine"` - MatchField string `gorm:"size:32;not null;comment:匹配维度 index/app/namespace/job" json:"match_field"` - Pattern string `gorm:"size:255;not null;comment:正则表达式" json:"pattern"` - DatasourceID *uint `json:"datasource_id,omitempty"` - Role string `gorm:"size:16;default:viewer;comment:viewer/analyst" json:"role"` - Description string `gorm:"size:255" json:"description"` - Status int `gorm:"default:1;comment:0禁用1启用" json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Engine string `gorm:"size:32;default:*;comment:引擎 loki/elasticsearch/*" json:"engine"` + MatchField string `gorm:"size:32;not null;comment:匹配维度 index/app/namespace/job" json:"match_field"` + Pattern string `gorm:"size:255;not null;comment:正则表达式" json:"pattern"` + DatasourceID *uint `json:"datasource_id,omitempty"` + Role string `gorm:"size:16;default:viewer;comment:viewer/analyst" json:"role"` + Description string `gorm:"size:255" json:"description"` + Status int `gorm:"default:1;comment:0禁用1启用" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -func (LogScopeRole) TableName() string { return "monitor_log_scope_roles" } +func (LogScopeRole) TableName() string { return "iam_log_scope_roles" } // UserLogScopeRole 用户 ↔ 日志范围权限 // Logan.Li type UserLogScopeRole struct { ID uint `gorm:"primarykey" json:"id"` - UserID uint `gorm:"uniqueIndex:idx_user_log_scope;not null" json:"user_id"` - LogScopeRoleID uint `gorm:"uniqueIndex:idx_user_log_scope;not null" json:"log_scope_role_id"` + UserID uint `gorm:"uniqueIndex:idx_iam_user_log_scope_role;not null" json:"user_id"` + LogScopeRoleID uint `gorm:"uniqueIndex:idx_iam_user_log_scope_role;not null" json:"log_scope_role_id"` CreatedAt time.Time `json:"created_at"` - LogScopeRole *LogScopeRole `json:"log_scope_role,omitempty" gorm:"foreignKey:LogScopeRoleID"` + LogScopeRole *LogScopeRole `json:"log_scope_role,omitempty" gorm:"foreignKey:LogScopeRoleID;references:ID"` } -func (UserLogScopeRole) TableName() string { return "monitor_user_log_scope_roles" } +func (UserLogScopeRole) TableName() string { return "iam_user_log_scope_roles" } // CreateLogScopeRoleRequest 创建日志权限策略 // Logan.Li type CreateLogScopeRoleRequest struct { @@ -64,9 +64,9 @@ type AssignLogScopeRoleRequest struct { // PreviewLogScopeRequest 命中预览 // Logan.Li type PreviewLogScopeRequest struct { - Engine string `json:"engine" binding:"required,oneof=loki elasticsearch"` - DatasourceID uint `json:"datasource_id"` - Index string `json:"index"` - Query string `json:"query"` - SampleValue string `json:"sample_value"` -} \ No newline at end of file + Engine string `json:"engine" binding:"required,oneof=loki elasticsearch"` + DatasourceID uint `json:"datasource_id"` + Index string `json:"index"` + Query string `json:"query"` + SampleValue string `json:"sample_value"` +} diff --git a/backend/model/login_log.go b/backend/model/login_log.go index 3e89541..a1aad75 100644 --- a/backend/model/login_log.go +++ b/backend/model/login_log.go @@ -8,19 +8,19 @@ type LoginLog struct { UserID *uint `json:"user_id"` Username string `gorm:"size:64;index" json:"username"` IP string `gorm:"size:64" json:"ip"` - Location string `gorm:"size:128" json:"location"` // IP 归属地 - Browser string `gorm:"size:128" json:"browser"` // 浏览器 - OS string `gorm:"size:128" json:"os"` // 操作系统 + Location string `gorm:"size:128" json:"location"` // IP 归属地 + Browser string `gorm:"size:128" json:"browser"` // 浏览器 + OS string `gorm:"size:128" json:"os"` // 操作系统 LoginType string `gorm:"size:32;default:password" json:"login_type"` // password / ldap UserAgent string `gorm:"size:500" json:"user_agent"` - Status string `gorm:"size:32" json:"status"` // success, failed + Status string `gorm:"size:32" json:"status"` // success, failed FailReason string `gorm:"size:255" json:"fail_reason"` LoginAt time.Time `gorm:"index" json:"login_at"` CreatedAt time.Time `json:"created_at"` } func (LoginLog) TableName() string { - return "sys_login_logs" + return "audit_login_logs" } // LoginLogQuery 登录日志查询 diff --git a/backend/model/metrics_aggregate.go b/backend/model/metrics_aggregate.go index e017968..d92c267 100644 --- a/backend/model/metrics_aggregate.go +++ b/backend/model/metrics_aggregate.go @@ -7,8 +7,8 @@ import "time" type MetricsAggregate struct { ID uint `gorm:"primarykey" json:"id"` Hostname string `gorm:"size:255;index:idx_agg;not null" json:"hostname"` - Metric string `gorm:"size:32;index:idx_agg;not null" json:"metric"` // cpu / mem / load1 / disk / net_rx / net_tx - Interval string `gorm:"size:8;index:idx_agg;not null" json:"interval"` // 1m / 5m / 1h / 6h / 1d + Metric string `gorm:"size:32;index:idx_agg;not null" json:"metric"` // cpu / mem / load1 / disk / net_rx / net_tx + Interval string `gorm:"size:8;index:idx_agg;not null" json:"interval"` // 1m / 5m / 1h / 6h / 1d Avg float64 `gorm:"type:decimal(10,2)" json:"avg"` MaxV float64 `gorm:"column:max_v;type:decimal(10,2)" json:"max"` MinV float64 `gorm:"column:min_v;type:decimal(10,2)" json:"min"` @@ -19,7 +19,7 @@ type MetricsAggregate struct { } func (MetricsAggregate) TableName() string { - return "monitor_metrics_aggregate" + return "monitor_metric_aggregates" } // AggregateReq 聚合触发请求 diff --git a/backend/model/monitor.go b/backend/model/monitor.go index 395257e..69a7b44 100644 --- a/backend/model/monitor.go +++ b/backend/model/monitor.go @@ -42,18 +42,18 @@ type MonitorTarget struct { OS string `gorm:"type:varchar(64);comment:操作系统" json:"os,omitempty"` OSVersion string `gorm:"type:varchar(64);comment:OS版本" json:"os_version,omitempty"` Arch string `gorm:"type:varchar(16);comment:系统架构" json:"arch,omitempty"` - Kernel string `gorm:"type:varchar(64);comment:内核版本" json:"kernel,omitempty"` - HostGroupID uint `gorm:"index;comment:主机组ID" json:"host_group_id,omitempty"` - Tags string `gorm:"type:varchar(512);comment:标签JSON" json:"tags,omitempty"` - CollectMode string `gorm:"type:varchar(16);default:'agent';comment:采集模式:agent/snmp/api" json:"collect_mode"` - SNMPConfig string `gorm:"type:text;comment:SNMP配置JSON" json:"snmp_config,omitempty"` - APIConfig string `gorm:"type:text;comment:API配置JSON" json:"api_config,omitempty"` - Address string `gorm:"type:varchar(255);comment:监控地址(IP或域名)" json:"address,omitempty"` - Protocol string `gorm:"type:varchar(32);comment:协议类型(http/https/tcp/udp/icmp/snmp)" json:"protocol,omitempty"` - Timeout int `gorm:"default:30;comment:超时时间(秒)" json:"timeout,omitempty"` - Config string `gorm:"type:text;comment:通用配置JSON" json:"config,omitempty"` - Interval int `gorm:"default:60;comment:采集间隔(秒)" json:"interval"` - Labels string `gorm:"type:varchar(512);comment:标签JSON" json:"labels,omitempty"` + Kernel string `gorm:"type:varchar(64);comment:内核版本" json:"kernel,omitempty"` + HostGroupID uint `gorm:"index;comment:主机组ID" json:"host_group_id,omitempty"` + Tags string `gorm:"type:varchar(512);comment:标签JSON" json:"tags,omitempty"` + CollectMode string `gorm:"type:varchar(16);default:'agent';comment:采集模式:agent/snmp/api" json:"collect_mode"` + SNMPConfig string `gorm:"type:text;comment:SNMP配置JSON" json:"snmp_config,omitempty"` + APIConfig string `gorm:"type:text;comment:API配置JSON" json:"api_config,omitempty"` + Address string `gorm:"type:varchar(255);comment:监控地址(IP或域名)" json:"address,omitempty"` + Protocol string `gorm:"type:varchar(32);comment:协议类型(http/https/tcp/udp/icmp/snmp)" json:"protocol,omitempty"` + Timeout int `gorm:"default:30;comment:超时时间(秒)" json:"timeout,omitempty"` + Config string `gorm:"type:text;comment:通用配置JSON" json:"config,omitempty"` + Interval int `gorm:"default:60;comment:采集间隔(秒)" json:"interval"` + Labels string `gorm:"type:varchar(512);comment:标签JSON" json:"labels,omitempty"` Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` CreatedAt time.Time `gorm:"not null;index;comment:创建时间" json:"created_at"` UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` @@ -86,36 +86,36 @@ const ( type DataSourceType string const ( - DataSourceAgent DataSourceType = "agent" // Agent采集 - DataSourceSNMP DataSourceType = "snmp" // SNMP采集 - DataSourceAPI DataSourceType = "api" // API采集 + DataSourceAgent DataSourceType = "agent" // Agent采集 + DataSourceSNMP DataSourceType = "snmp" // SNMP采集 + DataSourceAPI DataSourceType = "api" // API采集 DataSourcePrometheus DataSourceType = "prometheus" // Prometheus - DataSourceZabbix DataSourceType = "zabbix" // Zabbix + DataSourceZabbix DataSourceType = "zabbix" // Zabbix ) // MonitorMetric 监控指标定义表 // 定义系统中所有可采集的监控指标元数据 type MonitorMetric struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;uniqueIndex:uk_metric_name;comment:指标名称" json:"name"` - DisplayName string `gorm:"type:varchar(128);not null;comment:显示名称" json:"display_name"` - Description string `gorm:"type:varchar(256);comment:指标描述" json:"description,omitempty"` - MetricType MetricType `gorm:"type:varchar(16);not null;comment:指标类型: gauge/counter/summary" json:"metric_type"` - Unit string `gorm:"type:varchar(32);comment:单位" json:"unit,omitempty"` - DataSource DataSourceType `gorm:"type:varchar(16);not null;comment:数据来源" json:"data_source"` - CollectExpr string `gorm:"type:varchar(256);comment:采集表达式/OID/PromQL" json:"collect_expr,omitempty"` - Interval int `gorm:"default:60;comment:采集间隔(秒)" json:"interval"` - Labels string `gorm:"type:varchar(512);comment:指标标签定义JSON" json:"labels,omitempty"` - ValueType string `gorm:"type:varchar(16);default:'float64';comment:值类型" json:"value_type"` - Category string `gorm:"type:varchar(64);comment:指标分类" json:"category,omitempty"` - IsDefault bool `gorm:"default:true;comment:是否默认启用" json:"is_default"` - IsInternal bool `gorm:"default:false;comment:是否内置指标" json:"is_internal"` - Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` - Icon string `gorm:"type:varchar(64);comment:图标" json:"icon,omitempty"` - SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;uniqueIndex:uk_metric_name;comment:指标名称" json:"name"` + DisplayName string `gorm:"type:varchar(128);not null;comment:显示名称" json:"display_name"` + Description string `gorm:"type:varchar(256);comment:指标描述" json:"description,omitempty"` + MetricType MetricType `gorm:"type:varchar(16);not null;comment:指标类型: gauge/counter/summary" json:"metric_type"` + Unit string `gorm:"type:varchar(32);comment:单位" json:"unit,omitempty"` + DataSource DataSourceType `gorm:"type:varchar(16);not null;comment:数据来源" json:"data_source"` + CollectExpr string `gorm:"type:varchar(256);comment:采集表达式/OID/PromQL" json:"collect_expr,omitempty"` + Interval int `gorm:"default:60;comment:采集间隔(秒)" json:"interval"` + Labels string `gorm:"type:varchar(512);comment:指标标签定义JSON" json:"labels,omitempty"` + ValueType string `gorm:"type:varchar(16);default:'float64';comment:值类型" json:"value_type"` + Category string `gorm:"type:varchar(64);comment:指标分类" json:"category,omitempty"` + IsDefault bool `gorm:"default:true;comment:是否默认启用" json:"is_default"` + IsInternal bool `gorm:"default:false;comment:是否内置指标" json:"is_internal"` + Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` + Icon string `gorm:"type:varchar(64);comment:图标" json:"icon,omitempty"` + SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` } // TableName 指定表名 @@ -147,7 +147,7 @@ type MetricValue struct { // TableName 指定表名 func (MetricValue) TableName() string { - return "metric_values" + return "monitor_metric_values" } // MetricHistory 指标历史数据(简化版,供监控查询使用) @@ -213,29 +213,29 @@ func (MetricTag) TableName() string { type DashboardType string const ( - DashboardTypeSystem DashboardType = "system" // 系统内置 - DashboardTypeCustom DashboardType = "custom" // 自定义 - DashboardTypeShared DashboardType = "shared" // 共享 + DashboardTypeSystem DashboardType = "system" // 系统内置 + DashboardTypeCustom DashboardType = "custom" // 自定义 + DashboardTypeShared DashboardType = "shared" // 共享 ) // MonitorDashboard 监控大屏配置表 // 定义监控大屏的元数据配置 type MonitorDashboard struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:大屏名称" json:"name"` - Type DashboardType `gorm:"type:varchar(16);default:'custom';comment:大屏类型" json:"type"` - Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` - Layout string `gorm:"type:text;comment:布局配置JSON" json:"layout,omitempty"` - Theme string `gorm:"type:varchar(32);default:'dark';comment:主题: dark/light" json:"theme"` - RefreshRate int `gorm:"default:30;comment:自动刷新间隔(秒)" json:"refresh_rate"` - IsDefault bool `gorm:"default:false;comment:是否默认大屏" json:"is_default"` - IsPublic bool `gorm:"default:false;comment:是否公开" json:"is_public"` - CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` - Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` - SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:大屏名称" json:"name"` + Type DashboardType `gorm:"type:varchar(16);default:'custom';comment:大屏类型" json:"type"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + Layout string `gorm:"type:text;comment:布局配置JSON" json:"layout,omitempty"` + Theme string `gorm:"type:varchar(32);default:'dark';comment:主题: dark/light" json:"theme"` + RefreshRate int `gorm:"default:30;comment:自动刷新间隔(秒)" json:"refresh_rate"` + IsDefault bool `gorm:"default:false;comment:是否默认大屏" json:"is_default"` + IsPublic bool `gorm:"default:false;comment:是否公开" json:"is_public"` + CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` + Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` + SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` // 关联关系 Panels []DashboardPanel `gorm:"foreignKey:DashboardID" json:"panels,omitempty"` @@ -254,16 +254,16 @@ func (MonitorDashboard) TableName() string { type PanelType string const ( - PanelTypeChart PanelType = "chart" // 图表 - PanelTypeGauge PanelType = "gauge" // 仪表盘 - PanelTypeCounter PanelType = "counter" // 计数器 - PanelTypeTable PanelType = "table" // 表格 - PanelTypeStatus PanelType = "status" // 状态 - PanelTypeTopology PanelType = "topology" // 拓扑 - PanelTypeLog PanelType = "log" // 日志 - PanelTypeAlertList PanelType = "alert_list" // 告警列表 - PanelTypeHeatmap PanelType = "heatmap" // 热力图 - PanelTypeText PanelType = "text" // 文本 + PanelTypeChart PanelType = "chart" // 图表 + PanelTypeGauge PanelType = "gauge" // 仪表盘 + PanelTypeCounter PanelType = "counter" // 计数器 + PanelTypeTable PanelType = "table" // 表格 + PanelTypeStatus PanelType = "status" // 状态 + PanelTypeTopology PanelType = "topology" // 拓扑 + PanelTypeLog PanelType = "log" // 日志 + PanelTypeAlertList PanelType = "alert_list" // 告警列表 + PanelTypeHeatmap PanelType = "heatmap" // 热力图 + PanelTypeText PanelType = "text" // 文本 ) // ChartType 图表类型 @@ -298,13 +298,13 @@ type DashboardPanel struct { QueryExpr string `gorm:"type:text;comment:查询表达式" json:"query_expr,omitempty"` QueryConfig string `gorm:"type:text;comment:查询配置JSON" json:"query_config,omitempty"` // 展示配置 - TimeRange string `gorm:"type:varchar(16);default:'1h';comment:时间范围" json:"time_range"` - RefreshRate int `gorm:"default:0;comment:刷新间隔(秒),0表示继承大屏" json:"refresh_rate"` - Thresholds string `gorm:"type:text;comment:阈值配置JSON" json:"thresholds,omitempty"` - Colors string `gorm:"type:text;comment:颜色配置JSON" json:"colors,omitempty"` - Legend string `gorm:"type:varchar(64);comment:图例配置" json:"legend,omitempty"` - Unit string `gorm:"type:varchar(32);comment:显示单位" json:"unit,omitempty"` - Decimals int `gorm:"default:2;comment:小数位数" json:"decimals"` + TimeRange string `gorm:"type:varchar(16);default:'1h';comment:时间范围" json:"time_range"` + RefreshRate int `gorm:"default:0;comment:刷新间隔(秒),0表示继承大屏" json:"refresh_rate"` + Thresholds string `gorm:"type:text;comment:阈值配置JSON" json:"thresholds,omitempty"` + Colors string `gorm:"type:text;comment:颜色配置JSON" json:"colors,omitempty"` + Legend string `gorm:"type:varchar(64);comment:图例配置" json:"legend,omitempty"` + Unit string `gorm:"type:varchar(32);comment:显示单位" json:"unit,omitempty"` + Decimals int `gorm:"default:2;comment:小数位数" json:"decimals"` // 状态 Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"` @@ -314,18 +314,18 @@ type DashboardPanel struct { // TableName 指定表名 func (DashboardPanel) TableName() string { - return "dashboard_panels" + return "monitor_dashboard_panels" } // PanelQueryConfig 面板查询配置 type PanelQueryConfig struct { - Metrics []string `json:"metrics"` // 指标名称列表 - Targets []uint `json:"targets"` // 目标ID列表 - Instances []string `json:"instances"` // 实例列表 - Aggregations []string `json:"aggregations"` // 聚合函数 - GroupBy []string `json:"group_by"` // 分组标签 - Filters map[string]string `json:"filters"` // 过滤条件 - Step int `json:"step"` // 步长 + Metrics []string `json:"metrics"` // 指标名称列表 + Targets []uint `json:"targets"` // 目标ID列表 + Instances []string `json:"instances"` // 实例列表 + Aggregations []string `json:"aggregations"` // 聚合函数 + GroupBy []string `json:"group_by"` // 分组标签 + Filters map[string]string `json:"filters"` // 过滤条件 + Step int `json:"step"` // 步长 LegendFormat string `json:"legend_format"` // 图例格式 } @@ -362,9 +362,9 @@ func (HostGroup) TableName() string { type ProbeType string const ( - ProbeTypeHTTP ProbeType = "http_get" // HTTP GET 探测 - ProbeTypeTCP ProbeType = "tcp_socket" // TCP 端口连接探测 - ProbeTypeExec ProbeType = "exec_command" // 命令执行探测 + ProbeTypeHTTP ProbeType = "http_get" // HTTP GET 探测 + ProbeTypeTCP ProbeType = "tcp_socket" // TCP 端口连接探测 + ProbeTypeExec ProbeType = "exec_command" // 命令执行探测 ) // ProbeStatus 探针结果状态 @@ -378,33 +378,33 @@ const ( // HealthProbe 健康探针配置表 // 存储在平台 DB,Agent 拉取配置后在目标主机本地执行,结果随心跳上报 type HealthProbe struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - Name string `gorm:"type:varchar(128);not null;index" json:"name"` - Hostname string `gorm:"type:varchar(255);index;not null" json:"hostname"` - ProbeType ProbeType `gorm:"type:varchar(32);not null" json:"probe_type"` + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"type:varchar(128);not null;index" json:"name"` + Hostname string `gorm:"type:varchar(255);index;not null" json:"hostname"` + ProbeType ProbeType `gorm:"type:varchar(32);not null" json:"probe_type"` // HTTP 探针配置 - HTTPURL string `gorm:"type:varchar(512)" json:"http_url,omitempty"` - HTTPMethod string `gorm:"type:varchar(10);default:GET" json:"http_method,omitempty"` - HTTPHeaders string `gorm:"type:text" json:"http_headers,omitempty"` - HTTPExpectCode int `gorm:"default:200" json:"http_expect_code,omitempty"` - HTTPExpectBody string `gorm:"type:varchar(256)" json:"http_expect_body,omitempty"` + HTTPURL string `gorm:"type:varchar(512)" json:"http_url,omitempty"` + HTTPMethod string `gorm:"type:varchar(10);default:GET" json:"http_method,omitempty"` + HTTPHeaders string `gorm:"type:text" json:"http_headers,omitempty"` + HTTPExpectCode int `gorm:"default:200" json:"http_expect_code,omitempty"` + HTTPExpectBody string `gorm:"type:varchar(256)" json:"http_expect_body,omitempty"` // TCP 探针配置 - TCPHost string `gorm:"type:varchar(255)" json:"tcp_host,omitempty"` - TCPPort int `json:"tcp_port,omitempty"` + TCPHost string `gorm:"type:varchar(255)" json:"tcp_host,omitempty"` + TCPPort int `json:"tcp_port,omitempty"` // Exec 探针配置 - ExecCommand string `gorm:"type:text" json:"exec_command,omitempty"` + ExecCommand string `gorm:"type:text" json:"exec_command,omitempty"` // 通用配置 - Timeout int `gorm:"default:10" json:"timeout"` - Interval int `gorm:"default:60" json:"interval"` - Enabled bool `gorm:"default:true;index" json:"enabled"` - LastStatus ProbeStatus `gorm:"type:varchar(8);default:''" json:"last_status,omitempty"` - LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` - LastLatencyMs int `json:"last_latency_ms"` - FailCount int `json:"fail_count"` - Description string `gorm:"type:varchar(256)" json:"description,omitempty"` - CreatedAt time.Time `gorm:"not null" json:"created_at"` - UpdatedAt time.Time `gorm:"not null" json:"updated_at"` - DeletedAt *time.Time `gorm:"index" json:"deleted_at,omitempty"` + Timeout int `gorm:"default:10" json:"timeout"` + Interval int `gorm:"default:60" json:"interval"` + Enabled bool `gorm:"default:true;index" json:"enabled"` + LastStatus ProbeStatus `gorm:"type:varchar(8);default:''" json:"last_status,omitempty"` + LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` + LastLatencyMs int `json:"last_latency_ms"` + FailCount int `json:"fail_count"` + Description string `gorm:"type:varchar(256)" json:"description,omitempty"` + CreatedAt time.Time `gorm:"not null" json:"created_at"` + UpdatedAt time.Time `gorm:"not null" json:"updated_at"` + DeletedAt *time.Time `gorm:"index" json:"deleted_at,omitempty"` } // TableName 指定表名 @@ -417,9 +417,8 @@ type HealthProbeResult struct { ProbeID uint `json:"probe_id"` Name string `json:"name"` ProbeType string `json:"probe_type"` - Status ProbeStatus `json:"status"` // up / down - LatencyMs int `json:"latency_ms"` // 响应延迟(ms) + Status ProbeStatus `json:"status"` // up / down + LatencyMs int `json:"latency_ms"` // 响应延迟(ms) StatusCode int `json:"status_code,omitempty"` // HTTP 状态码 Message string `json:"message,omitempty"` // 错误/附加信息 } - diff --git a/backend/model/monitor_alert_rule.go b/backend/model/monitor_alert_rule.go index 2ba039d..8f72bad 100644 --- a/backend/model/monitor_alert_rule.go +++ b/backend/model/monitor_alert_rule.go @@ -14,21 +14,21 @@ const ( // MonitorAlertRule 中间件/站点拨测告警规则(主机规则仍用 AgentRule)// Logan.Li type MonitorAlertRule struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - Category MonitorAlertCategory `gorm:"type:varchar(16);not null;index" json:"category"` - Name string `gorm:"type:varchar(128);not null" json:"name"` - TargetID uint `gorm:"default:0;index;comment:目标ID,0=全部" json:"target_id"` - Metric string `gorm:"type:varchar(64);not null" json:"metric"` - Operator string `gorm:"type:varchar(8);default:>" json:"operator"` - Threshold float64 `gorm:"type:decimal(12,4)" json:"threshold"` - Severity string `gorm:"type:varchar(16);default:warning" json:"severity"` - Enabled bool `gorm:"default:true" json:"enabled"` - DurationSec int `gorm:"default:0;comment:持续秒数(预留)" json:"duration_sec"` - Port int `gorm:"default:0" json:"port,omitempty"` - Description string `gorm:"type:varchar(256)" json:"description,omitempty"` - LastTriggered *time.Time `json:"last_triggered,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Category MonitorAlertCategory `gorm:"type:varchar(16);not null;index" json:"category"` + Name string `gorm:"type:varchar(128);not null" json:"name"` + TargetID uint `gorm:"default:0;index;comment:目标ID,0=全部" json:"target_id"` + Metric string `gorm:"type:varchar(64);not null" json:"metric"` + Operator string `gorm:"type:varchar(8);default:>" json:"operator"` + Threshold float64 `gorm:"type:decimal(12,4)" json:"threshold"` + Severity string `gorm:"type:varchar(16);default:warning" json:"severity"` + Enabled bool `gorm:"default:true" json:"enabled"` + DurationSec int `gorm:"default:0;comment:持续秒数(预留)" json:"duration_sec"` + Port int `gorm:"default:0" json:"port,omitempty"` + Description string `gorm:"type:varchar(256)" json:"description,omitempty"` + LastTriggered *time.Time `json:"last_triggered,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -func (MonitorAlertRule) TableName() string { return "monitor_alert_rules" } \ No newline at end of file +func (MonitorAlertRule) TableName() string { return "monitor_probe_alert_rules" } diff --git a/backend/model/navigation.go b/backend/model/navigation.go index 5f95399..51577da 100644 --- a/backend/model/navigation.go +++ b/backend/model/navigation.go @@ -2,21 +2,20 @@ package model import ( "time" - ) // NavigationGroup 导航分组模型 type NavigationGroup struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:64;not null" json:"name"` - Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` - Description string `gorm:"size:255" json:"description"` - Icon string `gorm:"size:64" json:"icon"` - Sort int `gorm:"default:0" json:"sort"` - Enabled bool `gorm:"default:true" json:"enabled"` - LinkCount int `gorm:"default:0" json:"link_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Description string `gorm:"size:255" json:"description"` + Icon string `gorm:"size:64" json:"icon"` + Sort int `gorm:"default:0" json:"sort"` + Enabled bool `gorm:"default:true" json:"enabled"` + LinkCount int `gorm:"default:0" json:"link_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 @@ -26,18 +25,18 @@ func (NavigationGroup) TableName() string { // NavigationLink 导航链接模型 type NavigationLink struct { - ID uint `gorm:"primarykey" json:"id"` - GroupID uint `json:"group_id"` - Name string `gorm:"size:128;not null" json:"name"` - URL string `gorm:"size:500;not null" json:"url"` - Description string `gorm:"size:255" json:"description"` - Icon string `gorm:"size:64" json:"icon"` - Tags string `gorm:"type:text" json:"tags"` // JSON - IsFavorite bool `gorm:"default:false" json:"is_favorite"` - Sort int `gorm:"default:0" json:"sort"` - Enabled bool `gorm:"default:true" json:"enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + GroupID uint `json:"group_id"` + Name string `gorm:"size:128;not null" json:"name"` + URL string `gorm:"size:500;not null" json:"url"` + Description string `gorm:"size:255" json:"description"` + Icon string `gorm:"size:64" json:"icon"` + Tags string `gorm:"type:text" json:"tags"` // JSON + IsFavorite bool `gorm:"default:false" json:"is_favorite"` + Sort int `gorm:"default:0" json:"sort"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Group *NavigationGroup `json:"group,omitempty"` @@ -45,7 +44,7 @@ type NavigationLink struct { // TableName 表名 func (NavigationLink) TableName() string { - return "navigation_links" + return "sys_navigation_links" } // CreateNavigationGroupRequest 创建导航分组请求 diff --git a/backend/model/ops_modules.go b/backend/model/ops_modules.go index 421c1b5..6ba2390 100644 --- a/backend/model/ops_modules.go +++ b/backend/model/ops_modules.go @@ -2,83 +2,82 @@ package model import ( "time" - ) // AnsiblePlaybook Playbook 模型 // Logan.Li type AnsiblePlaybook struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:255;not null" json:"name"` - Description string `gorm:"type:text" json:"description"` - Content string `gorm:"type:text;not null" json:"content"` // YAML 内容 - Tags string `gorm:"size:500" json:"tags"` // 逗号分隔 - Category string `gorm:"size:100" json:"category"` // deployment/maintenance/etc - CreatedBy string `gorm:"size:255" json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:255;not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + Content string `gorm:"type:text;not null" json:"content"` // YAML 内容 + Tags string `gorm:"size:500" json:"tags"` // 逗号分隔 + Category string `gorm:"size:100" json:"category"` // deployment/maintenance/etc + CreatedBy string `gorm:"size:255" json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (AnsiblePlaybook) TableName() string { return "ops_ansible_playbooks" } // AnsibleJob 执行任务记录 // Logan.Li type AnsibleJob struct { - ID uint `gorm:"primarykey" json:"id"` - PlaybookID uint `gorm:"not null" json:"playbook_id"` - PlaybookName string `gorm:"size:255" json:"playbook_name"` - Status string `gorm:"size:20;default:pending" json:"status"` // pending/running/success/failed - TargetHosts string `gorm:"type:text" json:"target_hosts"` // JSON 数组 - Variables string `gorm:"type:text" json:"variables"` // JSON - Output string `gorm:"type:text" json:"output"` - Duration int64 `gorm:"default:0" json:"duration"` // 毫秒 - TriggeredBy string `gorm:"size:255" json:"triggered_by"` - StartedAt *time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + PlaybookID uint `gorm:"not null" json:"playbook_id"` + PlaybookName string `gorm:"size:255" json:"playbook_name"` + Status string `gorm:"size:20;default:pending" json:"status"` // pending/running/success/failed + TargetHosts string `gorm:"type:text" json:"target_hosts"` // JSON 数组 + Variables string `gorm:"type:text" json:"variables"` // JSON + Output string `gorm:"type:text" json:"output"` + Duration int64 `gorm:"default:0" json:"duration"` // 毫秒 + TriggeredBy string `gorm:"size:255" json:"triggered_by"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (AnsibleJob) TableName() string { return "ops_ansible_jobs" } // AnsibleInventory 主机清单 // Logan.Li type AnsibleInventory struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:255;not null" json:"name"` - Description string `gorm:"type:text" json:"description"` - Hosts string `gorm:"type:text" json:"hosts"` // JSON 数组 [{name,ip,port,user,os}] - CreatedBy string `gorm:"size:255" json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:255;not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + Hosts string `gorm:"type:text" json:"hosts"` // JSON 数组 [{name,ip,port,user,os}] + CreatedBy string `gorm:"size:255" json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (AnsibleInventory) TableName() string { return "ops_ansible_inventories" } // LogSource 日志源 // Logan.Li type LogSource struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:255;not null" json:"name"` - Type string `gorm:"size:50;not null" json:"type"` // application/syslog/docker/kubernetes - Status string `gorm:"size:20;default:active" json:"status"` - Config string `gorm:"type:text" json:"config"` // JSON {path/pattern} - LogCount int64 `gorm:"default:0" json:"log_count"` - LastSeen *time.Time `json:"last_seen"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:255;not null" json:"name"` + Type string `gorm:"size:50;not null" json:"type"` // application/syslog/docker/kubernetes + Status string `gorm:"size:20;default:active" json:"status"` + Config string `gorm:"type:text" json:"config"` // JSON {path/pattern} + LogCount int64 `gorm:"default:0" json:"log_count"` + LastSeen *time.Time `json:"last_seen"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (LogSource) TableName() string { return "sys_log_sources" } // LogEntry 日志条目 // Logan.Li type LogEntry struct { - ID uint `gorm:"primarykey" json:"id"` - SourceID uint `gorm:"index" json:"source_id"` - Timestamp time.Time `gorm:"index" json:"timestamp"` - Level string `gorm:"size:20;index" json:"level"` // error/warn/info/debug - Service string `gorm:"size:255;index" json:"service"` - Host string `gorm:"size:255;index" json:"host"` - Message string `gorm:"type:text" json:"message"` - TraceID string `gorm:"size:100;index" json:"trace_id"` - Raw string `gorm:"type:text" json:"raw"` - CreatedAt time.Time `json:"created_at"` + ID uint `gorm:"primarykey" json:"id"` + SourceID uint `gorm:"index" json:"source_id"` + Timestamp time.Time `gorm:"index" json:"timestamp"` + Level string `gorm:"size:20;index" json:"level"` // error/warn/info/debug + Service string `gorm:"size:255;index" json:"service"` + Host string `gorm:"size:255;index" json:"host"` + Message string `gorm:"type:text" json:"message"` + TraceID string `gorm:"size:100;index" json:"trace_id"` + Raw string `gorm:"type:text" json:"raw"` + CreatedAt time.Time `json:"created_at"` } func (LogEntry) TableName() string { return "sys_log_entries" } diff --git a/backend/model/prometheus_datasource.go b/backend/model/prometheus_datasource.go index 5369d71..15d0bb8 100644 --- a/backend/model/prometheus_datasource.go +++ b/backend/model/prometheus_datasource.go @@ -12,10 +12,10 @@ type PrometheusDataSource struct { Access string `gorm:"type:varchar(16);default:'server';comment:访问模式: server/proxy" json:"access"` // Basic Auth - BasicAuth bool `gorm:"default:false;comment:启用基础认证" json:"basic_auth"` - BasicAuthUser string `gorm:"type:varchar(128);comment:认证用户名" json:"basic_auth_user,omitempty"` - BasicAuthPass string `gorm:"type:varchar(256);comment:认证密码" json:"basic_auth_pass,omitempty"` - WithCredentials bool `gorm:"default:false;comment:携带凭证" json:"with_credentials"` + BasicAuth bool `gorm:"default:false;comment:启用基础认证" json:"basic_auth"` + BasicAuthUser string `gorm:"type:varchar(128);comment:认证用户名" json:"basic_auth_user,omitempty"` + BasicAuthPass string `gorm:"type:varchar(256);comment:认证密码" json:"basic_auth_pass,omitempty"` + WithCredentials bool `gorm:"default:false;comment:携带凭证" json:"with_credentials"` // TLS Client Auth TLSClientAuth bool `gorm:"default:false;comment:启用TLS客户端认证" json:"tls_client_auth"` @@ -31,8 +31,8 @@ type PrometheusDataSource struct { AlertmanagerDS string `gorm:"type:varchar(128);comment:Alertmanager数据源" json:"alertmanager_ds,omitempty"` // 采集与查询 - ScrapeInterval int `gorm:"default:15;comment:采集间隔(秒)" json:"scrape_interval"` - QueryTimeout int `gorm:"default:60;comment:查询超时(秒)" json:"query_timeout"` + ScrapeInterval int `gorm:"default:15;comment:采集间隔(秒)" json:"scrape_interval"` + QueryTimeout int `gorm:"default:60;comment:查询超时(秒)" json:"query_timeout"` HTTPMethod string `gorm:"type:varchar(8);default:'POST';comment:HTTP方法" json:"http_method"` // 杂项 @@ -109,9 +109,9 @@ type UpdatePromDSReq struct { // TestPromDSReq 测试数据源连接请求 type TestPromDSReq struct { - URL string `json:"url" binding:"required"` - BasicAuth bool `json:"basic_auth"` - BasicAuthUser string `json:"basic_auth_user"` - BasicAuthPass string `json:"basic_auth_pass"` - SkipTLSVerify bool `json:"skip_tls_verify"` + URL string `json:"url" binding:"required"` + BasicAuth bool `json:"basic_auth"` + BasicAuthUser string `json:"basic_auth_user"` + BasicAuthPass string `json:"basic_auth_pass"` + SkipTLSVerify bool `json:"skip_tls_verify"` } diff --git a/backend/model/rdp_session.go b/backend/model/rdp_session.go index 79c931f..e38206f 100644 --- a/backend/model/rdp_session.go +++ b/backend/model/rdp_session.go @@ -25,7 +25,7 @@ type RDPSession struct { } func (RDPSession) TableName() string { - return "sys_rdp_sessions" + return "audit_rdp_sessions" } // RDPSessionQuery Windows RDP 图形会话查询 // Logan.Li diff --git a/backend/model/resource_model.go b/backend/model/resource_model.go index 10c6535..a9b45b6 100644 --- a/backend/model/resource_model.go +++ b/backend/model/resource_model.go @@ -2,20 +2,19 @@ package model import ( "time" - ) // ResourceModel 资源模型 (CMDB 模型定义) type ResourceModel struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:64;not null" json:"name"` - Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` - Description string `gorm:"size:500" json:"description"` - Type string `gorm:"size:32;default:normal" json:"type"` // normal, parent - Attributes string `gorm:"type:text" json:"attributes"` // JSON 数组 - Icon string `gorm:"size:64" json:"icon"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Description string `gorm:"size:500" json:"description"` + Type string `gorm:"size:32;default:normal" json:"type"` // normal, parent + Attributes string `gorm:"type:text" json:"attributes"` // JSON 数组 + Icon string `gorm:"size:64" json:"icon"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (ResourceModel) TableName() string { diff --git a/backend/model/server.go b/backend/model/server.go index 2283260..04a8daf 100644 --- a/backend/model/server.go +++ b/backend/model/server.go @@ -6,41 +6,43 @@ import ( // Server 服务器模型 type Server struct { - ID uint `gorm:"primarykey" json:"id"` - Hostname string `gorm:"size:128;not null" json:"hostname"` - IP string `gorm:"size:64;not null" json:"ip"` - PublicIP string `gorm:"size:64" json:"public_ip"` - Port int `gorm:"default:22" json:"port"` - Username string `gorm:"size:64;default:root" json:"username"` - Password string `gorm:"size:255" json:"-"` - SSHKey string `gorm:"type:text" json:"-"` - AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key - OSType string `gorm:"size:32;default:linux" json:"os_type"` // 操作系统类型:linux/windows // Logan.Li - Protocol string `gorm:"size:32;default:ssh" json:"protocol"` // 远程协议:ssh/rdp // Logan.Li - RDPPort int `gorm:"default:3389" json:"rdp_port"` // Windows RDP 端口 // Logan.Li - OS string `gorm:"size:64" json:"os"` - Kernel string `gorm:"size:64" json:"kernel"` - OSVersion string `gorm:"size:64" json:"os_version"` - Arch string `gorm:"size:32" json:"arch"` - CPU int `json:"cpu"` - Memory int64 `json:"memory"` // MB - Disk int64 `json:"disk"` // GB - Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error - CPUUsage float64 `json:"cpu_usage"` - MemoryUsage float64 `json:"memory_usage"` - DiskUsage float64 `json:"disk_usage"` - Uptime int64 `json:"uptime"` // seconds - AgentVersion string `gorm:"size:32" json:"agent_version"` - AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline - HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li - RegionID *uint `json:"region_id"` - CloudID *uint `json:"cloud_id"` - CloudAccountID *uint `gorm:"index" json:"cloud_account_id"` // 关联导入来源云账号,用于准确统计。 // Logan.Li - Tags string `gorm:"type:text" json:"tags"` // JSON - Description string `gorm:"size:500" json:"description"` - LastCheckAt *time.Time `json:"last_check_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Hostname string `gorm:"size:128;not null" json:"hostname"` + Alias string `gorm:"size:128" json:"alias"` // 人工可读别名,展示优先级高于 hostname // Logan.Li + CloudInstanceName string `gorm:"size:128" json:"cloud_instance_name"` // 云平台实例名称,低于人工别名高于 hostname 展示 // Logan.Li + CloudInstanceID string `gorm:"size:128;index" json:"cloud_instance_id"` // 云平台实例 ID,仅做关联/去重,不作为首选展示名 // Logan.Li + IP string `gorm:"size:64;not null" json:"ip"` + PublicIP string `gorm:"size:64" json:"public_ip"` + Port int `gorm:"default:22" json:"port"` + Username string `gorm:"size:64;default:root" json:"username"` + Password string `gorm:"size:255" json:"-"` + SSHKey string `gorm:"type:text" json:"-"` + AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key + OSType string `gorm:"size:32;default:linux" json:"os_type"` // 操作系统类型:linux/windows // Logan.Li + Protocol string `gorm:"size:32;default:ssh" json:"protocol"` // 远程协议:ssh/rdp // Logan.Li + RDPPort int `gorm:"default:3389" json:"rdp_port"` // Windows RDP 端口 // Logan.Li + OS string `gorm:"size:64" json:"os"` + Kernel string `gorm:"size:64" json:"kernel"` + OSVersion string `gorm:"size:64" json:"os_version"` + Arch string `gorm:"size:32" json:"arch"` + CPU int `json:"cpu"` + Memory int64 `json:"memory"` // MB + Disk int64 `json:"disk"` // GB + Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + DiskUsage float64 `json:"disk_usage"` + Uptime int64 `json:"uptime"` // seconds + AgentVersion string `gorm:"size:32" json:"agent_version"` + AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline + HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li + RegionID *uint `json:"region_id"` + CloudID *uint `json:"cloud_id"` + Tags string `gorm:"type:text" json:"tags"` // JSON + Description string `gorm:"size:500" json:"description"` + LastCheckAt *time.Time `json:"last_check_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Region *Region `json:"region,omitempty"` @@ -67,24 +69,23 @@ type Cloud struct { // TableName 表名 func (Cloud) TableName() string { - return "asset_clouds" + return "asset_cloud_platforms" } // CloudAccount 云账号模型 type CloudAccount struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` - Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure - AccessKey string `gorm:"size:255;not null" json:"-"` - SecretKey string `gorm:"size:255;not null" json:"-"` - Status string `gorm:"size:32;default:active" json:"status"` // active, inactive, error - RegionCount int `gorm:"default:0" json:"region_count"` - ServerCount int `gorm:"default:0" json:"server_count"` - MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计 - BillingCurrency string `gorm:"size:8;default:CNY" json:"billing_currency"` // 账单原始币种。 // Logan.Li - LastSyncAt *time.Time `json:"last_sync_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure + AccessKey string `gorm:"size:255;not null" json:"-"` + SecretKey string `gorm:"size:255;not null" json:"-"` + Status string `gorm:"size:32;default:active" json:"status"` // active, inactive, error + RegionCount int `gorm:"default:0" json:"region_count"` + ServerCount int `gorm:"default:0" json:"server_count"` + MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计 + LastSyncAt *time.Time `json:"last_sync_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 @@ -108,7 +109,7 @@ type Region struct { // TableName 表名 func (Region) TableName() string { - return "asset_regions" + return "asset_cloud_regions" } // Domain 域名模型 @@ -133,52 +134,58 @@ func (Domain) TableName() string { // CreateServerRequest 创建服务器请求 type CreateServerRequest struct { - Hostname string `json:"hostname" binding:"required"` - IP string `json:"ip" binding:"required,ip"` - PublicIP string `json:"public_ip" binding:"omitempty,ip"` - Port int `json:"port" binding:"omitempty,min=1,max=65535"` - Username string `json:"username"` - Password string `json:"password"` - SSHKey string `json:"ssh_key"` - AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"` - CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段 - OSType string `json:"os_type" binding:"omitempty,oneof=linux windows"` // 操作系统类型 // Logan.Li - Protocol string `json:"protocol" binding:"omitempty,oneof=ssh rdp"` // 远程协议 // Logan.Li - RDPPort int `json:"rdp_port" binding:"omitempty,min=1,max=65535"` // RDP 端口 // Logan.Li - OS string `json:"os"` - CPU int `json:"cpu"` - Memory int64 `json:"memory"` - Disk int64 `json:"disk"` - Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"` - RegionID *uint `json:"region_id"` - CloudID *uint `json:"cloud_id"` - Tags string `json:"tags"` - Description string `json:"description"` + Hostname string `json:"hostname" binding:"required"` + Alias string `json:"alias"` + CloudInstanceName string `json:"cloud_instance_name"` + CloudInstanceID string `json:"cloud_instance_id"` + IP string `json:"ip" binding:"required,ip"` + PublicIP string `json:"public_ip" binding:"omitempty,ip"` + Port int `json:"port" binding:"omitempty,min=1,max=65535"` + Username string `json:"username"` + Password string `json:"password"` + SSHKey string `json:"ssh_key"` + AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"` + CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段 + OSType string `json:"os_type" binding:"omitempty,oneof=linux windows"` // 操作系统类型 // Logan.Li + Protocol string `json:"protocol" binding:"omitempty,oneof=ssh rdp"` // 远程协议 // Logan.Li + RDPPort int `json:"rdp_port" binding:"omitempty,min=1,max=65535"` // RDP 端口 // Logan.Li + OS string `json:"os"` + CPU int `json:"cpu"` + Memory int64 `json:"memory"` + Disk int64 `json:"disk"` + Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"` + RegionID *uint `json:"region_id"` + CloudID *uint `json:"cloud_id"` + Tags string `json:"tags"` + Description string `json:"description"` } // UpdateServerRequest 更新服务器请求 type UpdateServerRequest struct { - Hostname string `json:"hostname"` - IP string `json:"ip" binding:"omitempty,ip"` - PublicIP string `json:"public_ip" binding:"omitempty,ip"` - Port int `json:"port" binding:"omitempty,min=1,max=65535"` - Username string `json:"username"` - Password string `json:"password"` - SSHKey string `json:"ssh_key"` - AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"` - CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段 - OSType string `json:"os_type" binding:"omitempty,oneof=linux windows"` // 操作系统类型 // Logan.Li - Protocol string `json:"protocol" binding:"omitempty,oneof=ssh rdp"` // 远程协议 // Logan.Li - RDPPort int `json:"rdp_port" binding:"omitempty,min=1,max=65535"` // RDP 端口 // Logan.Li - OS string `json:"os"` - CPU int `json:"cpu"` - Memory int64 `json:"memory"` - Disk int64 `json:"disk"` - Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"` - RegionID *uint `json:"region_id"` - CloudID *uint `json:"cloud_id"` - Tags string `json:"tags"` - Description string `json:"description"` + Hostname string `json:"hostname"` + Alias *string `json:"alias"` + CloudInstanceName string `json:"cloud_instance_name"` + CloudInstanceID string `json:"cloud_instance_id"` + IP string `json:"ip" binding:"omitempty,ip"` + PublicIP string `json:"public_ip" binding:"omitempty,ip"` + Port int `json:"port" binding:"omitempty,min=1,max=65535"` + Username string `json:"username"` + Password string `json:"password"` + SSHKey string `json:"ssh_key"` + AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"` + CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段 + OSType string `json:"os_type" binding:"omitempty,oneof=linux windows"` // 操作系统类型 // Logan.Li + Protocol string `json:"protocol" binding:"omitempty,oneof=ssh rdp"` // 远程协议 // Logan.Li + RDPPort int `json:"rdp_port" binding:"omitempty,min=1,max=65535"` // RDP 端口 // Logan.Li + OS string `json:"os"` + CPU int `json:"cpu"` + Memory int64 `json:"memory"` + Disk int64 `json:"disk"` + Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"` + RegionID *uint `json:"region_id"` + CloudID *uint `json:"cloud_id"` + Tags string `json:"tags"` + Description string `json:"description"` } // BatchServerItem 批量导入单条服务器 // Logan.Li diff --git a/backend/model/session_command.go b/backend/model/session_command.go index 79f3363..bdc5b95 100644 --- a/backend/model/session_command.go +++ b/backend/model/session_command.go @@ -18,7 +18,7 @@ type SessionCommand struct { } func (SessionCommand) TableName() string { - return "sys_session_commands" + return "audit_session_commands" } // SessionCommandQuery 会话命令查询 // Logan.Li diff --git a/backend/model/session_command_item.go b/backend/model/session_command_item.go index af84e61..f3710de 100644 --- a/backend/model/session_command_item.go +++ b/backend/model/session_command_item.go @@ -4,4 +4,4 @@ package model type SessionCommandItem struct { SessionCommand SessionRecordSize int `gorm:"column:session_record_size" json:"session_record_size"` // 关联会话 record_size,0=无录制 // Logan.Li -} \ No newline at end of file +} diff --git a/backend/model/site.go b/backend/model/site.go index 44fd9fb..e761bb5 100644 --- a/backend/model/site.go +++ b/backend/model/site.go @@ -12,14 +12,14 @@ import ( type SiteTaskType string const ( - SiteTaskTypeHTTP SiteTaskType = "http" // HTTP/HTTPS Web 拨测 - SiteTaskTypeSSL SiteTaskType = "ssl" // SSL/TLS 证书拨测 - SiteTaskTypeTCP SiteTaskType = "tcp" // TCP拨测 - SiteTaskTypePing SiteTaskType = "ping" // ICMP Ping拨测 - SiteTaskTypeDNS SiteTaskType = "dns" // DNS拨测 - SiteTaskTypeUDP SiteTaskType = "udp" // UDP拨测 - SiteTaskTypeFTP SiteTaskType = "ftp" // FTP拨测 - SiteTaskTypeSMTP SiteTaskType = "smtp" // SMTP拨测 + SiteTaskTypeHTTP SiteTaskType = "http" // HTTP/HTTPS Web 拨测 + SiteTaskTypeSSL SiteTaskType = "ssl" // SSL/TLS 证书拨测 + SiteTaskTypeTCP SiteTaskType = "tcp" // TCP拨测 + SiteTaskTypePing SiteTaskType = "ping" // ICMP Ping拨测 + SiteTaskTypeDNS SiteTaskType = "dns" // DNS拨测 + SiteTaskTypeUDP SiteTaskType = "udp" // UDP拨测 + SiteTaskTypeFTP SiteTaskType = "ftp" // FTP拨测 + SiteTaskTypeSMTP SiteTaskType = "smtp" // SMTP拨测 SiteTaskTypeTraceroute SiteTaskType = "traceroute" // 路由追踪 ) @@ -27,9 +27,9 @@ const ( type SiteTaskMethod string const ( - SiteTaskMethodGET SiteTaskMethod = "GET" - SiteTaskMethodPOST SiteTaskMethod = "POST" - SiteTaskMethodHEAD SiteTaskMethod = "HEAD" + SiteTaskMethodGET SiteTaskMethod = "GET" + SiteTaskMethodPOST SiteTaskMethod = "POST" + SiteTaskMethodHEAD SiteTaskMethod = "HEAD" ) // SiteTaskStatus 拨测任务状态 @@ -44,37 +44,37 @@ const ( // SiteTask 站点拨测任务表 // 定义对网站、服务等进行主动拨测的任务配置 type SiteTask struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:任务名称" json:"name"` - URL string `gorm:"type:varchar(512);not null;comment:拨测URL/地址" json:"url"` - TaskType SiteTaskType `gorm:"type:varchar(16);not null;comment:拨测类型" json:"task_type"` - Method SiteTaskMethod `gorm:"type:varchar(8);default:'GET';comment:HTTP方法" json:"method"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:任务名称" json:"name"` + URL string `gorm:"type:varchar(512);not null;comment:拨测URL/地址" json:"url"` + TaskType SiteTaskType `gorm:"type:varchar(16);not null;comment:拨测类型" json:"task_type"` + Method SiteTaskMethod `gorm:"type:varchar(8);default:'GET';comment:HTTP方法" json:"method"` // HTTP 拨测配置 - Headers string `gorm:"type:text;comment:请求头JSON" json:"headers,omitempty"` - Body string `gorm:"type:text;comment:请求体" json:"body,omitempty"` - Timeout int `gorm:"default:10;comment:超时时间(秒)" json:"timeout"` - FollowRedirect bool `gorm:"default:true;comment:是否跟随重定向" json:"follow_redirect"` - VerifySSL bool `gorm:"default:true;comment:是否验证SSL证书" json:"verify_ssl"` + Headers string `gorm:"type:text;comment:请求头JSON" json:"headers,omitempty"` + Body string `gorm:"type:text;comment:请求体" json:"body,omitempty"` + Timeout int `gorm:"default:10;comment:超时时间(秒)" json:"timeout"` + FollowRedirect bool `gorm:"default:true;comment:是否跟随重定向" json:"follow_redirect"` + VerifySSL bool `gorm:"default:true;comment:是否验证SSL证书" json:"verify_ssl"` // 期望响应配置 ExpectedStatus int `gorm:"default:200;comment:期望状态码" json:"expected_status"` ExpectedContent string `gorm:"type:text;comment:期望响应内容(支持正则)" json:"expected_content,omitempty"` ExpectedLength int `gorm:"default:0;comment:期望响应长度(0表示不检查)" json:"expected_length"` // 执行配置 - Frequency int `gorm:"default:60;comment:拨测频率(秒)" json:"frequency"` - Nodes string `gorm:"type:varchar(256);comment:执行节点列表JSON" json:"nodes,omitempty"` + Frequency int `gorm:"default:60;comment:拨测频率(秒)" json:"frequency"` + Nodes string `gorm:"type:varchar(256);comment:执行节点列表JSON" json:"nodes,omitempty"` // DNS 配置 - DNSResolver string `gorm:"type:varchar(64);comment:DNS解析服务器" json:"dns_resolver,omitempty"` + DNSResolver string `gorm:"type:varchar(64);comment:DNS解析服务器" json:"dns_resolver,omitempty"` // 状态 - Status SiteTaskStatus `gorm:"type:varchar(16);default:'running';comment:任务状态" json:"status"` - LastResult string `gorm:"type:varchar(16);comment:最近结果: success/fail" json:"last_result,omitempty"` - LastCheckAt *time.Time `gorm:"comment:最近检查时间" json:"last_check_at,omitempty"` - SuccessRate float64 `gorm:"type:decimal(5,2);default:100.00;comment:成功率(%)" json:"success_rate"` - AvgResponse float64 `gorm:"comment:平均响应时间(ms)" json:"avg_response,omitempty"` - Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` - CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` + Status SiteTaskStatus `gorm:"type:varchar(16);default:'running';comment:任务状态" json:"status"` + LastResult string `gorm:"type:varchar(16);comment:最近结果: success/fail" json:"last_result,omitempty"` + LastCheckAt *time.Time `gorm:"comment:最近检查时间" json:"last_check_at,omitempty"` + SuccessRate float64 `gorm:"type:decimal(5,2);default:100.00;comment:成功率(%)" json:"success_rate"` + AvgResponse float64 `gorm:"comment:平均响应时间(ms)" json:"avg_response,omitempty"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` // 关联关系 Results []SiteResult `gorm:"foreignKey:TaskID" json:"results,omitempty"` @@ -83,7 +83,7 @@ type SiteTask struct { // TableName 指定表名 func (SiteTask) TableName() string { - return "ops_site_tasks" + return "monitor_site_tasks" } // ============================================ @@ -117,16 +117,16 @@ type SiteResult struct { ResponseSize int `gorm:"comment:响应大小(bytes)" json:"response_size,omitempty"` ResponseBody string `gorm:"type:text;comment:响应内容摘要" json:"response_body,omitempty"` // 网络信息 - RemoteIP string `gorm:"type:varchar(64);comment:远端IP" json:"remote_ip,omitempty"` - LocalNode string `gorm:"type:varchar(64);comment:执行节点" json:"local_node,omitempty"` + RemoteIP string `gorm:"type:varchar(64);comment:远端IP" json:"remote_ip,omitempty"` + LocalNode string `gorm:"type:varchar(64);comment:执行节点" json:"local_node,omitempty"` // 错误信息 ErrorMessage string `gorm:"type:text;comment:错误信息" json:"error_message,omitempty"` ErrorDetail string `gorm:"type:text;comment:错误详情" json:"error_detail,omitempty"` // SSL 证书信息 - SSLCertExpiry *time.Time `gorm:"comment:SSL证书过期时间" json:"ssl_cert_expiry,omitempty"` - SSLCertIssuer string `gorm:"type:varchar(256);comment:SSL证书签发者" json:"ssl_cert_issuer,omitempty"` - SSLCertSubject string `gorm:"type:varchar(256);comment:SSL证书主题" json:"ssl_cert_subject,omitempty"` - SSLDaysRemaining int `gorm:"comment:SSL证书剩余天数" json:"ssl_days_remaining,omitempty"` + SSLCertExpiry *time.Time `gorm:"comment:SSL证书过期时间" json:"ssl_cert_expiry,omitempty"` + SSLCertIssuer string `gorm:"type:varchar(256);comment:SSL证书签发者" json:"ssl_cert_issuer,omitempty"` + SSLCertSubject string `gorm:"type:varchar(256);comment:SSL证书主题" json:"ssl_cert_subject,omitempty"` + SSLDaysRemaining int `gorm:"comment:SSL证书剩余天数" json:"ssl_days_remaining,omitempty"` // 检查内容匹配 ContentMatch bool `gorm:"comment:内容匹配结果" json:"content_match,omitempty"` // 时间戳 @@ -139,7 +139,7 @@ type SiteResult struct { // TableName 指定表名 func (SiteResult) TableName() string { - return "ops_site_results" + return "monitor_site_results" } // ============================================ @@ -167,31 +167,31 @@ const ( // SiteAlert 拨测告警表 // 记录拨测任务触发的告警事件 type SiteAlert struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - TaskID uint `gorm:"not null;index;comment:任务ID" json:"task_id"` - Severity SiteAlertSeverity `gorm:"type:varchar(16);not null;comment:告警级别" json:"severity"` - Status SiteAlertStatus `gorm:"type:varchar(16);default:'active';index;comment:告警状态" json:"status"` - Title string `gorm:"type:varchar(256);not null;comment:告警标题" json:"title"` - Message string `gorm:"type:text;comment:告警详情" json:"message,omitempty"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + TaskID uint `gorm:"not null;index;comment:任务ID" json:"task_id"` + Severity SiteAlertSeverity `gorm:"type:varchar(16);not null;comment:告警级别" json:"severity"` + Status SiteAlertStatus `gorm:"type:varchar(16);default:'active';index;comment:告警状态" json:"status"` + Title string `gorm:"type:varchar(256);not null;comment:告警标题" json:"title"` + Message string `gorm:"type:text;comment:告警详情" json:"message,omitempty"` // 触发条件 - TriggerType string `gorm:"type:varchar(32);comment:触发类型: timeout/status_error/response_time/ssl_expiry" json:"trigger_type"` - TriggerValue string `gorm:"type:varchar(128);comment:触发值" json:"trigger_value,omitempty"` - Threshold float64 `gorm:"comment:阈值" json:"threshold,omitempty"` + TriggerType string `gorm:"type:varchar(32);comment:触发类型: timeout/status_error/response_time/ssl_expiry" json:"trigger_type"` + TriggerValue string `gorm:"type:varchar(128);comment:触发值" json:"trigger_value,omitempty"` + Threshold float64 `gorm:"comment:阈值" json:"threshold,omitempty"` // 响应信息 - ResponseTime float64 `gorm:"comment:响应时间(ms)" json:"response_time,omitempty"` - HTTPStatus int `gorm:"comment:HTTP状态码" json:"http_status,omitempty"` - ErrorMessage string `gorm:"type:text;comment:错误信息" json:"error_message,omitempty"` + ResponseTime float64 `gorm:"comment:响应时间(ms)" json:"response_time,omitempty"` + HTTPStatus int `gorm:"comment:HTTP状态码" json:"http_status,omitempty"` + ErrorMessage string `gorm:"type:text;comment:错误信息" json:"error_message,omitempty"` // 状态变化 - StartedAt time.Time `gorm:"not null;comment:告警开始时间" json:"started_at"` - ResolvedAt *time.Time `gorm:"comment:告警恢复时间" json:"resolved_at,omitempty"` - Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"` - NotifyCount int `gorm:"default:0;comment:通知次数" json:"notify_count"` - LastNotifyAt *time.Time `gorm:"comment:最近通知时间" json:"last_notify_at,omitempty"` - Acknowledged bool `gorm:"default:false;comment:是否已确认" json:"acknowledged"` - AcknowledgedBy uint `gorm:"comment:确认人ID" json:"acknowledged_by,omitempty"` - AcknowledgedAt *time.Time `gorm:"comment:确认时间" json:"acknowledged_at,omitempty"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + StartedAt time.Time `gorm:"not null;comment:告警开始时间" json:"started_at"` + ResolvedAt *time.Time `gorm:"comment:告警恢复时间" json:"resolved_at,omitempty"` + Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"` + NotifyCount int `gorm:"default:0;comment:通知次数" json:"notify_count"` + LastNotifyAt *time.Time `gorm:"comment:最近通知时间" json:"last_notify_at,omitempty"` + Acknowledged bool `gorm:"default:false;comment:是否已确认" json:"acknowledged"` + AcknowledgedBy uint `gorm:"comment:确认人ID" json:"acknowledged_by,omitempty"` + AcknowledgedAt *time.Time `gorm:"comment:确认时间" json:"acknowledged_at,omitempty"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` // 关联关系 Task *SiteTask `gorm:"foreignKey:TaskID" json:"task,omitempty"` @@ -199,45 +199,45 @@ type SiteAlert struct { // TableName 指定表名 func (SiteAlert) TableName() string { - return "ops_site_alerts" + return "monitor_site_alerts" } // SiteTaskCreateRequest 拨测任务创建请求 type SiteTaskCreateRequest struct { - Name string `json:"name" binding:"required,max=128"` - URL string `json:"url" binding:"required,max=512,url"` - TaskType SiteTaskType `json:"task_type" binding:"required"` - Method SiteTaskMethod `json:"method"` - Headers string `json:"headers"` - Body string `json:"body"` - Timeout int `json:"timeout"` - FollowRedirect bool `json:"follow_redirect"` - VerifySSL bool `json:"verify_ssl"` - ExpectedStatus int `json:"expected_status"` - ExpectedContent string `json:"expected_content"` - ExpectedLength int `json:"expected_length"` - Frequency int `json:"frequency"` - Nodes []string `json:"nodes"` - DNSResolver string `json:"dns_resolver"` - Description string `json:"description"` + Name string `json:"name" binding:"required,max=128"` + URL string `json:"url" binding:"required,max=512,url"` + TaskType SiteTaskType `json:"task_type" binding:"required"` + Method SiteTaskMethod `json:"method"` + Headers string `json:"headers"` + Body string `json:"body"` + Timeout int `json:"timeout"` + FollowRedirect bool `json:"follow_redirect"` + VerifySSL bool `json:"verify_ssl"` + ExpectedStatus int `json:"expected_status"` + ExpectedContent string `json:"expected_content"` + ExpectedLength int `json:"expected_length"` + Frequency int `json:"frequency"` + Nodes []string `json:"nodes"` + DNSResolver string `json:"dns_resolver"` + Description string `json:"description"` } // SiteTaskUpdateRequest 拨测任务更新请求 type SiteTaskUpdateRequest struct { - Name string `json:"name" binding:"max=128"` - URL string `json:"url" binding:"max=512,url"` - Method SiteTaskMethod `json:"method"` - Headers string `json:"headers"` - Body string `json:"body"` - Timeout int `json:"timeout"` - FollowRedirect bool `json:"follow_redirect"` - VerifySSL bool `json:"verify_ssl"` - ExpectedStatus int `json:"expected_status"` - ExpectedContent string `json:"expected_content"` - Frequency int `json:"frequency"` - Nodes []string `json:"nodes"` - Status SiteTaskStatus `json:"status"` - Description string `json:"description"` + Name string `json:"name" binding:"max=128"` + URL string `json:"url" binding:"max=512,url"` + Method SiteTaskMethod `json:"method"` + Headers string `json:"headers"` + Body string `json:"body"` + Timeout int `json:"timeout"` + FollowRedirect bool `json:"follow_redirect"` + VerifySSL bool `json:"verify_ssl"` + ExpectedStatus int `json:"expected_status"` + ExpectedContent string `json:"expected_content"` + Frequency int `json:"frequency"` + Nodes []string `json:"nodes"` + Status SiteTaskStatus `json:"status"` + Description string `json:"description"` } // SiteTaskStatistics 拨测任务统计 diff --git a/backend/model/snmp.go b/backend/model/snmp.go index d8f2575..7a1a686 100644 --- a/backend/model/snmp.go +++ b/backend/model/snmp.go @@ -30,9 +30,9 @@ const ( type SNMPPrivProtocol string const ( - SNMPPrivDES SNMPPrivProtocol = "DES" // DES加密 - SNMPPrivAES SNMPPrivProtocol = "AES" // AES加密 - SNMPPrivAES256 SNMPPrivProtocol = "AES256" // AES256加密 + SNMPPrivDES SNMPPrivProtocol = "DES" // DES加密 + SNMPPrivAES SNMPPrivProtocol = "AES" // AES加密 + SNMPPrivAES256 SNMPPrivProtocol = "AES256" // AES256加密 ) // SNMPDeviceStatus SNMP设备状态 @@ -47,43 +47,43 @@ const ( // SNMPDevice SNMP设备表 // 存储通过SNMP协议监控的网络设备信息 type SNMPDevice struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:设备名称" json:"name"` - IP string `gorm:"type:varchar(64);not null;index;comment:设备IP地址" json:"ip"` - Port int `gorm:"default:161;comment:SNMP端口" json:"port"` - Version SNMPVersion `gorm:"type:varchar(4);default:'v2c';comment:SNMP版本: v1/v2c/v3" json:"version"` - Community string `gorm:"type:varchar(64);comment:共同体字符串(v1/v2c)" json:"community,omitempty"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:设备名称" json:"name"` + IP string `gorm:"type:varchar(64);not null;index;comment:设备IP地址" json:"ip"` + Port int `gorm:"default:161;comment:SNMP端口" json:"port"` + Version SNMPVersion `gorm:"type:varchar(4);default:'v2c';comment:SNMP版本: v1/v2c/v3" json:"version"` + Community string `gorm:"type:varchar(64);comment:共同体字符串(v1/v2c)" json:"community,omitempty"` // SNMP v3 安全参数 - Username string `gorm:"type:varchar(64);comment:v3用户名" json:"username,omitempty"` - AuthProtocol SNMPAuthProtocol `gorm:"type:varchar(16);comment:v3认证协议" json:"auth_protocol,omitempty"` - AuthPassword string `gorm:"type:varchar(128);comment:v3认证密码" json:"auth_password,omitempty"` - PrivProtocol SNMPPrivProtocol `gorm:"type:varchar(16);comment:v3加密协议" json:"priv_protocol,omitempty"` - PrivPassword string `gorm:"type:varchar(128);comment:v3加密密码" json:"priv_password,omitempty"` - SecurityLevel string `gorm:"type:varchar(16);comment:v3安全级别: noAuthNoPriv/authNoPriv/authPriv" json:"security_level,omitempty"` + Username string `gorm:"type:varchar(64);comment:v3用户名" json:"username,omitempty"` + AuthProtocol SNMPAuthProtocol `gorm:"type:varchar(16);comment:v3认证协议" json:"auth_protocol,omitempty"` + AuthPassword string `gorm:"type:varchar(128);comment:v3认证密码" json:"auth_password,omitempty"` + PrivProtocol SNMPPrivProtocol `gorm:"type:varchar(16);comment:v3加密协议" json:"priv_protocol,omitempty"` + PrivPassword string `gorm:"type:varchar(128);comment:v3加密密码" json:"priv_password,omitempty"` + SecurityLevel string `gorm:"type:varchar(16);comment:v3安全级别: noAuthNoPriv/authNoPriv/authPriv" json:"security_level,omitempty"` // 设备信息 - DeviceType string `gorm:"type:varchar(64);comment:设备类型: switch/router/firewall/printer/server" json:"device_type"` - Vendor string `gorm:"type:varchar(64);comment:厂商" json:"vendor,omitempty"` - Model string `gorm:"type:varchar(64);comment:型号" json:"model,omitempty"` - SysDescr string `gorm:"type:text;comment:系统描述" json:"sys_descr,omitempty"` - SysObjectID string `gorm:"type:varchar(128);comment:系统OID" json:"sys_object_id,omitempty"` - SysUptime uint64 `gorm:"comment:系统运行时间(百分之一秒)" json:"sys_uptime,omitempty"` - SysContact string `gorm:"type:varchar(128);comment:系统联系人" json:"sys_contact,omitempty"` - SysName string `gorm:"type:varchar(128);comment:系统名称" json:"sys_name,omitempty"` - SysLocation string `gorm:"type:varchar(128);comment:系统位置" json:"sys_location,omitempty"` + DeviceType string `gorm:"type:varchar(64);comment:设备类型: switch/router/firewall/printer/server" json:"device_type"` + Vendor string `gorm:"type:varchar(64);comment:厂商" json:"vendor,omitempty"` + Model string `gorm:"type:varchar(64);comment:型号" json:"model,omitempty"` + SysDescr string `gorm:"type:text;comment:系统描述" json:"sys_descr,omitempty"` + SysObjectID string `gorm:"type:varchar(128);comment:系统OID" json:"sys_object_id,omitempty"` + SysUptime uint64 `gorm:"comment:系统运行时间(百分之一秒)" json:"sys_uptime,omitempty"` + SysContact string `gorm:"type:varchar(128);comment:系统联系人" json:"sys_contact,omitempty"` + SysName string `gorm:"type:varchar(128);comment:系统名称" json:"sys_name,omitempty"` + SysLocation string `gorm:"type:varchar(128);comment:系统位置" json:"sys_location,omitempty"` // 采集配置 - TemplateID uint `gorm:"index;comment:OID模板ID" json:"template_id,omitempty"` - CollectInterval int `gorm:"default:300;comment:采集间隔(秒)" json:"collect_interval"` - Timeout int `gorm:"default:10;comment:超时时间(秒)" json:"timeout"` - Retries int `gorm:"default:3;comment:重试次数" json:"retries"` - MaxRepetitions uint32 `gorm:"default:25;comment:BulkWalk最大重复数" json:"max_repetitions"` + TemplateID uint `gorm:"index;comment:OID模板ID" json:"template_id,omitempty"` + CollectInterval int `gorm:"default:300;comment:采集间隔(秒)" json:"collect_interval"` + Timeout int `gorm:"default:10;comment:超时时间(秒)" json:"timeout"` + Retries int `gorm:"default:3;comment:重试次数" json:"retries"` + MaxRepetitions uint32 `gorm:"default:25;comment:BulkWalk最大重复数" json:"max_repetitions"` // 状态 - Status SNMPDeviceStatus `gorm:"type:varchar(16);default:'offline';index;comment:状态" json:"status"` - LastCollectAt *time.Time `gorm:"comment:最后采集时间" json:"last_collect_at,omitempty"` - LastError string `gorm:"type:text;comment:最后错误信息" json:"last_error,omitempty"` - Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` - DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` + Status SNMPDeviceStatus `gorm:"type:varchar(16);default:'offline';index;comment:状态" json:"status"` + LastCollectAt *time.Time `gorm:"comment:最后采集时间" json:"last_collect_at,omitempty"` + LastError string `gorm:"type:text;comment:最后错误信息" json:"last_error,omitempty"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` // 关联关系 Template *SNMPTemplate `gorm:"foreignKey:TemplateID" json:"template,omitempty"` @@ -92,7 +92,7 @@ type SNMPDevice struct { // TableName 指定表名 func (SNMPDevice) TableName() string { - return "ops_snmp_devices" + return "monitor_snmp_devices" } // ============================================ @@ -102,16 +102,16 @@ func (SNMPDevice) TableName() string { // SNMPTemplate OID模板表 // 预定义的OID模板,用于快速配置同类设备的监控 type SNMPTemplate struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - Name string `gorm:"type:varchar(128);not null;comment:模板名称" json:"name"` - Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` - DeviceType string `gorm:"type:varchar(64);comment:适用设备类型" json:"device_type,omitempty"` - Vendor string `gorm:"type:varchar(64);comment:适用厂商" json:"vendor,omitempty"` - IsDefault bool `gorm:"default:false;comment:是否默认模板" json:"is_default"` - IsSystem bool `gorm:"default:false;comment:是否系统模板" json:"is_system"` - Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + Name string `gorm:"type:varchar(128);not null;comment:模板名称" json:"name"` + Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"` + DeviceType string `gorm:"type:varchar(64);comment:适用设备类型" json:"device_type,omitempty"` + Vendor string `gorm:"type:varchar(64);comment:适用厂商" json:"vendor,omitempty"` + IsDefault bool `gorm:"default:false;comment:是否默认模板" json:"is_default"` + IsSystem bool `gorm:"default:false;comment:是否系统模板" json:"is_system"` + Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` // 关联关系 @@ -120,7 +120,7 @@ type SNMPTemplate struct { // TableName 指定表名 func (SNMPTemplate) TableName() string { - return "ops_snmp_templates" + return "monitor_snmp_templates" } // SNMPTemplateOID OID模板条目表 @@ -142,7 +142,7 @@ type SNMPTemplateOID struct { // TableName 指定表名 func (SNMPTemplateOID) TableName() string { - return "ops_snmp_template_oids" + return "monitor_snmp_template_oids" } // ============================================ @@ -170,7 +170,7 @@ type SNMPMetric struct { // TableName 指定表名 func (SNMPMetric) TableName() string { - return "ops_snmp_metrics" + return "monitor_snmp_samples" } // ============================================ @@ -180,26 +180,26 @@ func (SNMPMetric) TableName() string { // SNMPInterface SNMP接口信息表 // 存储网络设备的接口详细信息 type SNMPInterface struct { - ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` - DeviceID uint `gorm:"not null;index;comment:设备ID" json:"device_id"` - IfIndex int `gorm:"not null;comment:接口索引" json:"if_index"` - IfDescr string `gorm:"type:varchar(128);comment:接口描述" json:"if_descr,omitempty"` - IfName string `gorm:"type:varchar(64);comment:接口名称" json:"if_name,omitempty"` - IfAlias string `gorm:"type:varchar(128);comment:接口别名" json:"if_alias,omitempty"` - IfType int `gorm:"comment:接口类型" json:"if_type,omitempty"` - IfSpeed uint64 `gorm:"comment:接口速率(bps)" json:"if_speed,omitempty"` - IfPhysAddr string `gorm:"type:varchar(32);comment:物理地址(MAC)" json:"if_phys_addr,omitempty"` - IfAdminStatus int `gorm:"comment:管理状态: 1=up 2=down 3=testing" json:"if_admin_status,omitempty"` - IfOperStatus int `gorm:"comment:操作状态: 1=up 2=down 3=testing" json:"if_oper_status,omitempty"` - IfHighSpeed uint32 `gorm:"comment:高速接口速率(Mbps)" json:"if_high_speed,omitempty"` - Status int `gorm:"default:1;comment:状态" json:"status"` - CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` + ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` + DeviceID uint `gorm:"not null;index;comment:设备ID" json:"device_id"` + IfIndex int `gorm:"not null;comment:接口索引" json:"if_index"` + IfDescr string `gorm:"type:varchar(128);comment:接口描述" json:"if_descr,omitempty"` + IfName string `gorm:"type:varchar(64);comment:接口名称" json:"if_name,omitempty"` + IfAlias string `gorm:"type:varchar(128);comment:接口别名" json:"if_alias,omitempty"` + IfType int `gorm:"comment:接口类型" json:"if_type,omitempty"` + IfSpeed uint64 `gorm:"comment:接口速率(bps)" json:"if_speed,omitempty"` + IfPhysAddr string `gorm:"type:varchar(32);comment:物理地址(MAC)" json:"if_phys_addr,omitempty"` + IfAdminStatus int `gorm:"comment:管理状态: 1=up 2=down 3=testing" json:"if_admin_status,omitempty"` + IfOperStatus int `gorm:"comment:操作状态: 1=up 2=down 3=testing" json:"if_oper_status,omitempty"` + IfHighSpeed uint32 `gorm:"comment:高速接口速率(Mbps)" json:"if_high_speed,omitempty"` + Status int `gorm:"default:1;comment:状态" json:"status"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` } // TableName 指定表名 func (SNMPInterface) TableName() string { - return "ops_snmp_interfaces" + return "monitor_snmp_interfaces" } // ============================================ diff --git a/backend/model/system_task.go b/backend/model/system_task.go index 4d52b5b..2d87825 100644 --- a/backend/model/system_task.go +++ b/backend/model/system_task.go @@ -13,4 +13,4 @@ type SystemTaskStatus struct { UpdatedAt time.Time `json:"updated_at"` } -func (SystemTaskStatus) TableName() string { return "sys_system_task_status" } \ No newline at end of file +func (SystemTaskStatus) TableName() string { return "sys_system_task_statuses" } diff --git a/backend/model/terminal_session.go b/backend/model/terminal_session.go index d17fb64..0e756dc 100644 --- a/backend/model/terminal_session.go +++ b/backend/model/terminal_session.go @@ -12,10 +12,10 @@ type TerminalSession struct { ServerIP string `gorm:"size:64" json:"server_ip"` ServerPort int `json:"server_port"` ClientIP string `gorm:"size:64" json:"client_ip"` - Status string `gorm:"size:32" json:"status"` // active, closed, failed - Duration int `json:"duration"` // seconds + Status string `gorm:"size:32" json:"status"` // active, closed, failed + Duration int `json:"duration"` // seconds RecordData string `gorm:"type:mediumtext" json:"-"` // 终端录制原始输出(asciicast v2 格式,max 16MB) - RecordSize int `json:"record_size"` // 录制字节数 + RecordSize int `json:"record_size"` // 录制字节数 StartTime time.Time `json:"start_time"` EndTime *time.Time `json:"end_time"` CreatedAt time.Time `json:"created_at"` @@ -23,18 +23,18 @@ type TerminalSession struct { // TableName 表名 func (TerminalSession) TableName() string { - return "sys_terminal_sessions" + return "audit_terminal_sessions" } // TerminalSessionQuery 终端会话查询 // Logan.Li type TerminalSessionQuery struct { - ServerID uint `form:"server_id"` - Username string `form:"username"` - Status string `form:"status"` - Keyword string `form:"keyword"` // 模糊匹配用户/主机名/IP/客户端IP - StartDate string `form:"start_date"` // YYYY-MM-DD - EndDate string `form:"end_date"` // YYYY-MM-DD - HasRecord string `form:"has_record"` // 1/true 仅含录制 - Page int `form:"page,default=1"` - PageSize int `form:"page_size,default=20"` + ServerID uint `form:"server_id"` + Username string `form:"username"` + Status string `form:"status"` + Keyword string `form:"keyword"` // 模糊匹配用户/主机名/IP/客户端IP + StartDate string `form:"start_date"` // YYYY-MM-DD + EndDate string `form:"end_date"` // YYYY-MM-DD + HasRecord string `form:"has_record"` // 1/true 仅含录制 + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=20"` } diff --git a/backend/model/user.go b/backend/model/user.go index 821cd32..d4ee6c0 100644 --- a/backend/model/user.go +++ b/backend/model/user.go @@ -2,22 +2,21 @@ package model import ( "time" - ) // User 用户模型 type User struct { - ID uint `gorm:"primarykey" json:"id"` - Username string `gorm:"size:64;uniqueIndex;not null" json:"username"` - Password string `gorm:"size:255;not null" json:"-"` - Email string `gorm:"size:128" json:"email"` - Phone string `gorm:"size:32" json:"phone"` - RealName string `gorm:"size:64" json:"real_name"` - Avatar string `gorm:"size:255" json:"avatar"` - Status int `gorm:"default:1" json:"status"` // 0:禁用 1:启用 - IsAdmin bool `gorm:"default:false" json:"is_admin"` - AuthSource string `gorm:"size:20;default:'local'" json:"auth_source"` // local / ldap - LastLogin *time.Time `json:"last_login"` + ID uint `gorm:"primarykey" json:"id"` + Username string `gorm:"size:64;uniqueIndex;not null" json:"username"` + Password string `gorm:"size:255;not null" json:"-"` + Email string `gorm:"size:128" json:"email"` + Phone string `gorm:"size:32" json:"phone"` + RealName string `gorm:"size:64" json:"real_name"` + Avatar string `gorm:"size:255" json:"avatar"` + Status int `gorm:"default:1" json:"status"` // 0:禁用 1:启用 + IsAdmin bool `gorm:"default:false" json:"is_admin"` + AuthSource string `gorm:"size:20;default:'local'" json:"auth_source"` // local / ldap + LastLogin *time.Time `json:"last_login"` // CI/CD Webhook 长期访问密钥(每用户仅一条,明文仅生成时返回一次)// Logan.Li ApiAccessKeyHash string `gorm:"size:255" json:"-"` ApiAccessKeyPrefix string `gorm:"size:32;index" json:"api_access_key_prefix,omitempty"` @@ -27,8 +26,8 @@ type User struct { ApiAccessKeyAt *time.Time `json:"api_access_key_at,omitempty"` ApiAccessKeyExpiresAt *time.Time `json:"api_access_key_expires_at,omitempty"` ApiAccessKeyLastUsedAt *time.Time `json:"api_access_key_last_used_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Roles []Role `gorm:"many2many:iam_user_roles;" json:"roles,omitempty"` @@ -41,17 +40,17 @@ func (User) TableName() string { // Role 角色模型 type Role struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:64;uniqueIndex;not null" json:"name"` - Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` - Description string `gorm:"size:255" json:"description"` - Status int `gorm:"default:1" json:"status"` - IsSystem bool `gorm:"default:false" json:"is_system"` // 系统默认角色,禁止删除 - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;uniqueIndex;not null" json:"name"` + Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` + Description string `gorm:"size:255" json:"description"` + Status int `gorm:"default:1" json:"status"` + IsSystem bool `gorm:"default:false" json:"is_system"` // 系统默认角色,禁止删除 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 - Permissions []Permission `gorm:"many2many:role_permissions;" json:"permissions,omitempty"` + Permissions []Permission `gorm:"many2many:iam_role_permissions;" json:"permissions,omitempty"` } // TableName 表名 @@ -61,17 +60,17 @@ func (Role) TableName() string { // Permission 权限模型 type Permission struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:64;not null" json:"name"` - Code string `gorm:"size:128;uniqueIndex;not null" json:"code"` - Type string `gorm:"size:32" json:"type"` // menu, button, api - Path string `gorm:"size:255" json:"path"` - Method string `gorm:"size:32" json:"method"` - ParentID *uint `json:"parent_id"` - Sort int `gorm:"default:0" json:"sort"` - Status int `gorm:"default:1" json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + Code string `gorm:"size:128;uniqueIndex;not null" json:"code"` + Type string `gorm:"size:32" json:"type"` // menu, button, api + Path string `gorm:"size:255" json:"path"` + Method string `gorm:"size:32" json:"method"` + ParentID *uint `json:"parent_id"` + Sort int `gorm:"default:0" json:"sort"` + Status int `gorm:"default:1" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 @@ -147,9 +146,9 @@ type UpdateUserRequest struct { // CreateRoleRequest 创建角色请求 // Logan.Li type CreateRoleRequest struct { - Name string `json:"name" binding:"required,min=1,max=64"` - Code string `json:"code" binding:"required,min=1,max=64"` - Description string `json:"description"` + Name string `json:"name" binding:"required,min=1,max=64"` + Code string `json:"code" binding:"required,min=1,max=64"` + Description string `json:"description"` PermissionIDs []uint `json:"permission_ids"` } diff --git a/backend/pkg/agentauth/credential.go b/backend/pkg/agentauth/credential.go new file mode 100644 index 0000000..dee42ab --- /dev/null +++ b/backend/pkg/agentauth/credential.go @@ -0,0 +1,81 @@ +package agentauth + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" + + "ezops/model" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// Issue 为指定主机轮换 Agent Token,旧 Token 会立即失效。 // Logan.Li +func Issue(db *gorm.DB, hostname, issuedBy string) (string, error) { + hostname = strings.TrimSpace(hostname) + if hostname == "" { + return "", fmt.Errorf("hostname is required") + } + prefix, err := randomHex(8) + if err != nil { + return "", err + } + // bcrypt 只处理前 72 字节,Token 总长度控制在 61 字节以内。 // Logan.Li + secret, err := randomHex(20) + if err != nil { + return "", err + } + token := "agt_" + prefix + "." + secret + hash, err := bcrypt.GenerateFromPassword([]byte(token), bcrypt.DefaultCost) + if err != nil { + return "", err + } + now := time.Now() + credential := model.AgentCredential{Hostname: hostname} + err = db.Where("hostname = ?", hostname).Assign(map[string]interface{}{ + "token_prefix": "agt_" + prefix, + "token_hash": string(hash), + "status": "active", + "issued_by": issuedBy, + "last_used_at": nil, + "expires_at": nil, + "rotated_at": now, + }).FirstOrCreate(&credential).Error + if err != nil { + return "", err + } + return token, nil +} + +// Authenticate 校验 Token 并返回绑定的主机名。 // Logan.Li +func Authenticate(db *gorm.DB, token string) (*model.AgentCredential, error) { + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 || !strings.HasPrefix(parts[0], "agt_") || parts[1] == "" { + return nil, fmt.Errorf("invalid agent token") + } + var credential model.AgentCredential + if err := db.Where("token_prefix = ? AND status = ?", parts[0], "active").Take(&credential).Error; err != nil { + return nil, fmt.Errorf("invalid agent token") + } + if credential.ExpiresAt != nil && credential.ExpiresAt.Before(time.Now()) { + return nil, fmt.Errorf("agent token expired") + } + if bcrypt.CompareHashAndPassword([]byte(credential.TokenHash), []byte(token)) != nil { + return nil, fmt.Errorf("invalid agent token") + } + now := time.Now() + _ = db.Model(&credential).Update("last_used_at", now).Error + credential.LastUsedAt = &now + return &credential, nil +} + +func randomHex(bytes int) (string, error) { + buf := make([]byte, bytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} diff --git a/backend/pkg/agentcli/agentcli.go b/backend/pkg/agentcli/agentcli.go index 3b60d74..e00d894 100644 --- a/backend/pkg/agentcli/agentcli.go +++ b/backend/pkg/agentcli/agentcli.go @@ -96,6 +96,7 @@ func NewEngine(cfg EngineConfig) (*Engine, error) { return nil, fmt.Errorf("Kubernetes 模式缺少集群客户端") } kt := cfg.Kubernetes + registry.Register(tools.NewKubernetesDiagnoseTool(kt.Client, kt.Namespace)) registry.Register(tools.NewKubernetesGetTool(kt.Client, kt.Namespace)) registry.Register(tools.NewPodLogsTool(kt.Client, kt.Namespace, kt.PodName, kt.Container)) registry.Register(tools.NewPodExecuteTool(kt.Client, kt.Namespace, kt.PodName, kt.Container, coreCfg.ToolTimeout)) diff --git a/backend/pkg/agentcli/core/agent.go b/backend/pkg/agentcli/core/agent.go index b7ca228..531a2ed 100644 --- a/backend/pkg/agentcli/core/agent.go +++ b/backend/pkg/agentcli/core/agent.go @@ -23,29 +23,49 @@ import ( type EventType string const ( + EventStatus EventType = "status" // 生命周期状态(accepted/context/thinking/...) EventThought EventType = "thought" // AI 思考/推理文本(流式结束后的完整内容) EventThoughtDelta EventType = "thought_delta" // AI 思考流式增量 token EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险) - EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时) - EventProgress EventType = "progress" // 轮次进度 + token - EventDone EventType = "done" // 任务完成(最终答案) - EventError EventType = "error" // 错误(含达到最大轮次) + EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时) + EventProgress EventType = "progress" // 轮次进度 + token + EventDone EventType = "done" // 任务完成(最终答案) + EventError EventType = "error" // 错误(含达到最大轮次) +) + +const ( + PhaseAccepted = "accepted" + PhaseContext = "context" + PhaseThinking = "thinking" + PhasePlanning = "planning" + PhaseSafety = "safety_check" + PhaseApproval = "approval_required" + PhaseToolRunning = "tool_running" + PhaseObserving = "observing" + PhaseValidating = "validating" + PhaseSummarizing = "summarizing" + PhaseDone = "done" + PhaseBlocked = "blocked" + PhaseError = "error" ) // Event 引擎产生的事件(controller 序列化后通过 WebSocket 推送) // Logan.Li type Event struct { - Type EventType `json:"type"` - Round int `json:"round,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"` + Type EventType `json:"type"` + Phase string `json:"phase,omitempty"` + Summary string `json:"summary,omitempty"` + Round int `json:"round,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"` + OutputSize int `json:"output_size,omitempty"` + Tokens int `json:"tokens,omitempty"` + Answer string `json:"answer,omitempty"` + Message string `json:"message,omitempty"` } // EventCallback 事件回调(controller 注入) // Logan.Li @@ -96,6 +116,7 @@ type Agent struct { evolver *evolver.EvolverEngine callback EventCallback totalTokens int + cache map[string]string mu sync.Mutex } @@ -110,7 +131,7 @@ func NewAgent(client llm.Client, registry *tools.Registry, cfg *Config, sc *safe if cfg.MaxContextTokens <= 0 { cfg.MaxContextTokens = 8000 } - return &Agent{llm: client, registry: registry, config: cfg, safetyCtl: sc, evolver: ev} + return &Agent{llm: client, registry: registry, config: cfg, safetyCtl: sc, evolver: ev, cache: make(map[string]string)} } // SetCallback 注入事件回调 // Logan.Li @@ -127,6 +148,7 @@ func (a *Agent) emit(e Event) { func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*RunResult, error) { a.totalTokens = priorTokens start := time.Now() + a.emit(Event{Type: EventStatus, Phase: PhaseAccepted, Summary: "任务已接收"}) // Evolver PREDICT:获取历史经验注入 system prompt evolveCtx := "" @@ -137,6 +159,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M // 工具描述 toolDescs := a.buildToolDescriptions() systemPrompt := prompt.BuildSystemPrompt(a.config.Mode, serverInfo, toolDescs, evolveCtx) + a.emit(Event{Type: EventStatus, Phase: PhaseContext, Summary: fmt.Sprintf("mode=%s tools=%d prior_messages=%d", a.config.Mode, len(a.registry.List()), len(prior))}) messages := []llm.Message{{Role: "system", Content: systemPrompt}} if len(prior) > 0 { @@ -168,6 +191,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M // ReAct 须用 Complete:Stream 无法组装 tool_calls/tool_use(GLM/Claude 等会只回文字不执行) // Logan.Li round := a.roundNum(iter) + a.emit(Event{Type: EventStatus, Phase: PhaseThinking, Round: round, Summary: "LLM 推理中"}) resp, err := a.llm.Complete(ctx, &llm.CompletionRequest{ Messages: trimmed, Tools: llmTools, @@ -175,7 +199,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M MaxTokens: a.config.MaxTokens, }) if err != nil { - a.emit(Event{Type: EventError, Message: "LLM 调用失败: " + err.Error()}) + a.emit(Event{Type: EventError, Phase: PhaseError, Message: "LLM 调用失败: " + err.Error()}) return nil, fmt.Errorf("LLM调用失败: %w", err) } @@ -208,7 +232,8 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M answer = "任务已完成。" } } - a.emit(Event{Type: EventDone, Answer: answer, Tokens: a.totalTokens}) + a.emit(Event{Type: EventStatus, Phase: PhaseSummarizing, Round: round, Summary: "汇总最终答案"}) + a.emit(Event{Type: EventDone, Phase: PhaseDone, Answer: answer, Tokens: a.totalTokens}) a.triggerEvolve(ctx, query, serverInfo, answer, toolRecords, true, start) return &RunResult{ Success: true, Output: answer, @@ -218,7 +243,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M // 中间轮思考(后续还有 tool_calls,与终局 done 分开,避免重复输出) // Logan.Li if thought := strings.TrimSpace(resp.Message.Content); thought != "" { - a.emit(Event{Type: EventThought, Round: round, Thought: thought}) + a.emit(Event{Type: EventThought, Phase: PhasePlanning, Round: round, Thought: thought}) } // 处理工具调用 @@ -231,7 +256,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M // 达到最大轮次 msg := fmt.Sprintf("达到最大迭代次数(%d),任务未能完成。可能任务过于复杂或需要更多步骤。", a.config.MaxIterations) - a.emit(Event{Type: EventError, Message: msg, Tokens: a.totalTokens}) + a.emit(Event{Type: EventError, Phase: PhaseError, Message: msg, Tokens: a.totalTokens}) a.triggerEvolve(ctx, query, serverInfo, "", toolRecords, false, start) return &RunResult{ Success: false, History: a.extractHistory(messages), TotalTokens: a.totalTokens, @@ -269,12 +294,21 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist continue } + if msg := a.suppressBroadKubernetesGet(tc.Function.Name, args, history); msg != "" { + a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: tools.RiskSafe.Display(), Summary: "工具执行中"}) + a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name, + Output: msg, Success: true, Duration: "0s", OutputSize: len(msg)}) + obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: msg}) + continue + } + risk := a.toolRisk(tool, args) - a.emit(Event{Type: EventToolCall, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display()}) + a.emit(Event{Type: EventStatus, Phase: PhaseSafety, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "安全检查"}) // 安全检查(高危触发 confirmFn,由 controller 异步确认) approved, err := a.safetyCtl.Check(tool, args) if err != nil { + a.emit(Event{Type: EventStatus, Phase: PhaseError, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Message: err.Error()}) obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: "安全检查失败: " + err.Error()}) continue @@ -282,12 +316,24 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist if !approved { obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: "操作已被用户拒绝执行"}) + a.emit(Event{Type: EventStatus, Phase: PhaseBlocked, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "用户拒绝执行"}) a.emit(Event{Type: EventToolResult, Round: round, ToolName: tc.Function.Name, - Output: "(用户拒绝执行)", Success: false}) + Phase: PhaseObserving, Output: "(用户拒绝执行)", Success: false, OutputSize: len("(用户拒绝执行)")}) continue } // 执行 + a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "工具执行中"}) + if cached, ok := a.cachedToolResult(tc.Function.Name, args); ok { + a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name, + Output: cached, Success: true, Duration: "0s", OutputSize: len(cached)}) + records = append(records, evolver.ToolCallRecord{ + ToolName: tc.Function.Name, Args: args, Output: cached, + Duration: 0, Success: true, RiskLevel: int(risk), + }) + obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: cached}) + continue + } toolStart := time.Now() toolCtx, cancel := context.WithTimeout(ctx, a.config.ToolTimeout) result, execErr := tool.Execute(toolCtx, args) @@ -317,8 +363,9 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist fmt.Sprintf("\n[输出已截断,原始 %d 字符]", len(output)) } - a.emit(Event{Type: EventToolResult, Round: round, ToolName: tc.Function.Name, - Output: output, Success: success, Duration: dur.Truncate(time.Millisecond).String()}) + a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name, + Output: output, Success: success, Duration: dur.Truncate(time.Millisecond).String(), OutputSize: len(output)}) + a.storeToolResult(tc.Function.Name, args, output, success) records = append(records, evolver.ToolCallRecord{ ToolName: tc.Function.Name, Args: args, Output: output, @@ -342,6 +389,82 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist return obs, records, failInc } +// cachedToolResult 返回同一任务内只读工具的缓存结果,避免 LLM 重复消耗 token。 // Logan.Li +func (a *Agent) cachedToolResult(toolName string, args map[string]interface{}) (string, bool) { + if !cacheableTool(toolName) { + return "", false + } + key := toolCacheKey(toolName, args) + if key == "" { + return "", false + } + if out, ok := a.cache[key]; ok { + return "已复用本轮任务缓存结果;不要重复查询同一对象,请基于首次结果总结,或改用更具体的 name/status_filter/pod 参数补查。\n\n" + out, true + } + return "", false +} + +// storeToolResult 缓存成功的只读工具输出。 // Logan.Li +func (a *Agent) storeToolResult(toolName string, args map[string]interface{}, output string, success bool) { + if !success || !cacheableTool(toolName) { + return + } + key := toolCacheKey(toolName, args) + if key == "" { + return + } + a.cache[key] = output +} + +func cacheableTool(toolName string) bool { + switch toolName { + case "kubernetes_diagnose", "kubernetes_get", "pod_logs", "aiops_asset_search", "aiops_alert_list", "aiops_metric_summary": + return true + default: + return false + } +} + +func toolCacheKey(toolName string, args map[string]interface{}) string { + b, err := json.Marshal(args) + if err != nil { + return "" + } + return toolName + ":" + string(b) +} + +// suppressBroadKubernetesGet 在聚合诊断后拦截全量 K8s 查询,迫使模型按异常对象下钻。 // Logan.Li +func (a *Agent) suppressBroadKubernetesGet(toolName string, args map[string]interface{}, history map[string]int) string { + if a.config.Mode != "kubernetes" && a.config.Mode != "k8s" { + return "" + } + if toolName != "kubernetes_get" || !hasDiagnoseHistory(history) { + return "" + } + resource := strings.ToLower(strings.TrimSpace(fmt.Sprint(args["resource"]))) + name := strings.TrimSpace(fmt.Sprint(args["name"])) + labelSelector := strings.TrimSpace(fmt.Sprint(args["label_selector"])) + statusFilter := strings.TrimSpace(fmt.Sprint(args["status_filter"])) + if name != "" && name != "" || labelSelector != "" && labelSelector != "" || statusFilter != "" && statusFilter != "" { + return "" + } + switch resource { + case "pods", "pod", "deployments", "deployment", "deploy", "services", "service", "svc", "nodes", "node", "events", "event": + return "已拦截全量 Kubernetes 查询:本轮已经执行过 kubernetes_diagnose,里面包含异常 Workload、异常 Pod、Top Events 和节点摘要。请不要再次拉全量资源;如需补证据,请按诊断结果中的具体对象调用 kubernetes_get(name/status_filter) 或 pod_logs(pod)。" + default: + return "" + } +} + +func hasDiagnoseHistory(history map[string]int) bool { + for key, count := range history { + if count > 0 && strings.HasPrefix(key, "kubernetes_diagnose:") { + return true + } + } + return false +} + // triggerEvolve 异步触发进化(带超时 + panic 恢复,绝不阻塞主循环) // Logan.Li func (a *Agent) triggerEvolve(ctx context.Context, query, serverHost, answer string, records []evolver.ToolCallRecord, success bool, start time.Time) { if a.evolver == nil { @@ -495,34 +618,46 @@ func (a *Agent) trimMessages(msgs []llm.Message) []llm.Message { return ensureToolMessageIntegrity(result) } -// ensureToolMessageIntegrity 确保 tool 消息前面有对应 assistant(tool_calls) // Logan.Li +// ensureToolMessageIntegrity 确保 assistant(tool_calls) 后紧跟完整 tool 结果组 // Logan.Li func ensureToolMessageIntegrity(msgs []llm.Message) []llm.Message { - validIDs := make(map[string]bool) - for _, m := range msgs { - if m.Role == "assistant" { - for _, tc := range m.ToolCalls { - validIDs[tc.ID] = true - } - } - } out := make([]llm.Message, 0, len(msgs)) - for i, m := range msgs { + for i := 0; i < len(msgs); i++ { + m := msgs[i] if m.Role == "tool" { - if !validIDs[m.ToolCallID] || i == 0 || msgs[i-1].Role != "assistant" { - continue - } - matched := false - for _, tc := range msgs[i-1].ToolCalls { - if tc.ID == m.ToolCallID { - matched = true - break - } - } - if !matched { - continue + continue + } + if m.Role != "assistant" || len(m.ToolCalls) == 0 { + out = append(out, m) + continue + } + + required := make(map[string]bool, len(m.ToolCalls)) + for _, tc := range m.ToolCalls { + if tc.ID != "" { + required[tc.ID] = true } } - out = append(out, m) + if len(required) == 0 { + continue + } + + seen := make(map[string]bool, len(required)) + tools := make([]llm.Message, 0, len(required)) + j := i + 1 + for ; j < len(msgs) && msgs[j].Role == "tool"; j++ { + tm := msgs[j] + if !required[tm.ToolCallID] || seen[tm.ToolCallID] { + continue + } + seen[tm.ToolCallID] = true + tools = append(tools, tm) + } + + if len(seen) == len(required) { + out = append(out, m) + out = append(out, tools...) + } + i = j - 1 } return out } diff --git a/backend/pkg/agentcli/core/agent_test.go b/backend/pkg/agentcli/core/agent_test.go new file mode 100644 index 0000000..01df16d --- /dev/null +++ b/backend/pkg/agentcli/core/agent_test.go @@ -0,0 +1,98 @@ +package core + +import ( + "testing" + + "ezops/pkg/agentcli/llm" +) + +func TestEnsureToolMessageIntegrityKeepsCompleteMultiToolGroup(t *testing.T) { + msgs := []llm.Message{ + {Role: "system", Content: "sys"}, + {Role: "user", Content: "查看异常副本"}, + {Role: "assistant", Content: "并行检查", ToolCalls: []llm.ToolCall{ + {ID: "call-1", Type: "function", Function: llm.FunctionCall{Name: "kubernetes_get", Arguments: "{}"}}, + {ID: "call-2", Type: "function", Function: llm.FunctionCall{Name: "kubernetes_get", Arguments: "{}"}}, + {ID: "call-3", Type: "function", Function: llm.FunctionCall{Name: "kubernetes_get", Arguments: "{}"}}, + }}, + {Role: "tool", ToolCallID: "call-1", Name: "kubernetes_get", Content: "deployments"}, + {Role: "tool", ToolCallID: "call-2", Name: "kubernetes_get", Content: "statefulsets"}, + {Role: "tool", ToolCallID: "call-3", Name: "kubernetes_get", Content: "pods"}, + } + + got := ensureToolMessageIntegrity(msgs) + if len(got) != len(msgs) { + t.Fatalf("完整多工具结果组应全部保留,got=%d want=%d", len(got), len(msgs)) + } + for i := 3; i < len(got); i++ { + if got[i].Role != "tool" || got[i].ToolCallID == "" { + t.Fatalf("第 %d 条工具结果异常: %+v", i, got[i]) + } + } +} + +func TestEnsureToolMessageIntegrityDropsIncompleteToolGroup(t *testing.T) { + msgs := []llm.Message{ + {Role: "system", Content: "sys"}, + {Role: "assistant", Content: "并行检查", ToolCalls: []llm.ToolCall{ + {ID: "call-1", Type: "function", Function: llm.FunctionCall{Name: "kubernetes_get", Arguments: "{}"}}, + {ID: "call-2", Type: "function", Function: llm.FunctionCall{Name: "kubernetes_get", Arguments: "{}"}}, + }}, + {Role: "tool", ToolCallID: "call-1", Name: "kubernetes_get", Content: "deployments"}, + {Role: "user", Content: "继续"}, + } + + got := ensureToolMessageIntegrity(msgs) + if len(got) != 2 { + t.Fatalf("不完整工具结果组应整体丢弃,got=%d want=2: %+v", len(got), got) + } + if got[0].Role != "system" || got[1].Role != "user" { + t.Fatalf("剩余消息异常: %+v", got) + } +} + +func TestSuppressBroadKubernetesGetAfterDiagnose(t *testing.T) { + agent := NewAgent(nil, nil, &Config{Mode: "kubernetes"}, nil, nil) + history := map[string]int{`kubernetes_diagnose:{"namespace":"astralx"}`: 1} + msg := agent.suppressBroadKubernetesGet("kubernetes_get", map[string]interface{}{"resource": "pods"}, history) + if msg == "" { + t.Fatal("expected broad pods query to be suppressed after diagnose") + } +} + +func TestSuppressBroadKubernetesGetBlocksAllPodAliasesAfterDiagnose(t *testing.T) { + agent := NewAgent(nil, nil, &Config{Mode: "kubernetes"}, nil, nil) + history := map[string]int{`kubernetes_diagnose:{"namespace":"astralx","focus":"all"}`: 1} + for _, resource := range []string{"pods", "pod"} { + msg := agent.suppressBroadKubernetesGet("kubernetes_get", map[string]interface{}{"resource": resource}, history) + if msg == "" { + t.Fatalf("expected broad %s query to be suppressed after diagnose", resource) + } + } +} + +func TestSuppressBroadKubernetesGetAllowsFilteredQuery(t *testing.T) { + agent := NewAgent(nil, nil, &Config{Mode: "kubernetes"}, nil, nil) + history := map[string]int{`kubernetes_diagnose:{"namespace":"astralx"}`: 1} + msg := agent.suppressBroadKubernetesGet("kubernetes_get", map[string]interface{}{ + "resource": "pods", "status_filter": "NotRunning", + }, history) + if msg != "" { + t.Fatalf("expected filtered pods query to pass, got %q", msg) + } +} + +func TestSuppressBroadKubernetesGetAllowsStatusFilteredPodsAfterDiagnose(t *testing.T) { + agent := NewAgent(nil, nil, &Config{Mode: "kubernetes"}, nil, nil) + history := map[string]int{`kubernetes_diagnose:{"namespace":"astralx","focus":"pods"}`: 1} + cases := []map[string]interface{}{ + {"resource": "pods", "status_filter": "NotRunning"}, + {"resource": "pod", "status_filter": "CrashLoopBackOff"}, + } + for _, args := range cases { + msg := agent.suppressBroadKubernetesGet("kubernetes_get", args, history) + if msg != "" { + t.Fatalf("expected status_filter query to pass for args=%v, got %q", args, msg) + } + } +} diff --git a/backend/pkg/agentcli/guardrail/sanitize.go b/backend/pkg/agentcli/guardrail/sanitize.go new file mode 100644 index 0000000..9fe6089 --- /dev/null +++ b/backend/pkg/agentcli/guardrail/sanitize.go @@ -0,0 +1,108 @@ +// Package guardrail 提供 Agent CLI 输入输出治理能力。 // Logan.Li +package guardrail + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "regexp" + "strings" +) + +var sensitivePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(bearer\s+)[a-z0-9._~+/=-]{16,}`), + regexp.MustCompile(`(?i)((?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|secret[_-]?key)\s*[:=]\s*)[^\s'"&]+`), + regexp.MustCompile(`(?i)((?:mysql|postgres|postgresql|redis|mongodb)://[^:\s/@]+:)[^@\s]+(@)`), + regexp.MustCompile(`(?is)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`), + regexp.MustCompile(`(?i)((?:Cookie|Set-Cookie):\s*)[^\r\n]+`), + regexp.MustCompile(`(?i)(AKIA[0-9A-Z]{16})`), +} + +var sensitiveKeys = map[string]bool{ + "password": true, "passwd": true, "pwd": true, "secret": true, + "token": true, "api_key": true, "apikey": true, "access_key": true, + "secret_key": true, "private_key": true, "cookie": true, +} + +// SanitizeText 脱敏工具输出、LLM 文本和错误信息,避免敏感信息进入前端/DB/记忆。 // Logan.Li +func SanitizeText(input string) string { + if input == "" { + return "" + } + out := input + for _, re := range sensitivePatterns { + out = re.ReplaceAllStringFunc(out, func(match string) string { + if strings.Contains(match, "PRIVATE KEY") { + return "[REDACTED_PRIVATE_KEY]" + } + sub := re.FindStringSubmatch(match) + if len(sub) >= 3 { + return sub[1] + "[REDACTED]" + sub[2] + } + if len(sub) >= 2 { + return sub[1] + "[REDACTED]" + } + return "[REDACTED]" + }) + } + return out +} + +// SanitizeJSON 脱敏 JSON 文本;非法 JSON 时退回文本脱敏。 // Logan.Li +func SanitizeJSON(raw string) string { + if strings.TrimSpace(raw) == "" { + return raw + } + var v interface{} + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return SanitizeText(raw) + } + v = sanitizeValue(v) + b, err := json.Marshal(v) + if err != nil { + return SanitizeText(raw) + } + return string(b) +} + +func sanitizeValue(v interface{}) interface{} { + switch val := v.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(val)) + for k, item := range val { + if sensitiveKeys[strings.ToLower(strings.ReplaceAll(k, "-", "_"))] { + out[k] = "[REDACTED]" + continue + } + out[k] = sanitizeValue(item) + } + return out + case []interface{}: + for i := range val { + val[i] = sanitizeValue(val[i]) + } + return val + case string: + return SanitizeText(val) + default: + return val + } +} + +// NewTraceID 生成短 trace id,便于串联一次 Agent 任务。 // Logan.Li +func NewTraceID() string { + return "tr-" + randomHex(12) +} + +// NewSpanID 生成短 span id,便于串联工具和消息事件。 // Logan.Li +func NewSpanID() string { + return "sp-" + randomHex(8) +} + +func randomHex(n int) string { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "0000000000000000" + } + return hex.EncodeToString(buf) +} diff --git a/backend/pkg/agentcli/guardrail/sanitize_test.go b/backend/pkg/agentcli/guardrail/sanitize_test.go new file mode 100644 index 0000000..da3aaf6 --- /dev/null +++ b/backend/pkg/agentcli/guardrail/sanitize_test.go @@ -0,0 +1,31 @@ +package guardrail + +import ( + "strings" + "testing" +) + +func TestSanitizeTextMasksCommonSecrets(t *testing.T) { + input := "Authorization: Bearer abcdefghijklmnopqrstuvwxyz password=secret123 mysql://root:pass@db/prod" + got := SanitizeText(input) + for _, leak := range []string{"abcdefghijklmnopqrstuvwxyz", "secret123", "root:pass@db"} { + if strings.Contains(got, leak) { + t.Fatalf("敏感信息未脱敏: %s in %s", leak, got) + } + } + if !strings.Contains(got, "[REDACTED]") { + t.Fatalf("脱敏标记缺失: %s", got) + } +} + +func TestSanitizeJSONMasksSensitiveKeys(t *testing.T) { + got := SanitizeJSON(`{"command":"echo ok","password":"p1","nested":{"api_key":"k1"}}`) + for _, leak := range []string{"p1", "k1"} { + if strings.Contains(got, leak) { + t.Fatalf("JSON 敏感字段未脱敏: %s in %s", leak, got) + } + } + if !strings.Contains(got, `"command":"echo ok"`) { + t.Fatalf("非敏感字段不应被改写: %s", got) + } +} diff --git a/backend/pkg/agentcli/llm/anthropic.go b/backend/pkg/agentcli/llm/anthropic.go index 72c9bde..3bf2e91 100644 --- a/backend/pkg/agentcli/llm/anthropic.go +++ b/backend/pkg/agentcli/llm/anthropic.go @@ -1,7 +1,7 @@ // anthropic.go - Anthropic Messages 协议 LLM 客户端实现 // Logan.Li // 支持官方 Claude(api.anthropic.com)及智谱 GLM 编码套餐(open.bigmodel.cn/api/anthropic)等 // 所有 Anthropic /v1/messages 兼容端点。与 openai.go 实现同一 Client 接口,由 client.go 按 Protocol 路由。 -// 同时发送 x-api-key 与 Bearer,兼容 Anthropic 官方和国内编码套餐网关。 // Logan.Li +// 鉴权统一用 Authorization: Bearer(智谱编码套餐实测通过;官方 Claude 较新版本亦支持)+ anthropic-version。 package llm import ( @@ -167,10 +167,7 @@ func buildTools(tools []Tool) []anthropicTool { // isAnthropicThinkingModel 判断是否为支持 thinking 的 Claude 模型 // Logan.Li func isAnthropicThinkingModel(model string) bool { m := strings.ToLower(model) - return strings.Contains(m, "claude-3-7") || strings.Contains(m, "claude-3.7") || - strings.Contains(m, "claude-sonnet-4") || strings.Contains(m, "claude-opus-4") || - strings.Contains(m, "claude-sonnet-5") || strings.Contains(m, "claude-opus-5") || - strings.Contains(m, "claude-fable-5") + return strings.Contains(m, "claude-3-7") || strings.Contains(m, "claude-3.7") } // buildRequest 构造 Anthropic 请求体(Complete 与 Stream 共用) // Logan.Li @@ -212,7 +209,6 @@ func (c *AnthropicClient) newHTTPRequest(ctx context.Context, body []byte) (*htt } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) - httpReq.Header.Set("x-api-key", c.cfg.APIKey) httpReq.Header.Set("anthropic-version", "2023-06-01") return httpReq, nil } @@ -360,8 +356,8 @@ func (c *AnthropicClient) Stream(ctx context.Context, req *CompletionRequest, on } } return &CompletionResponse{ - Model: c.cfg.Model, + Model: c.cfg.Model, Message: Message{Role: "assistant", Content: contentBuilder.String()}, - Usage: usage, + Usage: usage, }, nil } diff --git a/backend/pkg/agentcli/llm/openai.go b/backend/pkg/agentcli/llm/openai.go index 3f79042..1757a22 100644 --- a/backend/pkg/agentcli/llm/openai.go +++ b/backend/pkg/agentcli/llm/openai.go @@ -49,14 +49,10 @@ func NewOpenAIClient(cfg Config) Client { // Name 返回客户端名称 // Logan.Li func (c *OpenAIClient) Name() string { return "openai-compatible(" + c.cfg.Model + ")" } -// isOpenAIReasoningModel 判断是否为使用新推理参数的 OpenAI 模型。 // Logan.Li +// isOpenAIReasoningModel 判断是否为需要 reasoning_effort 的 OpenAI o-series 模型 // Logan.Li func isOpenAIReasoningModel(model string) bool { m := strings.ToLower(model) - if index := strings.LastIndex(m, "/"); index >= 0 { - m = m[index+1:] - } - return strings.HasPrefix(m, "o1") || strings.HasPrefix(m, "o3") || - strings.HasPrefix(m, "o4") || strings.HasPrefix(m, "gpt-5") + return strings.Contains(m, "o1") || strings.Contains(m, "o3") } // isGenericReasoningModel 判断是否为通用推理开关模型(DeepSeek R1 等) // Logan.Li @@ -65,35 +61,33 @@ func isGenericReasoningModel(model string) bool { return strings.Contains(m, "reasoning") || strings.Contains(m, "r1") || strings.Contains(m, "deepseek-reasoner") } -// applyReasoningParams 根据模型类型应用 token 与 reasoning 参数。 // Logan.Li +// applyReasoningParams 根据模型类型应用 reasoning 参数 // Logan.Li func applyReasoningParams(payload *openaiRequest, enabled bool) { - if isOpenAIReasoningModel(payload.Model) { - // 新版 OpenAI 推理模型使用 max_completion_tokens 且不接收 temperature。 // Logan.Li - payload.MaxCompletionTokens = payload.MaxTokens - payload.MaxTokens = 0 - payload.Temperature = 0 - if enabled { - payload.ReasoningEffort = "medium" - } + if !enabled { return } - if enabled && isGenericReasoningModel(payload.Model) { + if isOpenAIReasoningModel(payload.Model) { + // OpenAI o-series 使用 reasoning_effort,temperature 必须固定为 1(不传) // Logan.Li + payload.ReasoningEffort = "medium" + payload.Temperature = 0 + return + } + if isGenericReasoningModel(payload.Model) { payload.Reasoning = true } } // openaiRequest OpenAI chat/completions 请求体 // Logan.Li type openaiRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - Tools []Tool `json:"tools,omitempty"` - ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定 - Temperature float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` // 新版 OpenAI 推理模型的输出上限 // Logan.Li - Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true) - ReasoningEffort string `json:"reasoning_effort,omitempty"` // OpenAI o-series 推理强度 // Logan.Li - Reasoning bool `json:"reasoning,omitempty"` // DeepSeek 等部分平台推理开关 // Logan.Li + Model string `json:"model"` + Messages []Message `json:"messages"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定 + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true) + ReasoningEffort string `json:"reasoning_effort,omitempty"` // OpenAI o-series 推理强度 // Logan.Li + Reasoning bool `json:"reasoning,omitempty"` // DeepSeek 等部分平台推理开关 // Logan.Li } // openaiResponse OpenAI chat/completions 响应体 // Logan.Li @@ -251,9 +245,9 @@ func (c *OpenAIClient) Stream(ctx context.Context, req *CompletionRequest, onDel } } return &CompletionResponse{ - Model: c.cfg.Model, + Model: c.cfg.Model, Message: Message{Role: "assistant", Content: contentBuilder.String()}, - Usage: usage, + Usage: usage, }, nil } diff --git a/backend/pkg/agentcli/llm/provider_compat_test.go b/backend/pkg/agentcli/llm/provider_compat_test.go deleted file mode 100644 index f69dc57..0000000 --- a/backend/pkg/agentcli/llm/provider_compat_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package llm - -import "testing" - -func TestApplyReasoningParamsForModernOpenAIModels(t *testing.T) { - payload := openaiRequest{ - Model: "openai/gpt-5.4", - Temperature: 0.7, - MaxTokens: 8192, - } - applyReasoningParams(&payload, true) - - if payload.Temperature != 0 || payload.MaxTokens != 0 { - t.Fatalf("unsupported parameters were not cleared: %+v", payload) - } - if payload.MaxCompletionTokens != 8192 || payload.ReasoningEffort != "medium" { - t.Fatalf("modern OpenAI parameters were not applied: %+v", payload) - } -} - -func TestApplyReasoningParamsWithoutReasoningEffort(t *testing.T) { - payload := openaiRequest{Model: "gpt-5.4-mini", Temperature: 0.7, MaxTokens: 4096} - applyReasoningParams(&payload, false) - - if payload.MaxCompletionTokens != 4096 || payload.ReasoningEffort != "" { - t.Fatalf("model compatibility parameters are incorrect: %+v", payload) - } -} - -func TestAnthropicThinkingModels(t *testing.T) { - for _, model := range []string{"claude-3-7-sonnet", "claude-sonnet-4-6", "claude-opus-4-8", "claude-sonnet-5", "claude-fable-5"} { - if !isAnthropicThinkingModel(model) { - t.Fatalf("%q should support thinking parameters", model) - } - } - if isAnthropicThinkingModel("claude-haiku-4-5") { - t.Fatal("Claude Haiku should not receive thinking parameters") - } -} diff --git a/backend/pkg/agentcli/prompt/prompt.go b/backend/pkg/agentcli/prompt/prompt.go index 5752e40..e5b5182 100644 --- a/backend/pkg/agentcli/prompt/prompt.go +++ b/backend/pkg/agentcli/prompt/prompt.go @@ -51,9 +51,10 @@ func BuildSystemPrompt(mode, targetInfo, toolDescs, evolveContext string) string sb.WriteString("1. 破坏性命令(rm -rf /、mkfs、dd 覆写磁盘等)被严格禁止,绝不执行。\n") sb.WriteString("2. 高危操作(删除 Pod、重启 Deployment、扩缩容、换镜像等)需要用户确认后才能执行。\n") if mode == runModeKubernetes { - sb.WriteString("3. 排障时优先使用只读工具:kubernetes_get 查资源状态、pod_logs 看日志;确认根因后再 pod_execute 或 kubernetes_action。\n") - sb.WriteString("4. 容器内可能没有 systemctl、宿主机路径;不要假设节点级命令可用。\n") - sb.WriteString("5. 未选定 Pod 时,用 kubernetes_get 和 pod_logs(带 pod 参数)排障;选定 Pod 后可直接 pod_execute。\n") + sb.WriteString("3. 排障时优先使用只读工具;涉及异常副本、集群健康、Pod 异常、ImagePullBackOff、CrashLoopBackOff、探针失败时,第一步必须调用 kubernetes_diagnose。\n") + sb.WriteString("4. kubernetes_diagnose 已聚合 Workload/Pod/Event/Node,不要重复拉全量 pods/deployments/events;只针对异常对象补查 pod_logs 或 kubernetes_get name。\n") + sb.WriteString("5. 容器内可能没有 systemctl、宿主机路径;不要假设节点级命令可用。\n") + sb.WriteString("6. 未选定 Pod 时,用 kubernetes_diagnose 和必要的 pod_logs(带 pod 参数)排障;选定 Pod 后可直接 pod_execute。\n") } else { sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。\n") sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n") @@ -70,7 +71,7 @@ func BuildSystemPrompt(mode, targetInfo, toolDescs, evolveContext string) string sb.WriteString("你采用「思考-行动-观察」循环解决任务:\n") sb.WriteString("1. 思考:分析当前状态,决定下一步。在回复中先用简短文字说明你的判断和计划。\n") if mode == runModeKubernetes { - sb.WriteString("2. 行动:优先 kubernetes_get / pod_logs;需要容器内信息时用 pod_execute;变更用 kubernetes_action(一次一步)。\n") + sb.WriteString("2. 行动:健康巡检/异常副本先用 kubernetes_diagnose;需要细节时对异常对象调用 kubernetes_get / pod_logs;需要容器内信息时用 pod_execute;变更用 kubernetes_action(一次一步)。\n") sb.WriteString("3. 观察:仔细阅读工具输出;pod_execute 输出含 [exit_code=0] 表示成功。\n") } else { sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n") @@ -98,4 +99,4 @@ func FormatToolDescriptions(items []struct{ Name, Desc string }) string { sb.WriteString(fmt.Sprintf("- %s: %s\n", it.Name, it.Desc)) } return sb.String() -} \ No newline at end of file +} diff --git a/backend/pkg/agentcli/tools/kubernetes_diagnose.go b/backend/pkg/agentcli/tools/kubernetes_diagnose.go new file mode 100644 index 0000000..9340e06 --- /dev/null +++ b/backend/pkg/agentcli/tools/kubernetes_diagnose.go @@ -0,0 +1,601 @@ +// kubernetes_diagnose.go - Kubernetes 异常副本与健康诊断聚合工具 // Logan.Li +package tools + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + k8sclient "ezops/pkg/kubernetesclient" +) + +const defaultDiagnoseLimit = 8 + +// KubernetesDiagnoseTool 聚合查询异常 Workload、Pod、Event 和节点健康,减少 LLM 反复拉全量资源。 // Logan.Li +type KubernetesDiagnoseTool struct { + client *k8sclient.Client + namespace string +} + +// NewKubernetesDiagnoseTool 创建 Kubernetes 聚合诊断工具。 // Logan.Li +func NewKubernetesDiagnoseTool(client *k8sclient.Client, namespace string) *KubernetesDiagnoseTool { + return &KubernetesDiagnoseTool{client: client, namespace: namespace} +} + +func (t *KubernetesDiagnoseTool) Name() string { return "kubernetes_diagnose" } + +func (t *KubernetesDiagnoseTool) Description() string { + return "一键诊断 Kubernetes 命名空间健康和异常副本(只读)。优先用于“异常副本、集群健康、Pod 异常、Deployment 不可用、ImagePullBackOff、CrashLoopBackOff”等任务。" + + "工具会聚合 Deployment/StatefulSet/DaemonSet/Pod/Event/Node,只返回异常摘要、可能根因和建议补充检查,避免反复查询全量资源。" +} + +func (t *KubernetesDiagnoseTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "namespace": map[string]interface{}{ + "type": "string", + "description": "命名空间;空则使用会话绑定命名空间", + }, + "focus": map[string]interface{}{ + "type": "string", + "description": "关注点:replicas|pods|events|image|crash|probe|all,默认 all", + }, + "limit": map[string]interface{}{ + "type": "integer", + "description": "每类最多返回数量,默认 8,最大 20", + }, + }, + } +} + +func (t *KubernetesDiagnoseTool) RiskLevel() RiskLevel { return RiskSafe } + +func (t *KubernetesDiagnoseTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) { + ns := strings.TrimSpace(StringParam(args, "namespace")) + if ns == "" { + ns = t.namespace + } + if ns == "" { + return &Result{Success: false, Error: "namespace 不能为空"}, nil + } + limit := IntParam(args, "limit") + if limit <= 0 { + limit = defaultDiagnoseLimit + } + if limit > 20 { + limit = 20 + } + focus := strings.ToLower(strings.TrimSpace(StringParam(args, "focus"))) + if focus == "" { + focus = "all" + } + + report, err := t.buildReport(ns, focus, limit) + if err != nil { + return &Result{Success: false, Error: err.Error(), Output: err.Error()}, nil + } + b, err := json.MarshalIndent(report, "", " ") + if err != nil { + return nil, fmt.Errorf("诊断结果 JSON 序列化失败: %w", err) + } + return &Result{Success: true, Output: string(b)}, nil +} + +type k8sDiagnoseReport struct { + Namespace string `json:"namespace"` + Focus string `json:"focus"` + Summary k8sDiagnoseSummary `json:"summary"` + AbnormalWorkloads []k8sWorkloadFinding `json:"abnormal_workloads"` + AbnormalPods []k8sPodFinding `json:"abnormal_pods"` + TopEvents []k8sEventFinding `json:"top_events"` + NodeHealth []k8sNodeFinding `json:"node_health,omitempty"` + LikelyRootCauses []string `json:"likely_root_causes"` + RecommendedActions []string `json:"recommended_actions"` + NextTools []map[string]interface{} `json:"next_tools"` +} + +type k8sDiagnoseSummary struct { + Deployments int `json:"deployments"` + StatefulSets int `json:"statefulsets"` + DaemonSets int `json:"daemonsets"` + Pods int `json:"pods"` + AbnormalWorkloads int `json:"abnormal_workloads"` + AbnormalPods int `json:"abnormal_pods"` + WarningEvents int `json:"warning_events"` + NotReadyNodes int `json:"not_ready_nodes"` +} + +type k8sWorkloadFinding struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Desired int `json:"desired"` + Ready int `json:"ready"` + Available int `json:"available,omitempty"` + Reason string `json:"reason"` + Image string `json:"image,omitempty"` +} + +type k8sPodFinding struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Status string `json:"status"` + Reason string `json:"reason"` + Restarts int `json:"restarts"` + Node string `json:"node"` + IP string `json:"ip,omitempty"` + Image string `json:"image,omitempty"` + Owner string `json:"owner,omitempty"` + Messages []string `json:"messages,omitempty"` +} + +type k8sEventFinding struct { + Type string `json:"type"` + Reason string `json:"reason"` + Object string `json:"object"` + Count int32 `json:"count"` + LastSeen string `json:"last_seen,omitempty"` + Message string `json:"message"` +} + +type k8sNodeFinding struct { + Name string `json:"name"` + Ready string `json:"ready"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` +} + +func (t *KubernetesDiagnoseTool) buildReport(ns, focus string, limit int) (*k8sDiagnoseReport, error) { + pods, err := t.client.ListPods() + if err != nil { + return nil, fmt.Errorf("获取 Pod 失败: %w", err) + } + deployments, err := t.client.ListDeployments() + if err != nil { + return nil, fmt.Errorf("获取 Deployment 失败: %w", err) + } + statefulsets, err := t.client.ListStatefulSets() + if err != nil { + return nil, fmt.Errorf("获取 StatefulSet 失败: %w", err) + } + daemonsets, err := t.client.ListDaemonSets() + if err != nil { + return nil, fmt.Errorf("获取 DaemonSet 失败: %w", err) + } + events, err := t.client.ListEvents(ns) + if err != nil { + return nil, fmt.Errorf("获取 Event 失败: %w", err) + } + nodes, _ := t.client.ListNodes() + + report := &k8sDiagnoseReport{Namespace: ns, Focus: focus} + report.AbnormalWorkloads = diagnoseWorkloads(ns, deployments.Items, statefulsets.Items, daemonsets.Items) + report.AbnormalPods = diagnosePods(ns, pods.Items, deployments.Items) + report.TopEvents = diagnoseEvents(events.Items, limit) + report.NodeHealth = diagnoseNodes(nodes) + report.Summary = k8sDiagnoseSummary{ + Deployments: countDeployments(ns, deployments.Items), StatefulSets: countStatefulSets(ns, statefulsets.Items), + DaemonSets: countDaemonSets(ns, daemonsets.Items), Pods: countPods(ns, pods.Items), + AbnormalWorkloads: len(report.AbnormalWorkloads), AbnormalPods: len(report.AbnormalPods), + WarningEvents: countWarningEvents(events.Items), NotReadyNodes: len(report.NodeHealth), + } + report.LikelyRootCauses = inferK8sRootCauses(report.AbnormalPods, report.TopEvents) + report.RecommendedActions = recommendK8sActions(report.AbnormalPods, report.AbnormalWorkloads) + report.NextTools = recommendK8sNextTools(report.AbnormalPods, report.TopEvents, limit) + report.AbnormalWorkloads = limitWorkloadFindings(report.AbnormalWorkloads, limit) + report.AbnormalPods = limitPodFindings(report.AbnormalPods, limit) + return report, nil +} + +func diagnoseWorkloads(ns string, deployments []k8sclient.DeploymentItem, statefulsets []k8sclient.StatefulSetItem, daemonsets []k8sclient.DaemonSetItem) []k8sWorkloadFinding { + var out []k8sWorkloadFinding + for _, d := range deployments { + if d.Metadata.Namespace != ns || d.Spec.Replicas == 0 { + continue + } + if d.Status.ReadyReplicas < d.Spec.Replicas || d.Status.AvailableReplicas < d.Spec.Replicas { + out = append(out, k8sWorkloadFinding{ + Kind: "Deployment", Namespace: d.Metadata.Namespace, Name: d.Metadata.Name, + Desired: d.Spec.Replicas, Ready: d.Status.ReadyReplicas, Available: d.Status.AvailableReplicas, + Reason: "ready/available replicas less than desired", Image: firstDeploymentImage(d), + }) + } + } + for _, s := range statefulsets { + if s.Metadata.Namespace != ns || s.Spec.Replicas == 0 { + continue + } + if s.Status.ReadyReplicas < s.Spec.Replicas { + out = append(out, k8sWorkloadFinding{ + Kind: "StatefulSet", Namespace: s.Metadata.Namespace, Name: s.Metadata.Name, + Desired: s.Spec.Replicas, Ready: s.Status.ReadyReplicas, + Reason: "ready replicas less than desired", Image: firstStatefulSetImage(s), + }) + } + } + for _, d := range daemonsets { + if d.Metadata.Namespace != ns || d.Status.DesiredNumberScheduled == 0 { + continue + } + if d.Status.NumberReady < d.Status.DesiredNumberScheduled { + out = append(out, k8sWorkloadFinding{ + Kind: "DaemonSet", Namespace: d.Metadata.Namespace, Name: d.Metadata.Name, + Desired: d.Status.DesiredNumberScheduled, Ready: d.Status.NumberReady, + Reason: "ready pods less than desired scheduled pods", Image: firstDaemonSetImage(d), + }) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Kind == out[j].Kind { + return out[i].Name < out[j].Name + } + return out[i].Kind < out[j].Kind + }) + return out +} + +func diagnosePods(ns string, pods []k8sclient.PodItem, deployments []k8sclient.DeploymentItem) []k8sPodFinding { + var out []k8sPodFinding + for _, p := range pods { + if p.Metadata.Namespace != ns { + continue + } + status, reason, restarts, messages := podDiagnosisStatus(p) + if !isAbnormalPod(status, reason, restarts) { + continue + } + out = append(out, k8sPodFinding{ + Namespace: p.Metadata.Namespace, Name: p.Metadata.Name, Status: status, Reason: reason, + Restarts: restarts, Node: p.Spec.NodeName, IP: p.Status.PodIP, Image: firstPodImage(p), + Owner: inferDeploymentOwner(p, deployments), Messages: messages, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Reason == out[j].Reason { + return out[i].Name < out[j].Name + } + return podReasonRank(out[i].Reason) < podReasonRank(out[j].Reason) + }) + return out +} + +func podDiagnosisStatus(p k8sclient.PodItem) (string, string, int, []string) { + status := p.Status.Phase + reason := "" + restarts := 0 + var messages []string + for _, cs := range p.Status.ContainerStatuses { + if cs.RestartCount > restarts { + restarts = cs.RestartCount + } + if cs.State.Waiting != nil && cs.State.Waiting.Reason != "" { + status = cs.State.Waiting.Reason + reason = cs.State.Waiting.Reason + if cs.State.Waiting.Message != "" { + messages = append(messages, truncateK8sText(cs.State.Waiting.Message, 220)) + } + } + if cs.LastState.Terminated != nil && cs.LastState.Terminated.Reason != "" { + if reason == "" { + reason = cs.LastState.Terminated.Reason + } + msg := fmt.Sprintf("last terminated: %s exit=%d", cs.LastState.Terminated.Reason, cs.LastState.Terminated.ExitCode) + messages = append(messages, msg) + } + } + for _, c := range p.Status.Conditions { + if c.Type == "Ready" && c.Status != "True" { + if reason == "" && c.Reason != "" { + reason = c.Reason + } + if c.Message != "" { + messages = append(messages, truncateK8sText(c.Message, 220)) + } + } + } + if reason == "" { + reason = status + } + return status, reason, restarts, dedupeStrings(messages) +} + +func diagnoseEvents(events []k8sclient.EventItem, limit int) []k8sEventFinding { + sort.Slice(events, func(i, j int) bool { + return eventTime(events[i]).After(eventTime(events[j])) + }) + var out []k8sEventFinding + seen := make(map[string]bool) + for _, e := range events { + if e.Type != "Warning" && !isImportantEventReason(e.Reason) { + continue + } + key := fmt.Sprintf("%s/%s/%s/%s", e.InvolvedObject.Kind, e.InvolvedObject.Name, e.Reason, e.Message) + if seen[key] { + continue + } + seen[key] = true + out = append(out, k8sEventFinding{ + Type: e.Type, Reason: e.Reason, + Object: fmt.Sprintf("%s/%s", e.InvolvedObject.Kind, e.InvolvedObject.Name), + Count: e.Count, LastSeen: eventTime(e).Format(time.RFC3339), + Message: truncateK8sText(e.Message, 260), + }) + if len(out) >= limit { + break + } + } + return out +} + +func diagnoseNodes(nodes *k8sclient.NodeList) []k8sNodeFinding { + if nodes == nil { + return nil + } + var out []k8sNodeFinding + for _, n := range nodes.Items { + for _, c := range n.Status.Conditions { + if c.Type == "Ready" && c.Status != "True" { + out = append(out, k8sNodeFinding{Name: n.Metadata.Name, Ready: c.Status, Reason: c.Reason, Message: truncateK8sText(c.Message, 220)}) + break + } + } + } + return out +} + +func inferK8sRootCauses(pods []k8sPodFinding, events []k8sEventFinding) []string { + flags := make(map[string]bool) + for _, p := range pods { + r := strings.ToLower(p.Reason + " " + p.Status) + switch { + case strings.Contains(r, "imagepull") || strings.Contains(r, "errimagepull"): + flags["存在镜像拉取失败,优先检查镜像 tag、Harbor 仓库、imagePullSecrets 和节点到仓库网络。"] = true + case strings.Contains(r, "crashloop"): + flags["存在容器反复崩溃,优先查看 previous logs、启动参数、依赖连接和最近变更。"] = true + case strings.Contains(r, "oom"): + flags["存在 OOMKilled 风险,优先检查内存 limit、最近内存趋势和应用内存泄漏。"] = true + case strings.Contains(r, "containersnotready") || strings.Contains(r, "unready"): + flags["存在容器未就绪,需结合 readiness/liveness probe、事件和应用日志确认。"] = true + case strings.Contains(r, "pending") || strings.Contains(r, "failedscheduling"): + flags["存在调度失败或 Pending,优先检查节点资源、污点、亲和性和 PVC。"] = true + } + } + for _, e := range events { + msg := strings.ToLower(e.Reason + " " + e.Message) + if strings.Contains(msg, "liveness probe") || strings.Contains(msg, "readiness probe") { + flags["事件显示健康探针失败,需检查探针路径、超时时间、应用响应耗时和依赖服务。"] = true + } + } + return sortedMapKeys(flags) +} + +func recommendK8sActions(pods []k8sPodFinding, workloads []k8sWorkloadFinding) []string { + var out []string + if len(workloads) > 0 { + out = append(out, "先处理 ready/available 副本不足的 Workload,再回查副本是否恢复。") + } + if hasPodReason(pods, "ImagePullBackOff") || hasPodReason(pods, "ErrImagePull") { + out = append(out, "对镜像拉取失败的 Pod,确认镜像 tag 是否存在、仓库权限是否有效、节点能否访问镜像仓库。") + } + if hasPodReason(pods, "CrashLoopBackOff") { + out = append(out, "对 CrashLoopBackOff 的 Pod,补查 previous logs 和启动命令,避免只看当前空日志。") + } + if hasPodReason(pods, "ContainersNotReady") { + out = append(out, "对未就绪 Pod,查看事件中的 readiness/liveness probe 失败原因和应用健康端点耗时。") + } + if len(out) == 0 { + out = append(out, "当前聚合诊断未发现明显异常;如仍有业务问题,继续按服务名查看日志、Ingress、Service selector 和指标。") + } + return out +} + +func recommendK8sNextTools(pods []k8sPodFinding, events []k8sEventFinding, limit int) []map[string]interface{} { + var out []map[string]interface{} + for _, p := range pods { + if len(out) >= limit { + break + } + if strings.Contains(strings.ToLower(p.Reason), "imagepull") { + out = append(out, map[string]interface{}{"tool": "kubernetes_get", "args": map[string]interface{}{"resource": "events", "name": p.Name}}) + continue + } + if p.Status == "CrashLoopBackOff" || p.Restarts >= 5 || strings.Contains(strings.ToLower(p.Reason), "notready") { + out = append(out, map[string]interface{}{"tool": "pod_logs", "args": map[string]interface{}{"namespace": p.Namespace, "pod": p.Name, "tail": 120}}) + } + } + if len(out) == 0 && len(events) > 0 { + out = append(out, map[string]interface{}{"tool": "kubernetes_get", "args": map[string]interface{}{"resource": "events"}}) + } + return out +} + +func isAbnormalPod(status, reason string, restarts int) bool { + s := strings.ToLower(status + " " + reason) + if status != "Running" && status != "Succeeded" && status != "PodCompleted" && status != "Completed" { + return true + } + if strings.Contains(s, "notready") || strings.Contains(s, "crashloop") || strings.Contains(s, "imagepull") || strings.Contains(s, "oom") { + return true + } + return restarts >= 10 +} + +func podReasonRank(reason string) int { + r := strings.ToLower(reason) + switch { + case strings.Contains(r, "imagepull"): + return 1 + case strings.Contains(r, "crashloop"): + return 2 + case strings.Contains(r, "oom"): + return 3 + case strings.Contains(r, "notready"): + return 4 + default: + return 9 + } +} + +func inferDeploymentOwner(p k8sclient.PodItem, deployments []k8sclient.DeploymentItem) string { + for _, d := range deployments { + if d.Metadata.Namespace != p.Metadata.Namespace || d.Spec.Selector == nil { + continue + } + if labelsMatch(p.Metadata.Labels, d.Spec.Selector.MatchLabels) { + return "Deployment/" + d.Metadata.Name + } + } + return "" +} + +func firstDeploymentImage(d k8sclient.DeploymentItem) string { + if len(d.Spec.Template.Spec.Containers) == 0 { + return "" + } + return d.Spec.Template.Spec.Containers[0].Image +} + +func firstStatefulSetImage(s k8sclient.StatefulSetItem) string { + if len(s.Spec.Template.Spec.Containers) == 0 { + return "" + } + return s.Spec.Template.Spec.Containers[0].Image +} + +func firstDaemonSetImage(d k8sclient.DaemonSetItem) string { + if len(d.Spec.Template.Spec.Containers) == 0 { + return "" + } + return d.Spec.Template.Spec.Containers[0].Image +} + +func firstPodImage(p k8sclient.PodItem) string { + if len(p.Spec.Containers) == 0 { + return "" + } + return p.Spec.Containers[0].Image +} + +func eventTime(e k8sclient.EventItem) time.Time { + if !e.LastTimestamp.IsZero() { + return e.LastTimestamp + } + if !e.EventTime.IsZero() { + return e.EventTime + } + return e.FirstTimestamp +} + +func isImportantEventReason(reason string) bool { + r := strings.ToLower(reason) + return strings.Contains(r, "failed") || strings.Contains(r, "backoff") || strings.Contains(r, "unhealthy") +} + +func countDeployments(ns string, items []k8sclient.DeploymentItem) int { + n := 0 + for _, it := range items { + if it.Metadata.Namespace == ns { + n++ + } + } + return n +} + +func countStatefulSets(ns string, items []k8sclient.StatefulSetItem) int { + n := 0 + for _, it := range items { + if it.Metadata.Namespace == ns { + n++ + } + } + return n +} + +func countDaemonSets(ns string, items []k8sclient.DaemonSetItem) int { + n := 0 + for _, it := range items { + if it.Metadata.Namespace == ns { + n++ + } + } + return n +} + +func countPods(ns string, items []k8sclient.PodItem) int { + n := 0 + for _, it := range items { + if it.Metadata.Namespace == ns { + n++ + } + } + return n +} + +func countWarningEvents(items []k8sclient.EventItem) int { + n := 0 + for _, it := range items { + if it.Type == "Warning" { + n++ + } + } + return n +} + +func hasPodReason(pods []k8sPodFinding, reason string) bool { + needle := strings.ToLower(reason) + for _, p := range pods { + if strings.Contains(strings.ToLower(p.Reason+" "+p.Status), needle) { + return true + } + } + return false +} + +func limitWorkloadFindings(items []k8sWorkloadFinding, limit int) []k8sWorkloadFinding { + if len(items) <= limit { + return items + } + return items[:limit] +} + +func limitPodFindings(items []k8sPodFinding, limit int) []k8sPodFinding { + if len(items) <= limit { + return items + } + return items[:limit] +} + +func truncateK8sText(s string, max int) string { + s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) + if len([]rune(s)) <= max { + return s + } + r := []rune(s) + return string(r[:max]) + "..." +} + +func dedupeStrings(items []string) []string { + seen := make(map[string]bool) + var out []string + for _, item := range items { + if item == "" || seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + return out +} + +func sortedMapKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/backend/pkg/agentcli/tools/kubernetes_diagnose_test.go b/backend/pkg/agentcli/tools/kubernetes_diagnose_test.go new file mode 100644 index 0000000..92f0435 --- /dev/null +++ b/backend/pkg/agentcli/tools/kubernetes_diagnose_test.go @@ -0,0 +1,64 @@ +package tools + +import ( + "testing" + + k8sclient "ezops/pkg/kubernetesclient" +) + +func TestPodDiagnosisStatusDetectsWaitingReason(t *testing.T) { + pod := k8sclient.PodItem{ + Status: k8sclient.PodStatus{ + Phase: "Pending", + ContainerStatuses: []k8sclient.ContainerStatus{{ + Name: "app", + State: k8sclient.ContainerStatusState{Waiting: &k8sclient.ContainerStateWaiting{ + Reason: "ImagePullBackOff", Message: "Back-off pulling image", + }}, + }}, + }, + } + status, reason, restarts, messages := podDiagnosisStatus(pod) + if status != "ImagePullBackOff" || reason != "ImagePullBackOff" { + t.Fatalf("unexpected status=%s reason=%s", status, reason) + } + if restarts != 0 { + t.Fatalf("unexpected restarts=%d", restarts) + } + if len(messages) != 1 { + t.Fatalf("expected waiting message, got %v", messages) + } + if !isAbnormalPod(status, reason, restarts) { + t.Fatal("ImagePullBackOff should be abnormal") + } +} + +func TestPodDiagnosisStatusDetectsOOMKilled(t *testing.T) { + pod := k8sclient.PodItem{ + Status: k8sclient.PodStatus{ + Phase: "Running", + ContainerStatuses: []k8sclient.ContainerStatus{{ + Name: "app", Ready: true, RestartCount: 3, + LastState: k8sclient.ContainerStatusState{Terminated: &k8sclient.ContainerStateTerminated{ + Reason: "OOMKilled", ExitCode: 137, + }}, + }}, + }, + } + status, reason, restarts, _ := podDiagnosisStatus(pod) + if status != "Running" || reason != "OOMKilled" || restarts != 3 { + t.Fatalf("unexpected status=%s reason=%s restarts=%d", status, reason, restarts) + } + if !isAbnormalPod(status, reason, restarts) { + t.Fatal("OOMKilled should be abnormal") + } +} + +func TestIsAbnormalPodByRestartThreshold(t *testing.T) { + if !isAbnormalPod("Running", "Running", 10) { + t.Fatal("high restart pod should be abnormal") + } + if isAbnormalPod("Running", "Running", 2) { + t.Fatal("healthy running pod should not be abnormal") + } +} diff --git a/backend/pkg/aiopscontext/service.go b/backend/pkg/aiopscontext/service.go index 3343a8c..fb0822f 100644 --- a/backend/pkg/aiopscontext/service.go +++ b/backend/pkg/aiopscontext/service.go @@ -144,7 +144,8 @@ func (s *Service) MetricSummary(ctx context.Context, target, metric, timeRange s } since := sinceByRange(timeRange, time.Hour) var reports []model.AgentReport - if err := s.db.WithContext(ctx).Where("hostname = ? AND report_time >= ?", hostname, since). + // Agent 指标时间统一为 UTC,确保 SQLite 按时间文本比较能命中数据。 // Logan.Li + if err := s.db.WithContext(ctx).Where("hostname = ? AND report_time >= ?", hostname, since.UTC()). Order("report_time ASC").Limit(limit).Find(&reports).Error; err != nil { return nil, err } diff --git a/backend/pkg/billingsync/sync.go b/backend/pkg/billingsync/sync.go index 313c67e..841071f 100644 --- a/backend/pkg/billingsync/sync.go +++ b/backend/pkg/billingsync/sync.go @@ -3,6 +3,7 @@ package billingsync import ( "fmt" + "strings" "time" "ezops/cloud" @@ -23,16 +24,7 @@ type Result struct { // Yesterday 昨日日期 YYYY-MM-DD func Yesterday() string { - return billingYesterdayAt(time.Now()) -} - -// billingYesterdayAt 按平台业务时区计算昨日,避免服务器 UTC 时区造成日期偏差。 // Logan.Li -func billingYesterdayAt(now time.Time) string { - location, err := time.LoadLocation("Asia/Shanghai") - if err != nil { - location = time.FixedZone("CST", 8*60*60) - } - return now.In(location).AddDate(0, 0, -1).Format("2006-01-02") + return time.Now().AddDate(0, 0, -1).Format("2006-01-02") } // SyncDate 同步指定日期账单(全部或单个活跃云账号) @@ -47,7 +39,7 @@ func SyncDate(db *gorm.DB, billingDate string, accountID *uint) Result { if accountID != nil { q = q.Where("id = ?", *accountID) } else { - // 批量同步只选择已接入账单 API 的厂商,避免无效失败。 // Logan.Li + // 批量同步仅处理已接入账单 API 的厂商,避免不支持厂商造成无效失败。 // Logan.Li q = q.Where("provider IN ?", cloud.SupportedBillingProviders) } q.Find(&accounts) @@ -95,15 +87,8 @@ func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (c accountID := account.ID accountName := account.Name - billingCurrency := records[0].Currency - if billingCurrency == "" { - billingCurrency = "CNY" - } for _, rec := range records { - if rec.Currency == "" { - rec.Currency = billingCurrency - } var existing model.CloudBilling findErr := db.Where( "cloud_account_id = ? AND billing_date = ? AND provider = ? AND service = ? AND region = ?", @@ -125,6 +110,7 @@ func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (c if row.Currency == "" { row.Currency = "CNY" } + row.Currency = strings.ToUpper(row.Currency) if db.Create(&row).Error == nil { created++ } else { @@ -137,9 +123,13 @@ func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (c continue } + currency := strings.ToUpper(rec.Currency) + if currency == "" { + currency = "CNY" + } if db.Model(&existing).Updates(map[string]interface{}{ "cost": rec.Cost, - "currency": rec.Currency, + "currency": currency, "description": rec.Description, "account_name": accountName, }).Error == nil { @@ -152,12 +142,9 @@ func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (c monthStart := billingDate[:7] + "-01" var monthCost float64 db.Model(&model.CloudBilling{}). - Where("cloud_account_id = ? AND billing_date >= ? AND currency = ?", accountID, monthStart, billingCurrency). + Where("cloud_account_id = ? AND billing_date >= ?", accountID, monthStart). Select("COALESCE(SUM(cost), 0)").Scan(&monthCost) - db.Model(&account).Updates(map[string]interface{}{ - "monthly_cost": monthCost, - "billing_currency": billingCurrency, - }) + db.Model(&account).Update("monthly_cost", monthCost) return created, updated, skipped, nil } diff --git a/backend/pkg/billingsync/sync_test.go b/backend/pkg/billingsync/sync_test.go deleted file mode 100644 index 6da20ab..0000000 --- a/backend/pkg/billingsync/sync_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package billingsync - -import ( - "testing" - "time" -) - -// TestBillingYesterdayAt 验证 UTC 服务器按中国业务时区计算昨日。 // Logan.Li -func TestBillingYesterdayAt(t *testing.T) { - now := time.Date(2026, 7, 28, 21, 30, 0, 0, time.UTC) - if got := billingYesterdayAt(now); got != "2026-07-28" { - t.Fatalf("昨日日期错误: got=%s want=2026-07-28", got) - } -} diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 5550580..d0b7888 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -2,7 +2,6 @@ package database import ( "fmt" - "log" "strings" "time" @@ -70,10 +69,9 @@ func New(cfg config.DatabaseConfig) (*gorm.DB, error) { return db, nil } -// AutoMigrate 自动迁移数据库表 -// 逐表迁移:单张表的迁移错误(如索引名冲突)不会阻塞其他表, -// 避免 GORM + MySQL 8.0 的 "Can't DROP" 索引问题导致整个启动失败 -func AutoMigrate(db *gorm.DB) error { +// BootstrapSchema 为空数据库创建初始基线结构。 +// 该函数只能由显式的 `aiops migrate up` 调用,服务启动路径禁止调用。 // Logan.Li +func BootstrapSchema(db *gorm.DB) error { allModels := []interface{}{ &model.User{}, &model.Role{}, @@ -96,9 +94,9 @@ func AutoMigrate(db *gorm.DB) error { &model.AlertNotifyPolicy{}, // 告警集合通知策略 // Logan.Li &model.AlertNotifySettings{}, // 告警消息模版 // Logan.Li &model.NotifyChannel{}, - &model.MetricData{}, &model.Pipeline{}, &model.PipelineRun{}, + &model.JenkinsConfig{}, &model.BuildNode{}, &model.AppRelease{}, &model.CronJob{}, @@ -116,16 +114,16 @@ func AutoMigrate(db *gorm.DB) error { &model.LDAPConfig{}, &model.ResourceModel{}, &model.CloudBilling{}, + &model.BillingSettings{}, // 云账单换算设置 // Logan.Li &model.BillingAlertRule{}, &model.AgentReport{}, &model.AgentTask{}, &model.AgentRule{}, &model.AgentAlert{}, + &model.AgentPortPolicy{}, // Agent 端口策略 // Logan.Li &model.AnsiblePlaybook{}, &model.AnsibleJob{}, &model.AnsibleInventory{}, - &model.LogSource{}, - &model.LogEntry{}, &model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li &model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li &model.RDPSession{}, // RDP 图形会话审计 // Logan.Li @@ -136,7 +134,6 @@ func AutoMigrate(db *gorm.DB) error { &model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li &model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li &model.MonitorMetric{}, // 监控指标定义 // Logan.Li - &model.MetricValue{}, // 指标当前值 // Logan.Li &model.MetricHistory{}, // 指标历史时序 // Logan.Li &model.HostGroup{}, // 主机组 // Logan.Li &model.SNMPDevice{}, // SNMP设备 // Logan.Li @@ -152,8 +149,6 @@ func AutoMigrate(db *gorm.DB) error { &model.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li &model.LogDataSource{}, // Loki / ES 日志数据源 // Logan.Li &model.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li - &model.PipelineRepo{}, // Git 仓库认证 // Logan.Li - &model.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li &model.DeployEnvironment{}, // 发布环境分组 // Logan.Li &model.DeployPipelineMember{}, // 流水线成员 // Logan.Li &model.DeployUserGroup{}, // 用户组 // Logan.Li @@ -172,94 +167,196 @@ func AutoMigrate(db *gorm.DB) error { &model.HealthProbe{}, // 健康探针配置 // Logan.Li &model.ApprovalWorkflow{}, // 审批流程定义 // Logan.Li &model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li - &model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li - &model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li - &model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li + &model.AIRun{}, // AI 任务统一运行记录 // Logan.Li + &model.AIRunMessage{}, // AI 任务回放消息 // Logan.Li + &model.AIRunSpan{}, // AI 工具/LLM/Guardrail Trace Span // Logan.Li + &model.AICapability{}, // AI 内置/MCP/技能能力注册 // Logan.Li + &model.AICapabilityPolicy{}, // AI 工具能力策略 // Logan.Li + &model.OpsSession{}, // 人机统一执行会话 // Logan.Li + &model.OpsCommand{}, // 人机统一命令审计 // Logan.Li &model.TraceDataSource{}, // 链路追踪数据源 Jaeger/Tempo // Logan.Li } - var lastErr error for _, m := range allModels { if err := db.AutoMigrate(m); err != nil { - // 记录但不阻塞 — 表和大多数索引已正常创建, - // 个别索引 DROP 冲突不影响核心功能 - lastErr = err + return fmt.Errorf("创建初始基线表失败 (%T): %w", m, err) } } - // 全部跑完后返回 nil:单张表的索引错误不应阻断服务启动 - _ = lastErr - applySchemaPatches(db) return nil } -// applySchemaPatches 补全 AutoMigrate 可能遗漏的列(MySQL 索引冲突时 User 表不会自动加列)// Logan.Li -func applySchemaPatches(db *gorm.DB) { +// ApplyLegacySchemaPatches 为历史数据库执行一次兼容性补丁。 +// 调用方必须通过版本化迁移显式执行,任一失败都会中止,避免隐式部分成功。 // Logan.Li +func ApplyLegacySchemaPatches(db *gorm.DB) error { m := db.Migrator() if !m.HasTable(&model.User{}) { - return + return fmt.Errorf("缺少 iam_users,不能执行历史结构补丁") } - patchUserColumn := func(field string, col string) { + patchUserColumn := func(field string, col string) error { if !m.HasColumn(&model.User{}, col) { if err := m.AddColumn(&model.User{}, field); err != nil { - log.Printf("[DB] 补列 %s 失败: %v", col, err) + return fmt.Errorf("补充 iam_users.%s 失败: %w", col, err) } } + return nil + } + for _, column := range [][2]string{ + {"ApiAccessKeyExpiresAt", "api_access_key_expires_at"}, + {"ApiAccessKeyID", "api_access_key_id"}, + {"ApiAccessKeyDescription", "api_access_key_description"}, + {"ApiAccessKeyDurationMonths", "api_access_key_duration_months"}, + {"ApiAccessKeyLastUsedAt", "api_access_key_last_used_at"}, + } { + if err := patchUserColumn(column[0], column[1]); err != nil { + return err + } } - patchUserColumn("ApiAccessKeyExpiresAt", "api_access_key_expires_at") - patchUserColumn("ApiAccessKeyID", "api_access_key_id") - patchUserColumn("ApiAccessKeyDescription", "api_access_key_description") - patchUserColumn("ApiAccessKeyDurationMonths", "api_access_key_duration_months") - patchUserColumn("ApiAccessKeyLastUsedAt", "api_access_key_last_used_at") // 补全日志数据源表可能缺失的列(旧表仅有基础字段)// Logan.Li - patchLogDSColumn := func(field string, col string) { + patchLogDSColumn := func(field string, col string) error { if !m.HasColumn(&model.LogDataSource{}, col) { if err := m.AddColumn(&model.LogDataSource{}, field); err != nil { - log.Printf("[DB] 补列 monitor_log_datasources.%s 失败: %v", col, err) + return fmt.Errorf("补充 monitor_log_datasources.%s 失败: %w", col, err) } } + return nil + } + for _, column := range [][2]string{{"AuthType", "auth_type"}, {"Token", "token"}, {"Headers", "headers"}} { + if err := patchLogDSColumn(column[0], column[1]); err != nil { + return err + } } - patchLogDSColumn("AuthType", "auth_type") - patchLogDSColumn("Token", "token") - patchLogDSColumn("Headers", "headers") // 日志权限关联表:AutoMigrate 在 MySQL 索引冲突时可能只创建策略表未创建用户关联表 // Logan.Li - ensureTable := func(tbl interface{}, name string) { + ensureTable := func(tbl interface{}, name string) error { if !m.HasTable(tbl) { if err := db.AutoMigrate(tbl); err != nil { - log.Printf("[DB] 补表 %s 失败: %v", name, err) + return fmt.Errorf("创建 %s 失败: %w", name, err) } } + return nil + } + if err := ensureTable(&model.LogScopeRole{}, "iam_log_scope_roles"); err != nil { + return err + } + if !m.HasTable("iam_user_log_scope_roles") { + if err := createUserLogScopeRolesTable(db); err != nil { + return err + } } - ensureTable(&model.LogScopeRole{}, "monitor_log_scope_roles") - ensureTable(&model.UserLogScopeRole{}, "monitor_user_log_scope_roles") // LDAP 目录类型列(openldap | ad) // Logan.Li if m.HasTable(&model.LDAPConfig{}) && !m.HasColumn(&model.LDAPConfig{}, "server_type") { if err := m.AddColumn(&model.LDAPConfig{}, "ServerType"); err != nil { - log.Printf("[DB] 补列 iam_ldap_configs.server_type 失败: %v", err) + return fmt.Errorf("补充 iam_ldap_configs.server_type 失败: %w", err) } } // 服务器 RDP/协议字段(旧库 AutoMigrate 可能未加列)// Logan.Li - patchServerColumn := func(field string, col string) { + patchServerColumn := func(field string, col string) error { if !m.HasColumn(&model.Server{}, col) { if err := m.AddColumn(&model.Server{}, field); err != nil { - log.Printf("[DB] 补列 asset_servers.%s 失败: %v", col, err) + return fmt.Errorf("补充 asset_servers.%s 失败: %w", col, err) } } + return nil + } + for _, column := range [][2]string{ + {"OSType", "os_type"}, {"Protocol", "protocol"}, {"RDPPort", "rdp_port"}, + {"Alias", "alias"}, {"CloudInstanceName", "cloud_instance_name"}, {"CloudInstanceID", "cloud_instance_id"}, + } { + if err := patchServerColumn(column[0], column[1]); err != nil { + return err + } } - patchServerColumn("OSType", "os_type") - patchServerColumn("Protocol", "protocol") - patchServerColumn("RDPPort", "rdp_port") // 将历史仅填 os=windows 的记录补齐协议字段 // Logan.Li if m.HasColumn(&model.Server{}, "os_type") && m.HasColumn(&model.Server{}, "protocol") { - _ = db.Exec(` + if err := db.Exec(` UPDATE asset_servers - SET os_type = 'windows', protocol = 'rdp', rdp_port = IF(rdp_port IS NULL OR rdp_port = 0, 3389, rdp_port) + SET os_type = 'windows', protocol = 'rdp', rdp_port = CASE WHEN rdp_port IS NULL OR rdp_port = 0 THEN 3389 ELSE rdp_port END WHERE (os_type IS NULL OR os_type = '' OR protocol IS NULL OR protocol = '') AND LOWER(os) LIKE '%windows%' - `).Error + `).Error; err != nil { + return fmt.Errorf("补全 Windows 服务器协议字段失败: %w", err) + } } + + // 兼容早期 GORM 将 CIProvider 误建为 c_iprovider 的流水线表。// Logan.Li + if m.HasTable(&model.Pipeline{}) { + if !m.HasColumn(&model.Pipeline{}, "ci_provider") { + if err := m.AddColumn(&model.Pipeline{}, "CIProvider"); err != nil { + return fmt.Errorf("补充 deploy_pipelines.ci_provider 失败: %w", err) + } + } + if m.HasColumn(&model.Pipeline{}, "ci_provider") && m.HasColumn(&model.Pipeline{}, "c_iprovider") { + if err := db.Exec(` + UPDATE deploy_pipelines + SET ci_provider = COALESCE(NULLIF(ci_provider, ''), NULLIF(c_iprovider, ''), 'aiops') + WHERE ci_provider IS NULL OR ci_provider = '' + `).Error; err != nil { + return fmt.Errorf("迁移 deploy_pipelines.ci_provider 失败: %w", err) + } + } + patchPipelineColumn := func(field string, col string) error { + if !m.HasColumn(&model.Pipeline{}, col) { + if err := m.AddColumn(&model.Pipeline{}, field); err != nil { + return fmt.Errorf("补充 deploy_pipelines.%s 失败: %w", col, err) + } + } + return nil + } + for _, column := range [][2]string{{"JenkinsView", "jenkins_view"}, {"JenkinsViewURL", "jenkins_view_url"}, {"JenkinsViews", "jenkins_views"}, {"JenkinsParams", "jenkins_params"}, {"JenkinsSteps", "jenkins_steps"}} { + if err := patchPipelineColumn(column[0], column[1]); err != nil { + return err + } + } + } + if m.HasTable(&model.JenkinsConfig{}) { + patchJenkinsColumn := func(field string, col string) error { + if !m.HasColumn(&model.JenkinsConfig{}, col) { + if err := m.AddColumn(&model.JenkinsConfig{}, field); err != nil { + return fmt.Errorf("补充 deploy_jenkins_configs.%s 失败: %w", col, err) + } + } + return nil + } + if err := patchJenkinsColumn("RefMode", "ref_mode"); err != nil { + return err + } + if err := patchJenkinsColumn("RefParam", "ref_param"); err != nil { + return err + } + if err := db.Exec("UPDATE deploy_jenkins_configs SET ref_mode = 'both' WHERE ref_mode IS NULL OR ref_mode = ''").Error; err != nil { + return fmt.Errorf("补全 deploy_jenkins_configs.ref_mode 失败: %w", err) + } + if err := db.Exec("UPDATE deploy_jenkins_configs SET ref_param = 'SELECT' WHERE ref_param IS NULL OR ref_param = ''").Error; err != nil { + return fmt.Errorf("补全 deploy_jenkins_configs.ref_param 失败: %w", err) + } + } + return nil +} + +func createUserLogScopeRolesTable(db *gorm.DB) error { + sql := `CREATE TABLE IF NOT EXISTS iam_user_log_scope_roles ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + log_scope_role_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NULL, + UNIQUE KEY idx_iam_user_log_scope_role (user_id, log_scope_role_id), + KEY idx_iam_user_log_scope_role_role (log_scope_role_id) + )` + if db.Dialector.Name() == "sqlite" { + sql = `CREATE TABLE IF NOT EXISTS iam_user_log_scope_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + log_scope_role_id INTEGER NOT NULL, + created_at DATETIME, + UNIQUE(user_id, log_scope_role_id) + )` + } + if err := db.Exec(sql).Error; err != nil { + return fmt.Errorf("创建 iam_user_log_scope_roles 失败: %w", err) + } + return nil } diff --git a/backend/pkg/database/dialect.go b/backend/pkg/database/dialect.go index e022072..7627a88 100644 --- a/backend/pkg/database/dialect.go +++ b/backend/pkg/database/dialect.go @@ -51,13 +51,13 @@ func CleanupSNMPMetricsSQL(driver string, retentionDays int) string { switch driver { case "sqlite": return fmt.Sprintf( - "DELETE FROM ops_snmp_metrics WHERE timestamp < CAST(strftime('%%s', 'now', '-%d days') AS INTEGER)", + "DELETE FROM monitor_snmp_samples WHERE timestamp < CAST(strftime('%%s', 'now', '-%d days') AS INTEGER)", retentionDays, ) default: return fmt.Sprintf( - "DELETE FROM ops_snmp_metrics WHERE timestamp < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL %d DAY))", + "DELETE FROM monitor_snmp_samples WHERE timestamp < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL %d DAY))", retentionDays, ) } -} \ No newline at end of file +} diff --git a/backend/pkg/kubernetesclient/client.go b/backend/pkg/kubernetesclient/client.go index 02f61b4..143be4d 100644 --- a/backend/pkg/kubernetesclient/client.go +++ b/backend/pkg/kubernetesclient/client.go @@ -203,16 +203,22 @@ func (c *Client) get(path string, result interface{}) error { // getRaw performs a GET request returning raw bytes with custom Accept header func (c *Client) getRaw(path string, accept string) ([]byte, error) { req, err := http.NewRequest("GET", c.BaseURL+path, nil) - if err != nil { return nil, err } + if err != nil { + return nil, err + } req.Header.Set("Accept", accept) if c.BearerToken != "" { req.Header.Set("Authorization", "Bearer "+c.BearerToken) } resp, err := c.HTTPClient.Do(req) - if err != nil { return nil, fmt.Errorf("GET %s: %w", path, err) } + if err != nil { + return nil, fmt.Errorf("GET %s: %w", path, err) + } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) - if err != nil { return nil, fmt.Errorf("read body: %w", err) } + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } if resp.StatusCode >= 400 { return nil, fmt.Errorf("K8s API error %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) } @@ -222,13 +228,17 @@ func (c *Client) getRaw(path string, accept string) ([]byte, error) { // putRaw performs a PUT request with raw body and custom Content-Type func (c *Client) putRaw(path string, contentType string, data []byte) error { req, err := http.NewRequest("PUT", c.BaseURL+path, strings.NewReader(string(data))) - if err != nil { return err } + if err != nil { + return err + } req.Header.Set("Content-Type", contentType) if c.BearerToken != "" { req.Header.Set("Authorization", "Bearer "+c.BearerToken) } resp, err := c.HTTPClient.Do(req) - if err != nil { return fmt.Errorf("PUT %s: %w", path, err) } + if err != nil { + return fmt.Errorf("PUT %s: %w", path, err) + } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) @@ -245,9 +255,9 @@ type NodeList struct { } type NodeItem struct { - Metadata ObjectMeta `json:"metadata"` - Status NodeStatus `json:"status"` - Spec NodeSpec `json:"spec"` + Metadata ObjectMeta `json:"metadata"` + Status NodeStatus `json:"status"` + Spec NodeSpec `json:"spec"` } type NodeSpec struct { @@ -263,11 +273,11 @@ type NodeTaint struct { } type NodeStatus struct { - Capacity ResourceList `json:"capacity"` - Allocatable ResourceList `json:"allocatable"` + Capacity ResourceList `json:"capacity"` + Allocatable ResourceList `json:"allocatable"` Conditions []NodeCondition `json:"conditions"` - NodeInfo NodeInfo `json:"nodeInfo"` - Addresses []NodeAddress `json:"addresses"` + NodeInfo NodeInfo `json:"nodeInfo"` + Addresses []NodeAddress `json:"addresses"` } type ResourceList map[string]string @@ -305,14 +315,14 @@ type PodItem struct { } type PodSpec struct { - NodeName string `json:"nodeName"` + NodeName string `json:"nodeName"` Containers []Container `json:"containers"` } type Container struct { - Name string `json:"name"` - Image string `json:"image"` - Resources ContainerRes `json:"resources"` + Name string `json:"name"` + Image string `json:"image"` + Resources ContainerRes `json:"resources"` } type ContainerRes struct { @@ -321,10 +331,10 @@ type ContainerRes struct { } type PodStatus struct { - Phase string `json:"phase"` - PodIP string `json:"podIP"` - ContainerStatuses []ContainerStatus `json:"containerStatuses"` - Conditions []PodCondition `json:"conditions,omitempty"` + Phase string `json:"phase"` + PodIP string `json:"podIP"` + ContainerStatuses []ContainerStatus `json:"containerStatuses"` + Conditions []PodCondition `json:"conditions,omitempty"` } // PodCondition for pod status conditions (Scheduled, Initialized, Ready etc) @@ -337,8 +347,30 @@ type PodCondition struct { } type ContainerStatus struct { - RestartCount int `json:"restartCount"` - Ready bool `json:"ready"` + Name string `json:"name"` + RestartCount int `json:"restartCount"` + Ready bool `json:"ready"` + State ContainerStatusState `json:"state,omitempty"` + LastState ContainerStatusState `json:"lastState,omitempty"` +} + +// ContainerStatusState 解析容器等待/终止状态,用于 AI 诊断 ImagePullBackOff、OOMKilled 等原因。 // Logan.Li +type ContainerStatusState struct { + Waiting *ContainerStateWaiting `json:"waiting,omitempty"` + Terminated *ContainerStateTerminated `json:"terminated,omitempty"` +} + +// ContainerStateWaiting 容器等待状态。 // Logan.Li +type ContainerStateWaiting struct { + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` +} + +// ContainerStateTerminated 容器终止状态。 // Logan.Li +type ContainerStateTerminated struct { + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + ExitCode int `json:"exitCode,omitempty"` } // DeploymentList response @@ -380,8 +412,8 @@ type ServiceList struct { } type ServiceItem struct { - Metadata ObjectMeta `json:"metadata"` - Spec ServiceSpec `json:"spec"` + Metadata ObjectMeta `json:"metadata"` + Spec ServiceSpec `json:"spec"` } type ServiceSpec struct { @@ -392,11 +424,11 @@ type ServiceSpec struct { } type ServicePort struct { - Name string `json:"name"` - Port int `json:"port"` + Name string `json:"name"` + Port int `json:"port"` TargetPort interface{} `json:"targetPort"` - NodePort int `json:"nodePort"` - Protocol string `json:"protocol"` + NodePort int `json:"nodePort"` + Protocol string `json:"protocol"` } // IngressList response @@ -405,13 +437,13 @@ type IngressList struct { } type IngressItem struct { - Metadata ObjectMeta `json:"metadata"` - Spec IngressSpec `json:"spec"` + Metadata ObjectMeta `json:"metadata"` + Spec IngressSpec `json:"spec"` } type IngressSpec struct { - TLS []IngressTLS `json:"tls"` - Rules []IngressRule `json:"rules"` + TLS []IngressTLS `json:"tls"` + Rules []IngressRule `json:"rules"` } type IngressTLS struct { @@ -419,7 +451,7 @@ type IngressTLS struct { } type IngressRule struct { - Host string `json:"host"` + Host string `json:"host"` HTTP *IngressHTTP `json:"http,omitempty"` } @@ -428,8 +460,8 @@ type IngressHTTP struct { } type IngressPath struct { - Path string `json:"path"` - PathType string `json:"pathType"` + Path string `json:"path"` + PathType string `json:"pathType"` Backend IngressBackend `json:"backend"` } @@ -438,7 +470,7 @@ type IngressBackend struct { } type IngressBackendService struct { - Name string `json:"name"` + Name string `json:"name"` Port IngressServicePort `json:"port"` } @@ -461,20 +493,20 @@ type IngressClassItem struct { // ObjectMeta type ObjectMeta struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Labels map[string]string `json:"labels"` - Annotations map[string]string `json:"annotations"` - CreationTimestamp string `json:"creationTimestamp"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` + CreationTimestamp string `json:"creationTimestamp"` } // SyncResult holds sync counts type SyncResult struct { - Nodes int `json:"nodes"` - Pods int `json:"pods"` - Deployments int `json:"deployments"` - Services int `json:"services"` - Ingresses int `json:"ingresses"` + Nodes int `json:"nodes"` + Pods int `json:"pods"` + Deployments int `json:"deployments"` + Services int `json:"services"` + Ingresses int `json:"ingresses"` Errors []string `json:"errors,omitempty"` // 各资源同步错误,便于排查 // Logan.Li } @@ -812,8 +844,8 @@ func (c *Client) ExecInPod(namespace, podName, container string, command []strin url.PathEscape(namespace), url.PathEscape(podName), params.Encode()) dialer := websocket.Dialer{ - TLSClientConfig: c.TLSConfig, - Subprotocols: []string{"v4.channel.k8s.io", "v3.channel.k8s.io", "channel.k8s.io"}, + TLSClientConfig: c.TLSConfig, + Subprotocols: []string{"v4.channel.k8s.io", "v3.channel.k8s.io", "channel.k8s.io"}, HandshakeTimeout: 10 * time.Second, } header := http.Header{} @@ -863,9 +895,9 @@ type HPAList struct { // HPAItem represents a HorizontalPodAutoscaler type HPAItem struct { - Metadata ObjectMeta `json:"metadata"` - Spec HPASpec `json:"spec"` - Status HPAStatus `json:"status"` + Metadata ObjectMeta `json:"metadata"` + Spec HPASpec `json:"spec"` + Status HPAStatus `json:"status"` } // HPASpec defines the HPA spec @@ -885,7 +917,7 @@ type ScaleTargetRef struct { // HPAMetric defines a metric for HPA type HPAMetric struct { - Type string `json:"type"` + Type string `json:"type"` Resource *struct { Name string `json:"name"` Target struct { @@ -914,12 +946,12 @@ func (c *Client) ListHPAs() (*HPAList, error) { type CreateHPARequest struct { Name string `json:"name"` Namespace string `json:"namespace"` - TargetKind string `json:"targetKind"` // Deployment, StatefulSet + TargetKind string `json:"targetKind"` // Deployment, StatefulSet TargetName string `json:"targetName"` MinReplicas int `json:"minReplicas"` MaxReplicas int `json:"maxReplicas"` - CPUPercent int `json:"cpuPercent"` // target average CPU utilization - MemPercent int `json:"memPercent"` // target average memory utilization (0 = disabled) + CPUPercent int `json:"cpuPercent"` // target average CPU utilization + MemPercent int `json:"memPercent"` // target average memory utilization (0 = disabled) } // CreateHPA creates a new HPA @@ -1004,9 +1036,9 @@ type CronJobList struct { // CronJobItem represents a CronJob type CronJobItem struct { - Metadata ObjectMeta `json:"metadata"` - Spec CronJobSpec `json:"spec"` - Status CronJobStatus `json:"status"` + Metadata ObjectMeta `json:"metadata"` + Spec CronJobSpec `json:"spec"` + Status CronJobStatus `json:"status"` } // CronJobSpec defines the CronJob spec @@ -1042,10 +1074,10 @@ func (c *Client) ListCronJobs() (*CronJobList, error) { // CreateCronJobRequest is the payload for creating a CronJob type CreateCronJobRequest struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Schedule string `json:"schedule"` // cron expression - Image string `json:"image"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Schedule string `json:"schedule"` // cron expression + Image string `json:"image"` Command []string `json:"command"` } @@ -1145,9 +1177,9 @@ func (c *Client) ListNamespaces() ([]string, error) { // NamespaceItem holds full namespace metadata type NamespaceItem struct { - Metadata ObjectMeta `json:"metadata"` - Spec NamespaceSpec `json:"spec"` - Status NamespaceStatus `json:"status"` + Metadata ObjectMeta `json:"metadata"` + Spec NamespaceSpec `json:"spec"` + Status NamespaceStatus `json:"status"` } // NamespaceSpec holds namespace spec @@ -1209,15 +1241,15 @@ func (c *Client) DeleteNamespace(name string) error { // ResourceQuotaItem represents a K8s ResourceQuota type ResourceQuotaItem struct { - Metadata ObjectMeta `json:"metadata"` - Spec ResourceQuotaSpec `json:"spec"` - Status ResourceQuotaStatus `json:"status"` + Metadata ObjectMeta `json:"metadata"` + Spec ResourceQuotaSpec `json:"spec"` + Status ResourceQuotaStatus `json:"status"` } // ResourceQuotaSpec holds hard limits type ResourceQuotaSpec struct { - Hard map[string]string `json:"hard"` - Scopes []string `json:"scopes,omitempty"` + Hard map[string]string `json:"hard"` + Scopes []string `json:"scopes,omitempty"` } // ResourceQuotaStatus holds used/hard counts @@ -1245,8 +1277,8 @@ func (c *Client) ListResourceQuotas(namespace string) ([]ResourceQuotaItem, erro // LimitRangeItem represents a K8s LimitRange type LimitRangeItem struct { - Metadata ObjectMeta `json:"metadata"` - Spec LimitRangeSpec `json:"spec"` + Metadata ObjectMeta `json:"metadata"` + Spec LimitRangeSpec `json:"spec"` } // LimitRangeSpec holds limit items @@ -1303,17 +1335,17 @@ type SecretList struct { // SecretItem represents a Secret (return keys only, not values) type SecretItem struct { - Metadata ObjectMeta `json:"metadata"` - Type string `json:"type"` - DataKeys []string `json:"-"` + Metadata ObjectMeta `json:"metadata"` + Type string `json:"type"` + DataKeys []string `json:"-"` } // ListSecrets fetches Secrets in a namespace (names+types only, no data) func (c *Client) ListSecrets(namespace string) (*SecretList, error) { var result struct { Items []struct { - Metadata ObjectMeta `json:"metadata"` - Type string `json:"type"` + Metadata ObjectMeta `json:"metadata"` + Type string `json:"type"` Data map[string]string `json:"data"` } `json:"items"` } @@ -1348,14 +1380,14 @@ type PVCItem struct { Metadata ObjectMeta `json:"metadata"` Spec PVCSpec `json:"spec"` Status struct { - Phase string `json:"phase"` - Capacity map[string]string `json:"capacity"` + Phase string `json:"phase"` + Capacity map[string]string `json:"capacity"` } `json:"status"` } type PVCSpec struct { - AccessModes []string `json:"accessModes"` - Resources struct { + AccessModes []string `json:"accessModes"` + Resources struct { Requests map[string]string `json:"requests"` } `json:"resources"` StorageClassName string `json:"storageClassName"` @@ -1386,16 +1418,16 @@ type StatefulSetList struct { } type StatefulSetItem struct { - Metadata ObjectMeta `json:"metadata"` - Spec StatefulSetSpec `json:"spec"` + Metadata ObjectMeta `json:"metadata"` + Spec StatefulSetSpec `json:"spec"` Status struct { - Replicas int `json:"replicas"` - ReadyReplicas int `json:"readyReplicas"` + Replicas int `json:"replicas"` + ReadyReplicas int `json:"readyReplicas"` } `json:"status"` } type StatefulSetSpec struct { - Replicas int `json:"replicas"` + Replicas int `json:"replicas"` Template struct { Spec struct { Containers []struct { @@ -1424,8 +1456,8 @@ type DaemonSetList struct { } type DaemonSetItem struct { - Metadata ObjectMeta `json:"metadata"` - Spec DaemonSetSpec `json:"spec"` + Metadata ObjectMeta `json:"metadata"` + Spec DaemonSetSpec `json:"spec"` Status struct { DesiredNumberScheduled int `json:"desiredNumberScheduled"` CurrentNumberScheduled int `json:"currentNumberScheduled"` @@ -1589,18 +1621,18 @@ type EventSource struct { } type EventItem struct { - Metadata ObjectMeta `json:"metadata"` - InvolvedObject InvolvedObject `json:"involvedObject"` - Type string `json:"type"` - Reason string `json:"reason"` - Message string `json:"message"` - Source EventSource `json:"source"` - ReportingComponent string `json:"reportingComponent"` - ReportingInstance string `json:"reportingInstance"` - Count int32 `json:"count"` - FirstTimestamp time.Time `json:"firstTimestamp"` - LastTimestamp time.Time `json:"lastTimestamp"` - EventTime time.Time `json:"eventTime"` + Metadata ObjectMeta `json:"metadata"` + InvolvedObject InvolvedObject `json:"involvedObject"` + Type string `json:"type"` + Reason string `json:"reason"` + Message string `json:"message"` + Source EventSource `json:"source"` + ReportingComponent string `json:"reportingComponent"` + ReportingInstance string `json:"reportingInstance"` + Count int32 `json:"count"` + FirstTimestamp time.Time `json:"firstTimestamp"` + LastTimestamp time.Time `json:"lastTimestamp"` + EventTime time.Time `json:"eventTime"` } type InvolvedObject struct { @@ -1673,7 +1705,7 @@ type RoleList struct { } type RoleItem struct { - Metadata ObjectMeta `json:"metadata"` + Metadata ObjectMeta `json:"metadata"` Rules []PolicyRule `json:"rules"` } @@ -1700,9 +1732,9 @@ type RoleBindingList struct { } type RoleBindingItem struct { - Metadata ObjectMeta `json:"metadata"` - RoleRef RoleRef `json:"roleRef"` - Subjects []Subject `json:"subjects"` + Metadata ObjectMeta `json:"metadata"` + RoleRef RoleRef `json:"roleRef"` + Subjects []Subject `json:"subjects"` } type RoleRef struct { @@ -1775,7 +1807,7 @@ func (c *Client) applyRaw(yamlContent string) (map[string]interface{}, error) { } // Try GET first — if 200, it exists, do PUT. If 404, do POST. - parentPath := apiPath[:strings.LastIndex(apiPath, "/")] // without /name suffix + parentPath := apiPath[:strings.LastIndex(apiPath, "/")] // without /name suffix _, getErr := c.getRaw(apiPath, "application/yaml") if getErr == nil { // Resource exists → PUT update @@ -1801,8 +1833,10 @@ func (c *Client) applyRaw(yamlContent string) (map[string]interface{}, error) { func (c *Client) resourceAPIPath(apiVersion, kind, namespace, name string) (string, bool) { resource := strings.ToLower(kind) + "s" switch kind { - case "Endpoints": resource = "endpoints" - case "PodMetrics": resource = "pods" + case "Endpoints": + resource = "endpoints" + case "PodMetrics": + resource = "pods" } var groupPrefix string @@ -1829,7 +1863,9 @@ func (c *Client) resourceAPIPath(apiVersion, kind, namespace, name string) (stri } ns := namespace - if ns == "" { ns = "default" } + if ns == "" { + ns = "default" + } if groupPrefix != "" { switch kind { @@ -1866,15 +1902,15 @@ type ReplicaSetList struct { // ReplicaSetItem 表示一个 ReplicaSet(含完整 spec/status/metadata,用于回滚取 template) type ReplicaSetItem struct { - Metadata ObjectMeta `json:"metadata"` - Spec ReplicaSetSpec `json:"spec"` - Status ReplicaSetStatus `json:"status"` + Metadata ObjectMeta `json:"metadata"` + Spec ReplicaSetSpec `json:"spec"` + Status ReplicaSetStatus `json:"status"` } // ReplicaSetSpec 与 DeploymentSpec 结构一致,关键是 template type ReplicaSetSpec struct { - Replicas int `json:"replicas"` - Template ReplicaSetTemplate `json:"template"` + Replicas int `json:"replicas"` + Template ReplicaSetTemplate `json:"template"` } // ReplicaSetTemplate pod 模板 diff --git a/backend/pkg/logaccess/access.go b/backend/pkg/logaccess/access.go index e1524eb..feffb1f 100644 --- a/backend/pkg/logaccess/access.go +++ b/backend/pkg/logaccess/access.go @@ -27,9 +27,9 @@ var ( // GetUserLogScopeRoles 获取用户已启用的日志权限策略 // Logan.Li func GetUserLogScopeRoles(db *gorm.DB, userID uint) ([]model.LogScopeRole, error) { var roles []model.LogScopeRole - err := db.Table("monitor_log_scope_roles"). - Joins("JOIN monitor_user_log_scope_roles ON monitor_user_log_scope_roles.log_scope_role_id = monitor_log_scope_roles.id"). - Where("monitor_user_log_scope_roles.user_id = ? AND monitor_log_scope_roles.status = 1", userID). + err := db.Table("iam_log_scope_roles"). + Joins("JOIN iam_user_log_scope_roles ON iam_user_log_scope_roles.log_scope_role_id = iam_log_scope_roles.id"). + Where("iam_user_log_scope_roles.user_id = ? AND iam_log_scope_roles.status = 1", userID). Find(&roles).Error return roles, err } diff --git a/backend/pkg/rdp/session_engine.go b/backend/pkg/rdp/session_engine.go index 0476095..cda934a 100644 --- a/backend/pkg/rdp/session_engine.go +++ b/backend/pkg/rdp/session_engine.go @@ -560,6 +560,7 @@ func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event Cl if event.Text == "" { return nil } + log.Printf("[RDP] clipboard local→remote len=%d cliprdr=%t", len(event.Text), clip != nil) if clip != nil { clip.SetLocalText(event.Text) } diff --git a/backend/pkg/retention/retention.go b/backend/pkg/retention/retention.go new file mode 100644 index 0000000..d9c5510 --- /dev/null +++ b/backend/pkg/retention/retention.go @@ -0,0 +1,127 @@ +// 高频数据保留与批量清理,避免 SQLite/MySQL 大表无限增长。 // Logan.Li +package retention + +import ( + "fmt" + "time" + + "ezops/config" + "ezops/model" + + "gorm.io/gorm" +) + +const defaultBatchSize = 10000 + +// legacyMetricsAggregate 兼容旧聚合表;新写入统一使用 monitor_metric_aggregates。 // Logan.Li +type legacyMetricsAggregate struct { + ID uint `gorm:"primaryKey"` + BucketTime time.Time `gorm:"column:bucket_time"` +} + +// TableName 固定旧聚合表名称,避免历史数据在迁移完成前无限增长。 // Logan.Li +func (legacyMetricsAggregate) TableName() string { + return "monitor_metrics_aggregate" +} + +// ItemResult 描述单张表的清理结果,供系统任务审计与告警使用。 // Logan.Li +type ItemResult struct { + Table string `json:"table"` + Retention int `json:"retention_days"` + RowsDeleted int64 `json:"rows_deleted"` + Skipped bool `json:"skipped,omitempty"` +} + +// Result 汇总一次全量高频数据清理的可审计结果。 // Logan.Li +type Result struct { + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + Items []ItemResult `json:"items"` + Total int64 `json:"total_rows_deleted"` +} + +// Run 仅清理已存在的高频表;每批先读取主键再删除,避免一次 DELETE 长事务。 // Logan.Li +func Run(db *gorm.DB, cfg config.RetentionConfig) (Result, error) { + result := Result{StartedAt: time.Now()} + batchSize := normalizeBatchSize(cfg.CleanupBatchSize) + + items := []struct { + model interface{} + table string + column string + retentionDays int + }{ + {&model.AgentReport{}, "monitor_agent_reports", "report_time", cfg.AgentReportsDays}, + {&model.MetricData{}, "monitor_metric_data", "timestamp", cfg.MetricHistoryDays}, + {&model.MetricHistory{}, "monitor_metric_histories", "timestamp", cfg.MetricHistoryDays}, + {&model.MetricsAggregate{}, "monitor_metric_aggregates", "bucket_time", cfg.MetricAggregateDays}, + {&legacyMetricsAggregate{}, "monitor_metrics_aggregate", "bucket_time", cfg.MetricAggregateDays}, + {&model.SNMPMetric{}, "monitor_snmp_samples", "timestamp", cfg.SNMPSamplesDays}, + {&model.SiteResult{}, "monitor_site_results", "checked_at", cfg.SiteResultsDays}, + {&model.LogEntry{}, "sys_log_entries", "timestamp", cfg.LogEntriesDays}, + } + + for _, item := range items { + retentionDays := normalizeRetentionDays(item.retentionDays) + entry := ItemResult{Table: item.table, Retention: retentionDays} + if !db.Migrator().HasTable(item.model) { + entry.Skipped = true + result.Items = append(result.Items, entry) + continue + } + + cutoff := time.Now().AddDate(0, 0, -retentionDays) + var err error + if item.table == "monitor_metric_histories" || item.table == "monitor_snmp_samples" { + err = deleteInBatches(db, item.model, item.column+" < ?", cutoff.Unix(), batchSize, &entry.RowsDeleted) + } else { + err = deleteInBatches(db, item.model, item.column+" < ?", cutoff, batchSize, &entry.RowsDeleted) + } + if err != nil { + return result, fmt.Errorf("清理 %s 失败: %w", item.table, err) + } + result.Total += entry.RowsDeleted + result.Items = append(result.Items, entry) + } + + result.FinishedAt = time.Now() + return result, nil +} + +// deleteInBatches 通过主键分页删除,兼容 SQLite 与 MySQL,失败时不会隐藏受影响行数。 // Logan.Li +func deleteInBatches(db *gorm.DB, target interface{}, condition string, value interface{}, batchSize int, total *int64) error { + for { + var ids []uint + if err := db.Model(target).Where(condition, value).Order("id ASC").Limit(batchSize).Pluck("id", &ids).Error; err != nil { + return err + } + if len(ids) == 0 { + return nil + } + deleted := db.Where("id IN ?", ids).Delete(target) + if deleted.Error != nil { + return deleted.Error + } + if deleted.RowsAffected != int64(len(ids)) { + return fmt.Errorf("批量删除行数不一致: 预期 %d,实际 %d", len(ids), deleted.RowsAffected) + } + *total += deleted.RowsAffected + } +} + +func normalizeRetentionDays(days int) int { + if days <= 0 { + return 30 + } + return days +} + +func normalizeBatchSize(size int) int { + if size <= 0 { + return defaultBatchSize + } + if size > defaultBatchSize { + return defaultBatchSize + } + return size +} diff --git a/backend/pkg/retention/retention_test.go b/backend/pkg/retention/retention_test.go new file mode 100644 index 0000000..693c321 --- /dev/null +++ b/backend/pkg/retention/retention_test.go @@ -0,0 +1,88 @@ +package retention + +import ( + "testing" + "time" + + "ezops/config" + "ezops/model" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +// TestRun 验证高频数据按保留期分批清理且不影响新数据。 // Logan.Li +func TestRun(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:retention?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.AgentReport{}); err != nil { + t.Fatal(err) + } + old := model.AgentReport{Hostname: "old", ReportTime: time.Now().AddDate(0, 0, -8)} + current := model.AgentReport{Hostname: "new", ReportTime: time.Now()} + if err := db.Create(&old).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(¤t).Error; err != nil { + t.Fatal(err) + } + + result, err := Run(db, config.RetentionConfig{AgentReportsDays: 7, CleanupBatchSize: 1}) + if err != nil { + t.Fatal(err) + } + if result.Total != 1 { + t.Fatalf("应清理 1 条记录,实际 %d", result.Total) + } + var count int64 + if err := db.Model(&model.AgentReport{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("应保留 1 条新记录,实际 %d", count) + } +} + +// TestRunCleansLegacyMetricTables 验证保留期覆盖尚未下线的旧时序表。 // Logan.Li +func TestRunCleansLegacyMetricTables(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:retention_legacy?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.MetricData{}, &legacyMetricsAggregate{}); err != nil { + t.Fatal(err) + } + if err := db.Create(&model.MetricData{Name: "old", Timestamp: time.Now().AddDate(0, 0, -31)}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.MetricData{Name: "current", Timestamp: time.Now()}).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO monitor_metrics_aggregate (bucket_time) VALUES (?), (?)", time.Now().AddDate(0, 0, -366), time.Now()).Error; err != nil { + t.Fatal(err) + } + + result, err := Run(db, config.RetentionConfig{MetricHistoryDays: 30, MetricAggregateDays: 365, CleanupBatchSize: 1}) + if err != nil { + t.Fatal(err) + } + if result.Total != 2 { + t.Fatalf("应清理两条旧指标,实际 %d", result.Total) + } + assertTableCount(t, db, "monitor_metric_data", 1) + assertTableCount(t, db, "monitor_metrics_aggregate", 1) +} + +// assertTableCount 断言时序表保留数据量。 // Logan.Li +func assertTableCount(t *testing.T, db *gorm.DB, table string, want int64) { + t.Helper() + var count int64 + if err := db.Table(table).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != want { + t.Fatalf("表 %s 记录数错误: got %d want %d", table, count, want) + } +} diff --git a/backend/pkg/systask/registry.go b/backend/pkg/systask/registry.go index cb59042..845a2e5 100644 --- a/backend/pkg/systask/registry.go +++ b/backend/pkg/systask/registry.go @@ -45,32 +45,27 @@ var ( { Code: "alert_engine", Name: "告警引擎扫描", Description: "扫描 Agent 指标阈值、主机离线、端口策略与日志告警规则,触发通知与自动恢复", - Schedule: "每 30 秒", Category: "monitor", Runnable: false, Enabled: true, + Schedule: "每 30 秒", Category: "monitor", Runnable: false, Enabled: true, }, { Code: "middleware_monitor", Name: "中间件监控采集", Description: "直连 Redis/MySQL 等中间件目标采集指标并写入监控库", - Schedule: "每 30 秒", Category: "monitor", Runnable: false, Enabled: true, + Schedule: "每 30 秒", Category: "monitor", Runnable: false, Enabled: true, }, { - Code: "agent_report_cleanup", Name: "Agent 上报清理", - Description: "清理 7 天前的 Agent 指标上报原始数据,防止表膨胀", - Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true, + Code: "retention_cleanup", Name: "高频数据保留清理", + Description: "按保留策略分批清理 Agent、指标、SNMP、站点拨测和日志数据,并记录受影响行数", + Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true, }, { Code: "audit_log_cleanup", Name: "审计日志清理", Description: "按系统设置中的保留天数清理操作审计、登录日志、终端会话与命令记录", - Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true, - }, - { - Code: "snmp_metrics_cleanup", Name: "SNMP 指标清理", - Description: "清理 7 天前的 SNMP 采集指标历史,防止表膨胀", - Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true, + Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true, }, { Code: "cloud_billing_sync", Name: "云账单同步", - Description: "从云账号 AK 拉取昨日账单(按产品+地域),写入云账单管理;当前支持阿里云", - Schedule: "每天 10:30", Category: "finops", Runnable: true, Enabled: true, + Description: "从云账号 AK 拉取昨日账单(按产品+地域),写入云账单管理;当前支持阿里云和腾讯云", // Logan.Li + Schedule: "每天 10:30", Category: "finops", Runnable: true, Enabled: true, }, } ) @@ -237,4 +232,4 @@ func findDef(code string) (Def, bool) { } } return Def{}, false -} \ No newline at end of file +} diff --git a/backend/repository/repo.go b/backend/repository/repo.go index 4fbbb9e..995e4d2 100644 --- a/backend/repository/repo.go +++ b/backend/repository/repo.go @@ -23,7 +23,7 @@ func (r *ServerRepository) DB() *gorm.DB { } // List 分页查询服务器列表 -func (r *ServerRepository) List(keyword, status, regionID, sortField, sortOrder string, page, pageSize int) ([]model.Server, int64, error) { +func (r *ServerRepository) List(keyword, status, regionID string, page, pageSize int) ([]model.Server, int64, error) { q := r.db.Model(&model.Server{}) if keyword != "" { q = q.Where("hostname LIKE ? OR ip LIKE ? OR public_ip LIKE ?", @@ -42,22 +42,11 @@ func (r *ServerRepository) List(keyword, status, regionID, sortField, sortOrder var list []model.Server err := q.Preload("Region").Preload("Cloud"). Offset((page - 1) * pageSize).Limit(pageSize). - Order(serverOrderClause(sortField, sortOrder)).Find(&list).Error + Order("created_at DESC").Find(&list).Error return list, total, err } -// serverOrderClause 返回服务器列表允许使用的排序表达式,避免直接拼接请求参数。 // Logan.Li -func serverOrderClause(sortField, sortOrder string) string { - if sortField != "hostname" { - return "created_at DESC" - } - if sortOrder == "desc" { - return "LOWER(hostname) DESC, id DESC" - } - return "LOWER(hostname) ASC, id ASC" -} - // GetByID 根据 ID 获取服务器 func (r *ServerRepository) GetByID(id uint) (*model.Server, error) { var s model.Server diff --git a/backend/repository/server_sort_test.go b/backend/repository/server_sort_test.go deleted file mode 100644 index 4fb57fb..0000000 --- a/backend/repository/server_sort_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package repository - -import ( - "testing" - - "ezops/model" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -// TestServerRepositoryListHostnameOrder 验证实际服务器仓储按主机名分页排序。 // Logan.Li -func TestServerRepositoryListHostnameOrder(t *testing.T) { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("打开测试数据库失败: %v", err) - } - if err := db.AutoMigrate(&model.Server{}, &model.Region{}, &model.Cloud{}); err != nil { - t.Fatalf("迁移测试表失败: %v", err) - } - for _, hostname := range []string{"test-win", "c-windows", "alpha-host"} { - if err := db.Create(&model.Server{Hostname: hostname, IP: "127.0.0.1"}).Error; err != nil { - t.Fatalf("创建测试服务器失败: %v", err) - } - } - - repo := NewServerRepository(db) - servers, total, err := repo.List("", "", "", "hostname", "asc", 1, 10) - if err != nil { - t.Fatalf("查询测试服务器失败: %v", err) - } - if total != 3 || len(servers) != 3 { - t.Fatalf("服务器数量异常 total=%d len=%d", total, len(servers)) - } - if servers[0].Hostname != "alpha-host" || servers[2].Hostname != "test-win" { - t.Fatalf("主机名升序结果异常: %q, %q, %q", servers[0].Hostname, servers[1].Hostname, servers[2].Hostname) - } -} - -// TestServerOrderClause 验证非法排序参数无法进入 SQL 表达式。 // Logan.Li -func TestServerOrderClause(t *testing.T) { - tests := []struct { - sortField string - sortOrder string - want string - }{ - {want: "created_at DESC"}, - {sortField: "hostname", sortOrder: "asc", want: "LOWER(hostname) ASC, id ASC"}, - {sortField: "hostname", sortOrder: "desc", want: "LOWER(hostname) DESC, id DESC"}, - {sortField: "hostname; DROP TABLE asset_servers", sortOrder: "asc", want: "created_at DESC"}, - } - for _, tt := range tests { - if got := serverOrderClause(tt.sortField, tt.sortOrder); got != tt.want { - t.Fatalf("serverOrderClause(%q, %q) = %q, want %q", tt.sortField, tt.sortOrder, got, tt.want) - } - } -} diff --git a/backend/router/agent_static.go b/backend/router/agent_static.go index c146bc9..5f641fb 100644 --- a/backend/router/agent_static.go +++ b/backend/router/agent_static.go @@ -29,6 +29,10 @@ func registerAgentDownloads(r *gin.Engine, log *zap.Logger) { r.GET("/agent/arch", func(c *gin.Context) { base := requestBaseURL(c) + if base == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "request host is required"}) + return + } downloads := availableAgentDownloads(paths.binDir) c.JSON(http.StatusOK, gin.H{ "version": "1.0.0", @@ -110,11 +114,17 @@ func requestBaseURL(c *gin.Context) string { scheme = "https" } if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { - scheme = proto + // 多级代理可能传逗号列表,首项是客户端最初使用的协议。 // Logan.Li + scheme = strings.TrimSpace(strings.Split(proto, ",")[0]) } host := c.Request.Host + if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" { + // 多级代理可能传逗号列表,首项是客户端最初访问的域名。 // Logan.Li + host = strings.TrimSpace(strings.Split(forwardedHost, ",")[0]) + } if host == "" { - host = "localhost:8080" + // HTTP/1.1 正常请求必须带 Host,缺失时不再伪造本机地址。 // Logan.Li + return "" } return fmt.Sprintf("%s://%s", scheme, host) } diff --git a/backend/router/routes.go b/backend/router/routes.go index 4f3d7d3..4f1bba9 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -76,15 +76,15 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c metricsWSHandler := controller.NewMetricsWSHandler(metricsHub, wsMgr) r.GET("/ws/metrics", middleware.JWTAuth(&cfg.JWT, db), metricsWSHandler.ServeWS) - // WebSocket 流水线日志推流 + // 流水线运行日志与工作区属于构建产物,必须同时完成登录和所属流水线授权。 // Logan.Li wsLogHandler := controller.NewWSPipelineLogHandler(db, redisCache, wsMgr) - r.GET("/ws/pipeline/:runId/log", wsLogHandler.StreamLog) - r.GET("/api/v1/pipelines/runs/:runId/log-stream", wsLogHandler.GetLogHistory) + r.GET("/ws/pipeline/:runId/log", middleware.JWTAuth(&cfg.JWT, db), middleware.PipelineRunAccess(db), wsLogHandler.StreamLog) + r.GET("/api/v1/pipelines/runs/:runId/log-stream", middleware.JWTAuth(&cfg.JWT, db), middleware.PipelineRunAccess(db), wsLogHandler.GetLogHistory) // 工作区文件浏览器(浏览/下载流水线构建目录文件) workspaceFilesHandler := controller.NewWorkspaceFilesHandler(db) - r.GET("/api/v1/pipelines/runs/:runId/files", workspaceFilesHandler.ListFiles) - r.GET("/api/v1/pipelines/runs/:runId/files/download", workspaceFilesHandler.DownloadFile) + r.GET("/api/v1/pipelines/runs/:runId/files", middleware.JWTAuth(&cfg.JWT, db), middleware.PipelineRunAccess(db), workspaceFilesHandler.ListFiles) + r.GET("/api/v1/pipelines/runs/:runId/files/download", middleware.JWTAuth(&cfg.JWT, db), middleware.PipelineRunAccess(db), workspaceFilesHandler.DownloadFile) // WebSocket K8s exec 交互终端 // Logan.Li k8sHandler := controller.NewKubernetesHandler(db, wsMgr) @@ -129,7 +129,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c api.GET("/rdp-staged/:token", controller.ServeRDPStaged) // Webhook 触发路由(无需 JWT,由 Webhook Token 签名验证) - pipelineHandler := controller.NewPipelineHandler(db, redisCache, ti) + pipelineHandler := controller.NewPipelineHandler(db, redisCache, ti, cfg.JWT.Secret) webhookGroup := api.Group("/webhook") { webhookGroup.POST("/job/:jobName", pipelineHandler.WebhookTrigger) @@ -264,7 +264,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c } // 云账单处理器(云账号同步账单路由共用) // Logan.Li - billingHandler := controller.NewBillingHandler(db) + billingHandler := controller.NewBillingHandler(db, &cfg.Billing) // 云账号路由 cloudAccountHandler := controller.NewCloudAccountHandler(db) @@ -291,6 +291,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c domains.GET("", domainHandler.ListDomains) domains.POST("", domainHandler.CreateDomain) domains.PUT("/:id", domainHandler.UpdateDomain) + domains.POST("/:id/check-ssl", domainHandler.CheckDomainSSL) domains.DELETE("/:id", domainHandler.DeleteDomain) } @@ -324,6 +325,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c billings.DELETE("/:id", billingHandler.DeleteBilling) billings.GET("/stats", billingHandler.GetBillingStats) billings.GET("/cost-analysis", billingHandler.GetBillingCostAnalysis) + billings.GET("/settings", billingHandler.GetBillingSettings) + billings.PUT("/settings", middleware.AdminAuth(), billingHandler.SaveBillingSettings) billings.GET("/providers", billingHandler.GetProviderOptions) billings.POST("/sync", billingHandler.SyncYesterdayBillings) } @@ -658,6 +661,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c buildNodes.POST("/:id/test", pipelineHandler.TestBuildNode) } + // Jenkins 外部 CI 接入:同步 Jobs 并通过 Jenkins API 触发构建。 // Logan.Li + jenkins := authorized.Group("/deploy/jenkins") + { + jenkins.GET("/config", pipelineHandler.GetJenkinsConfig) + jenkins.PUT("/config", pipelineHandler.SaveJenkinsConfig) + jenkins.POST("/test", pipelineHandler.TestJenkinsConfig) + jenkins.POST("/sync", pipelineHandler.SyncJenkinsJobs) + jenkins.GET("/refs", pipelineHandler.GetJenkinsRefs) + } + // 应用发布路由 releases := authorized.Group("/releases") { @@ -669,24 +682,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c releases.POST("/:id/rollback", pipelineHandler.RollbackRelease) } - // 仓库认证 & 镜像仓库认证路由 - deployHandler := controller.NewDeployHandler(db) - { - repos := authorized.Group("/deploy/repos") - repos.GET("", deployHandler.ListRepos) - repos.POST("", deployHandler.CreateRepo) - repos.GET("/:id", deployHandler.GetRepo) - repos.PUT("/:id", deployHandler.UpdateRepo) - repos.DELETE("/:id", deployHandler.DeleteRepo) - - registries := authorized.Group("/deploy/registries") - registries.GET("", deployHandler.ListRegistries) - registries.POST("", deployHandler.CreateRegistry) - registries.GET("/:id", deployHandler.GetRegistry) - registries.PUT("/:id", deployHandler.UpdateRegistry) - registries.DELETE("/:id", deployHandler.DeleteRegistry) - } - // 工具配置路由(发布配置 Tab ④)// Logan.Li toolConfigHandler := controller.NewToolConfigHandler(db, ti) tools := authorized.Group("/deploy/tools") @@ -879,27 +874,28 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c ll.GET("", llHandler.ListLoginLogs) } - // Agent 上报路由(无需 JWT) + // Agent 采集通道保持既有协议,待按主机绑定的可轮换凭证上线后再强制校验。 // Logan.Li agentHandler := controller.NewAgentHandler(db, redisCache, metricsHub) taskHandler := controller.NewAgentTaskHandler(db) - agentGroup := api.Group("/agent") + agentGroup := api.Group("/agent", middleware.AgentAuth(db, cfg.Agent.EnforceAuth)) { agentGroup.POST("/report", agentHandler.ReceiveReport) agentGroup.GET("/tasks/poll", taskHandler.PollTasks) agentGroup.POST("/tasks/:task_id/result", taskHandler.SubmitResult) - agentGroup.POST("/aggregate", agentHandler.AggregateMetrics) - agentGroup.POST("/exec", agentInstallHandler.RemoteExec) - // 健康探针:Agent 拉取本机配置(无需 JWT) + // 健康探针:Agent 拉取本机配置。 agentGroup.GET("/health-probes/config", agentHandler.GetHealthProbesForAgent) - // 端口策略:Agent 拉取本机端口过滤策略(无需 JWT) + // 端口策略:Agent 拉取本机端口过滤策略。 agentGroup.GET("/port-policy", agentHandler.GetPortPolicyForAgent) } // Agent 数据查询 + 任务管理(需要 JWT) agentQuery := authorized.Group("/agent") { + agentQuery.POST("/exec", agentInstallHandler.RemoteExec) agentQuery.GET("/latest", agentHandler.GetLatestReports) agentQuery.GET("/history/:hostname", agentHandler.GetHostHistory) agentQuery.GET("/aggregate", agentHandler.GetAggregate) + // 指标聚合是控制台维护操作,必须使用用户 JWT,不能由采集 Agent 调用。 // Logan.Li + agentQuery.POST("/aggregate", agentHandler.AggregateMetrics) // 远程任务 agentQuery.POST("/tasks", taskHandler.CreateTask) @@ -942,7 +938,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c aiopsGroup.GET("/llm-config", llmHandler.GetLLMConfig) aiopsGroup.PUT("/llm-config", llmHandler.SaveLLMConfig) aiopsGroup.POST("/llm-config/test", llmHandler.TestLLMConnection) - aiopsGroup.POST("/llm-config/models", llmHandler.ListLLMModels) // 读取 OpenAI/New API 兼容模型列表 // Logan.Li aiopsGroup.POST("/chat", llmHandler.Chat) aiopsGroup.POST("/chat/stream", llmHandler.ChatStream) aiopsGroup.GET("/chat-history", llmHandler.GetChatHistory) @@ -1055,18 +1050,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c zabbixDSGroup.POST("/test", zabbixDSHandler.TestConnection) } - // 自定义监控仪表盘 — CRUD + 设默认 // Logan.Li - customDashHandler := controller.NewCustomDashboardHandler(db) - customDashGroup := authorized.Group("/custom-dashboards") - { - customDashGroup.GET("", customDashHandler.List) - customDashGroup.GET("/:id", customDashHandler.Get) - customDashGroup.POST("", customDashHandler.Create) - customDashGroup.PUT("/:id", customDashHandler.Update) - customDashGroup.DELETE("/:id", customDashHandler.Delete) - customDashGroup.POST("/:id/default", customDashHandler.SetDefault) - } - // 链路追踪数据源管理 — CRUD + 测试连接 + 设默认 // Logan.Li traceDSHandler := controller.NewTraceDSHandler(db) traceProxyHandler := controller.NewTraceProxyHandler(traceDSHandler) diff --git a/backend/seed/seed.go b/backend/seed/seed.go index b69f4cd..082da98 100644 --- a/backend/seed/seed.go +++ b/backend/seed/seed.go @@ -84,8 +84,8 @@ func SeedData(db *gorm.DB) { }`, FlowConfig: `[{"name":"直接主管","approver_role":"team_lead","step_type":"audit"},{"name":"运维负责人","approver_role":"ops_manager","step_type":"audit"},{"name":"运维执行","approver_role":"ops_manager","step_type":"execute"}]`, Enabled: true, - }, - { + }, + { Name: "应用部署申请", Code: "app_deploy", Description: "申请新应用部署,包含 Git 地址、端口、Nacos 等配置信息", @@ -108,7 +108,7 @@ func SeedData(db *gorm.DB) { }`, FlowConfig: `[{"name":"技术负责人","approver_role":"tech_lead","step_type":"audit"},{"name":"运维负责人","approver_role":"ops_manager","step_type":"audit"},{"name":"部署执行","approver_role":"ops_manager","step_type":"execute"}]`, Enabled: true, - }, + }, { Name: "权限申请", Code: "permission_request", diff --git a/backend/seed/snmp_seed.go b/backend/seed/snmp_seed.go index c9b27c8..693b8ee 100644 --- a/backend/seed/snmp_seed.go +++ b/backend/seed/snmp_seed.go @@ -1,8 +1,8 @@ package seed import ( - snmppkg "ezops/pkg/snmp" "ezops/model" + snmppkg "ezops/pkg/snmp" "go.uber.org/zap" "gorm.io/gorm" @@ -20,7 +20,7 @@ func SeedSNMPTemplates(db *gorm.DB) { Name: bt.Name, Description: bt.Description, DeviceType: bt.DeviceType, Vendor: bt.Vendor, IsDefault: bt.Vendor == "H3C" && bt.DeviceType == "switch", - IsSystem: true, Status: 1, + IsSystem: true, Status: 1, } if err := db.Create(&tpl).Error; err != nil { zap.L().Warn("seed snmp template failed", zap.String("name", bt.Name), zap.Error(err)) @@ -39,4 +39,4 @@ func SeedSNMPTemplates(db *gorm.DB) { } zap.L().Info("seeded snmp template", zap.String("name", bt.Name), zap.Int("oids", len(bt.OIDs))) } -} \ No newline at end of file +} diff --git a/backend/service/service.go b/backend/service/service.go index 857ec5a..f749eea 100644 --- a/backend/service/service.go +++ b/backend/service/service.go @@ -29,8 +29,8 @@ func NewServerService(repo *repository.ServerRepository, cache ezredis.Cache, cr } // ListServers 获取服务器列表 -func (s *ServerService) ListServers(keyword, status, regionID, sortField, sortOrder string, page, pageSize int) ([]model.Server, int64, error) { - list, total, err := s.repo.List(keyword, status, regionID, sortField, sortOrder, page, pageSize) +func (s *ServerService) ListServers(keyword, status, regionID string, page, pageSize int) ([]model.Server, int64, error) { + list, total, err := s.repo.List(keyword, status, regionID, page, pageSize) if err != nil { return nil, 0, err } diff --git a/backend/static/.gitkeep b/backend/static/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/static/.gitkeep @@ -0,0 +1 @@ + diff --git a/config.yaml b/config.yaml index 08360a8..21f99f0 100644 --- a/config.yaml +++ b/config.yaml @@ -24,6 +24,26 @@ jwt: secret: "aiops-local-dev-secret-change-in-production" expire_hour: 24 +# Agent 凭证和高频数据保留策略。生产环境请通过独立配置或环境变量覆盖。 // Logan.Li +agent: + enforce_auth: false + +retention: + agent_reports_days: 7 + metric_history_days: 30 + metric_aggregate_days: 365 + snmp_samples_days: 30 + site_results_days: 30 + log_entries_days: 30 + cleanup_batch_size: 10000 + +# 云账单统一折算口径:汇率表示 1 单位原币折合多少 CNY。 // Logan.Li +billing: + base_currency: "CNY" + exchange_rates: + CNY: 1 + USD: 7.2 + websocket: read_buffer_size: 1024 write_buffer_size: 1024 diff --git a/deploy/README.md b/deploy/README.md index 23b0a70..ca208ba 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -48,6 +48,8 @@ http://localhost:8080/version 连接外部 MySQL/Redis 时使用根目录 `docker-compose.k8s.yml`。 +生产前复制 `.env.example` 为 `.env`、填入随机密码和 JWT 并执行 `chmod 600 .env`。Compose 不再挂载 Docker Socket,K8s 清单不使用 `hostNetwork`;uploads 通过独立持久卷保存。 + --- ## 二、独立二进制(无 Docker) @@ -99,6 +101,8 @@ systemctl status aiops 清单目录:`deploy/kubernetes/` 默认镜像:`lghost/aiops:`,NodePort `30080`。 +K8s 部署前必须替换 `deploy/kubernetes/secret.yaml` 中的 `REPLACE_WITH_*` 占位符;`deployment.yaml` 会一并应用 MySQL/Redis NetworkPolicy。详细流程见 [kubernetes/README.md](./kubernetes/README.md)。 + 详见 [kubernetes/README.md](./kubernetes/README.md)。 --- @@ -111,4 +115,4 @@ systemctl status aiops | 单机演示/测试 | `./run.sh docker up` | | 无 Docker 的 Linux | `./aiops` + `deploy/binary/install.sh` | | K8s 集群 | `./run.sh kubernetes deploy` | -| 版本发布 | `./run.sh release v1.x` | \ No newline at end of file +| 版本发布 | `./run.sh release v1.x` | diff --git a/deploy/kubernetes/README.md b/deploy/kubernetes/README.md index bb9830f..ab55b97 100644 --- a/deploy/kubernetes/README.md +++ b/deploy/kubernetes/README.md @@ -11,11 +11,11 @@ AIOps 平台在 Kubernetes 上的部署清单(Go/Gin 后端 + go:embed 前端 | 文件 | 说明 | |------|------| | `namespace.yaml` | `devops` 命名空间 | -| `secret.yaml` | MySQL/JWT 密码(Secret) | +| `secret.yaml` | MySQL/JWT Secret 模板(部署前必须注入随机值) | | `configmap.yaml` | 后端 `config.yaml`(ConfigMap) | -| `mysql.yaml` | MySQL 8.0 StatefulSet + Headless Service + 10Gi 持久卷 | -| `redis.yaml` | Redis 7 StatefulSet + Headless Service + 2Gi 持久卷 | -| `deployment.yaml` | AIOps Deployment(embed 单端口 8080)+ NodePort Service (30080) | +| `mysql.yaml` | MySQL 8.0 Deployment + Service + 10Gi 持久卷 | +| `redis.yaml` | Redis 7 Deployment + Service + 2Gi 持久卷 | +| `deployment.yaml` | AIOps Deployment、NodePort Service (30080) 和 MySQL/Redis NetworkPolicy | | `pvc-aiops-data.yaml` | local 模式 PVC:`sqlite3.db` / `redka.db` 与 `/app/aiops` 同级 | | `ingress.yaml` | Ingress:所有流量 → aiops:8080(含 WebSocket 支持) | @@ -35,7 +35,7 @@ AIOps 平台在 Kubernetes 上的部署清单(Go/Gin 后端 + go:embed 前端 ┌──────────────┼──────────────┐ ▼ ▼ MySQL 8.0 Redis 7 - (StatefulSet) (StatefulSet) + (Deployment) (Deployment) mysql:3306 redis:6379 10Gi PV 2Gi PV ``` @@ -72,14 +72,16 @@ AIOps 平台在 Kubernetes 上的部署清单(Go/Gin 后端 + go:embed 前端 cd deploy/kubernetes kubectl apply -f namespace.yaml +# 先生成随机密钥并替换 secret.yaml 的 REPLACE_WITH_* 占位符,勿提交真实值。 // Logan.Li +# openssl rand -hex 32 kubectl apply -f secret.yaml kubectl apply -f configmap.yaml kubectl apply -f pvc-aiops-data.yaml kubectl apply -f mysql.yaml kubectl apply -f redis.yaml -kubectl -n devops rollout status statefulset/mysql -kubectl -n devops rollout status statefulset/redis +kubectl -n devops rollout status deployment/mysql +kubectl -n devops rollout status deployment/redis # 构建镜像(Mac/Linux 均可,按 TARGETARCH 自动选架构) cd ../.. @@ -114,8 +116,9 @@ kubectl -n devops logs -f deployment/aiops # 进入应用容器 kubectl -n devops exec -it deployment/aiops -- bash -# 进入 MySQL -kubectl -n devops exec -it mysql-0 -- mysql -uaiops -paiops_pass_2026 aiops +# 进入 MySQL(密码从本地 Secret 读取,避免出现在 shell 历史中) +MYSQL_PASSWORD="$(kubectl -n devops get secret aiops-secret -o jsonpath='{.data.mysql-password}' | base64 -d)" +kubectl -n devops exec -it deployment/mysql -- mysql -uaiops -p"$MYSQL_PASSWORD" aiops # 滚动更新(换镜像 tag 后) kubectl -n devops set image deployment/aiops aiops=lghost/aiops:v1.5.0 --record @@ -140,12 +143,19 @@ kubectl -n devops get pvc | 项目 | 来源 | 值 | |------|------|----| -| MySQL 业务账号 | Secret `aiops-secret` | 用户 `aiops` / 密码 `aiops_pass_2026` / 库 `aiops` | -| MySQL root | Secret `aiops-secret` | `aiops_root_2026` | -| JWT | Secret `aiops-secret` | `aiops-jwt-secret-key-2026` | +| MySQL 业务账号 | Secret `aiops-secret` | 用户 `aiops` / `mysql-password` 键 / 库 `aiops` | +| MySQL root | Secret `aiops-secret` | `mysql-root-password` 键 | +| JWT | Secret `aiops-secret` | `jwt-secret` 键 | | Redis | 无密码 | db `0` | -> ⚠️ 生产环境请修改 `secret.yaml` 和 `configmap.yaml` 中的默认密码。 +> ⚠️ `secret.yaml` 只包含不可用的占位符。部署前须通过受控 Secret 管理系统或本地安全通道填充随机值;ConfigMap 不得保存密码和 JWT。 + +## 安全基线 + +- 应用容器以 UID/GID `10001` 运行,移除了 `hostNetwork` 和 Docker Socket 挂载。 +- SQLite/redka 与 uploads 分别通过 PVC 持久化;不要以 `emptyDir` 替代 uploads PVC。 +- `deployment.yaml` 中的 NetworkPolicy 保护 MySQL/Redis 的集群内入口。应用同时接收集群外 Agent 上报,待明确 Ingress 与 Agent 出口 CIDR 后,再增加应用 Pod 的严格入站策略。 +- 使用 Ingress 时应创建 TLS Secret 并配置 HTTPS;NodePort 仅适合受防火墙保护的内网运维入口。 ## 资源限额 diff --git a/deploy/kubernetes/configmap.yaml b/deploy/kubernetes/configmap.yaml index e2aa4b8..f783b78 100644 --- a/deploy/kubernetes/configmap.yaml +++ b/deploy/kubernetes/configmap.yaml @@ -1,4 +1,4 @@ -# config.yaml — 由 ConfigMap 挂载到 /app/config.yaml +# config.yaml — 由 ConfigMap 挂载到 /app/config.yaml;不得放入任何 Secret。 // Logan.Li # 注意:环境变量优先级高于此文件 (viper AutomaticEnv) # 默认 MODE=local(Deployment env)→ SQLite + redka,无需外部 MySQL/Redis # 集群模式:Deployment 设 MODE=release 并配置 DATABASE_/REDIS_ 环境变量 @@ -25,7 +25,3 @@ data: port: 6379 password: "" db: 0 - - jwt: - secret: "aiops-jwt-secret-key-2026" - expire_hour: 24 diff --git a/deploy/kubernetes/deployment.yaml b/deploy/kubernetes/deployment.yaml index d3f0579..e424948 100644 --- a/deploy/kubernetes/deployment.yaml +++ b/deploy/kubernetes/deployment.yaml @@ -34,13 +34,27 @@ spec: app.kubernetes.io/name: aiops app.kubernetes.io/component: app spec: - hostNetwork: true - dnsPolicy: ClusterFirstWithHostNet + # 不使用 hostNetwork,避免 Pod 直接共享节点网络命名空间。 // Logan.Li + dnsPolicy: ClusterFirst enableServiceLinks: false + automountServiceAccountToken: false + securityContext: + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault # 首次部署时在 PVC 中预创建库文件,避免 subPath 挂载成目录 // Logan.Li initContainers: - name: init-local-db image: busybox:1.36 + securityContext: + runAsUser: 0 + runAsGroup: 0 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault command: - sh - -c @@ -51,8 +65,9 @@ spec: if [ -f /vol/data/redka/redka.db ] && [ ! -f /vol/redka.db ]; then cp /vol/data/redka/redka.db /vol/redka.db fi - [ -f /vol/sqlite3.db ] || install -m 644 /dev/null /vol/sqlite3.db - [ -f /vol/redka.db ] || install -m 644 /dev/null /vol/redka.db + [ -f /vol/sqlite3.db ] || install -m 660 /dev/null /vol/sqlite3.db + [ -f /vol/redka.db ] || install -m 660 /dev/null /vol/redka.db + chown 10001:10001 /vol/sqlite3.db /vol/redka.db volumeMounts: - name: data mountPath: /vol @@ -72,22 +87,28 @@ spec: - name: MODE value: local - name: JWT_SECRET - value: "aiops-jwt-secret-key-2026" - # --- 集群模式(mode=release)时启用 --- + valueFrom: + secretKeyRef: + name: aiops-secret + key: jwt-secret + # --- 集群模式(mode=release)时通过 Secret 引用数据库凭据。 // Logan.Li # - name: DATABASE_DRIVER # value: "mysql" # - name: DATABASE_HOST - # value: "10.10.10.75" + # value: "mysql" # - name: DATABASE_USER - # value: "root" + # value: "aiops" # - name: DATABASE_PASSWORD - # value: "aiops_root_2026" + # valueFrom: + # secretKeyRef: + # name: aiops-secret + # key: mysql-password # - name: DATABASE_DBNAME # value: "aiops" # - name: REDIS_DRIVER # value: "redis" # - name: REDIS_HOST - # value: "10.10.10.75" + # value: "redis" volumeMounts: - name: config @@ -104,6 +125,16 @@ spec: mountPath: /app/redka.db subPath: redka.db + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + readinessProbe: httpGet: path: /version @@ -130,7 +161,8 @@ spec: configMap: name: aiops-config - name: uploads - emptyDir: {} + persistentVolumeClaim: + claimName: aiops-uploads - name: data persistentVolumeClaim: claimName: aiops-data @@ -153,3 +185,46 @@ spec: targetPort: 8080 nodePort: 30080 protocol: TCP +--- +# 限制集群内 MySQL/Redis 仅接受 AIOps Pod 连接。 // Logan.Li +# AIOps 需要接收集群外 Agent 的上报;在确定 Ingress 与 Agent 出口 CIDR 前, +# 不对应用 Pod 启用默认拒绝策略,以免阻断运维采集链路。 // Logan.Li +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: mysql-ingress-from-aiops-only + namespace: devops +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: mysql + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: aiops + ports: + - protocol: TCP + port: 3306 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: redis-ingress-from-aiops-only + namespace: devops +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: redis + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: aiops + ports: + - protocol: TCP + port: 6379 diff --git a/deploy/kubernetes/mysql.yaml b/deploy/kubernetes/mysql.yaml index c91a815..817bf7e 100644 --- a/deploy/kubernetes/mysql.yaml +++ b/deploy/kubernetes/mysql.yaml @@ -15,6 +15,11 @@ spec: labels: app.kubernetes.io/name: mysql spec: + securityContext: + fsGroup: 999 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault containers: - name: mysql image: mysql:8.0 @@ -23,9 +28,19 @@ spec: - containerPort: 3306 env: - name: MYSQL_ROOT_PASSWORD - value: "aiops_root_2026" + valueFrom: + secretKeyRef: + name: aiops-secret + key: mysql-root-password - name: MYSQL_DATABASE value: aiops + - name: MYSQL_USER + value: aiops + - name: MYSQL_PASSWORD + valueFrom: + secretKeyRef: + name: aiops-secret + key: mysql-password - name: TZ value: Asia/Shanghai args: @@ -35,15 +50,47 @@ spec: volumeMounts: - name: mysql-data mountPath: /var/lib/mysql + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault readinessProbe: exec: - command: ["sh", "-c", "mysqladmin ping -h 127.0.0.1 -uroot -paiops_root_2026"] + command: ["sh", "-c", "mysqladmin ping -h 127.0.0.1 -uroot -p\"$MYSQL_ROOT_PASSWORD\""] initialDelaySeconds: 20 periodSeconds: 10 timeoutSeconds: 5 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 2Gi volumes: - name: mysql-data - emptyDir: {} + persistentVolumeClaim: + claimName: mysql-data +--- +# MySQL 数据必须独立持久化,供备份与恢复演练使用。 // Logan.Li +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mysql-data + namespace: devops + labels: + app.kubernetes.io/name: mysql +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi --- apiVersion: v1 kind: Service diff --git a/deploy/kubernetes/pvc-aiops-data.yaml b/deploy/kubernetes/pvc-aiops-data.yaml index 4e67ca0..bae9bea 100644 --- a/deploy/kubernetes/pvc-aiops-data.yaml +++ b/deploy/kubernetes/pvc-aiops-data.yaml @@ -13,4 +13,20 @@ spec: - ReadWriteOnce resources: requests: - storage: 10Gi \ No newline at end of file + storage: 10Gi +--- +# uploads 不能使用 emptyDir,否则 Pod 重建会丢失上传的制品和附件。 // Logan.Li +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: aiops-uploads + namespace: devops + labels: + app.kubernetes.io/name: aiops + app.kubernetes.io/component: uploads +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 20Gi diff --git a/deploy/kubernetes/quick-deploy.yaml b/deploy/kubernetes/quick-deploy.yaml index 1852356..a811447 100644 --- a/deploy/kubernetes/quick-deploy.yaml +++ b/deploy/kubernetes/quick-deploy.yaml @@ -1,6 +1,6 @@ # quick-deploy.yaml — AIOps 单容器快速部署 # 部署: kubectl apply -f quick-deploy.yaml -# 此文件自包含 (ConfigMap + Deployment + Service), 不依赖其他 YAML +# 此文件为开发演示模板;生产请使用 deployment.yaml + Secret + PVC + NetworkPolicy。 // Logan.Li # # 两种模式: # 远程模式 (默认): MODE=release, 连接外部 MySQL + Redis @@ -24,9 +24,16 @@ data: redis: driver: redka - jwt: - secret: "aiops-jwt-secret-key-2026" - expire_hour: 24 +--- +# 仅为模板占位符,部署前必须替换为随机值,禁止提交真实生产密钥。 // Logan.Li +apiVersion: v1 +kind: Secret +metadata: + name: aiops-secret + namespace: devops +type: Opaque +stringData: + jwt-secret: "REPLACE_WITH_RANDOM_JWT_SECRET_AT_LEAST_32_BYTES" --- apiVersion: apps/v1 kind: Deployment @@ -53,11 +60,25 @@ spec: app.kubernetes.io/name: aiops app.kubernetes.io/component: app spec: - hostNetwork: true - dnsPolicy: ClusterFirstWithHostNet + dnsPolicy: ClusterFirst + enableServiceLinks: false + automountServiceAccountToken: false + securityContext: + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault initContainers: - name: init-local-db image: busybox:1.36 + securityContext: + runAsUser: 0 + runAsGroup: 0 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault command: - sh - -c @@ -68,8 +89,9 @@ spec: if [ -f /vol/data/redka/redka.db ] && [ ! -f /vol/redka.db ]; then cp /vol/data/redka/redka.db /vol/redka.db fi - [ -f /vol/sqlite3.db ] || install -m 644 /dev/null /vol/sqlite3.db - [ -f /vol/redka.db ] || install -m 644 /dev/null /vol/redka.db + [ -f /vol/sqlite3.db ] || install -m 660 /dev/null /vol/sqlite3.db + [ -f /vol/redka.db ] || install -m 660 /dev/null /vol/redka.db + chown 10001:10001 /vol/sqlite3.db /vol/redka.db volumeMounts: - name: data mountPath: /vol @@ -86,7 +108,10 @@ spec: - name: TZ value: Asia/Shanghai - name: JWT_SECRET - value: "aiops-jwt-secret-key-2026" + valueFrom: + secretKeyRef: + name: aiops-secret + key: jwt-secret # 集群模式 (MODE=release) 时取消注释: # - name: DATABASE_DRIVER # value: mysql @@ -107,6 +132,17 @@ spec: - name: data mountPath: /app/redka.db subPath: redka.db + - name: uploads + mountPath: /app/uploads + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault resources: requests: cpu: 100m @@ -121,6 +157,9 @@ spec: - name: data persistentVolumeClaim: claimName: aiops-data + - name: uploads + persistentVolumeClaim: + claimName: aiops-uploads --- apiVersion: v1 kind: Service diff --git a/deploy/kubernetes/redis.yaml b/deploy/kubernetes/redis.yaml index f143401..dcc3609 100644 --- a/deploy/kubernetes/redis.yaml +++ b/deploy/kubernetes/redis.yaml @@ -15,17 +15,61 @@ spec: labels: app.kubernetes.io/name: redis spec: + securityContext: + fsGroup: 999 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault containers: - name: redis image: redis:7-alpine imagePullPolicy: IfNotPresent ports: - containerPort: 6379 + command: ["redis-server", "--appendonly", "yes"] + volumeMounts: + - name: redis-data + mountPath: /data + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault readinessProbe: exec: command: ["redis-cli", "ping"] initialDelaySeconds: 5 periodSeconds: 5 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + volumes: + - name: redis-data + persistentVolumeClaim: + claimName: redis-data +--- +# Redis AOF 数据持久化,避免 Pod 重建时丢失分布式锁和缓存状态。 // Logan.Li +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: redis-data + namespace: devops + labels: + app.kubernetes.io/name: redis +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi --- apiVersion: v1 kind: Service diff --git a/deploy/kubernetes/secret.yaml b/deploy/kubernetes/secret.yaml index 741aff4..f061d75 100644 --- a/deploy/kubernetes/secret.yaml +++ b/deploy/kubernetes/secret.yaml @@ -5,6 +5,7 @@ metadata: namespace: devops type: Opaque stringData: - mysql-root-password: "aiops_root_2026" - mysql-password: "aiops_pass_2026" - jwt-secret: "aiops-jwt-secret-key-2026" + # 仅为模板占位符,部署前必须替换为随机值,禁止提交真实生产密钥。 // Logan.Li + mysql-root-password: "REPLACE_WITH_RANDOM_MYSQL_ROOT_PASSWORD" + mysql-password: "REPLACE_WITH_RANDOM_MYSQL_APPLICATION_PASSWORD" + jwt-secret: "REPLACE_WITH_RANDOM_JWT_SECRET_AT_LEAST_32_BYTES" diff --git a/docker-compose.k8s.yml b/docker-compose.k8s.yml index ca577bd..b000499 100644 --- a/docker-compose.k8s.yml +++ b/docker-compose.k8s.yml @@ -7,7 +7,7 @@ services: image: ${DOCKERHUB_IMAGE:-lghost/aiops}:${VERSION:-dev} build: context: . - dockerfile: Dockerfile.embed + dockerfile: Dockerfile args: VERSION: ${VERSION:-dev} BUILD_TIME: ${BUILD_TIME:-unknown} @@ -16,12 +16,33 @@ services: restart: unless-stopped ports: - "8080:8080" + environment: + MODE: ${MODE:-release} + DATABASE_DRIVER: ${DATABASE_DRIVER:-mysql} + DATABASE_HOST: ${DATABASE_HOST:?请设置 DATABASE_HOST} + DATABASE_PORT: ${DATABASE_PORT:-3306} + DATABASE_USER: ${DATABASE_USER:?请设置 DATABASE_USER} + DATABASE_PASSWORD: ${DATABASE_PASSWORD:?请设置 DATABASE_PASSWORD} + DATABASE_DBNAME: ${DATABASE_DBNAME:-aiops} + REDIS_DRIVER: ${REDIS_DRIVER:-redis} + REDIS_HOST: ${REDIS_HOST:?请设置 REDIS_HOST} + REDIS_PORT: ${REDIS_PORT:-6379} + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + JWT_SECRET: ${JWT_SECRET:?请设置 JWT_SECRET} volumes: - ./backend/config.external.yaml:/app/config.yaml - - /var/run/docker.sock:/var/run/docker.sock + - aiops_uploads:/app/uploads + # 禁止挂载 Docker Socket;容器控制应使用受限远端 API 或专用执行器。 // Logan.Li + cap_drop: + - ALL + security_opt: + - no-new-privileges:true healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/version"] interval: 30s timeout: 10s retries: 3 - start_period: 40s \ No newline at end of file + start_period: 40s + +volumes: + aiops_uploads: diff --git a/docker-compose.yml b/docker-compose.yml index 697278f..a013a47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,13 +10,13 @@ services: hostname: aiops-mysql restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-CHANGE_ME} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?请在 .env 设置 MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: ${MYSQL_DATABASE:-aiops} MYSQL_USER: ${MYSQL_USER:-aiops} - MYSQL_PASSWORD: ${MYSQL_PASSWORD:-CHANGE_ME} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?请在 .env 设置 MYSQL_PASSWORD} TZ: Asia/Shanghai ports: - - "3306:3306" + - "127.0.0.1:3306:3306" volumes: - mysql_data:/var/lib/mysql command: > @@ -40,7 +40,7 @@ services: hostname: aiops-redis restart: unless-stopped ports: - - "6379:6379" + - "127.0.0.1:6379:6379" volumes: - redis_data:/data command: redis-server --appendonly yes @@ -79,18 +79,22 @@ services: DATABASE_HOST: mysql DATABASE_PORT: 3306 DATABASE_USER: ${MYSQL_USER:-aiops} - DATABASE_PASSWORD: ${MYSQL_PASSWORD:-CHANGE_ME} + DATABASE_PASSWORD: ${MYSQL_PASSWORD:?请在 .env 设置 MYSQL_PASSWORD} DATABASE_DBNAME: ${MYSQL_DATABASE:-aiops} REDIS_DRIVER: redis REDIS_HOST: redis REDIS_PORT: 6379 - JWT_SECRET: ${JWT_SECRET:-aiops-jwt-secret-change-me} + JWT_SECRET: ${JWT_SECRET:?请在 .env 设置 JWT_SECRET} # RDP 使用自研协议栈,第三方 grdp 已移除;该变量保留兼容但后端不再读取。 // Logan.Li RDP_ENGINE: ${RDP_ENGINE:-rdp} RDP_DEBUG: ${RDP_DEBUG:-1} volumes: - aiops_uploads:/app/uploads - - /var/run/docker.sock:/var/run/docker.sock + # 禁止挂载 Docker Socket;容器控制应使用受限远端 API 或专用执行器。 // Logan.Li + cap_drop: + - ALL + security_opt: + - no-new-privileges:true depends_on: mysql: condition: service_healthy diff --git a/docs/automation-deploy/README.md b/docs/automation-deploy/README.md index 40aa1ee..0c6b7d5 100644 --- a/docs/automation-deploy/README.md +++ b/docs/automation-deploy/README.md @@ -25,6 +25,7 @@ | 远程执行 | 已完成 | SSH 执行、上传、结果查看 | | Agent 任务 | 已完成 | Agent 轮询 command/script | | Pipeline | 已完成 | 流水线定义、阶段、执行日志 | +| Jenkins 接入 | 已完成 | 保存 Jenkins API Token/密码、扫描 Jobs、同步为外部流水线、触发构建并回填日志 | | 发布管理 | 已完成 | 发布单、环境、审批、详情 | | Workspace 文件 | 已完成 | 构建目录文件浏览 | | Workspace 终端 | 已完成 | xterm 进入构建目录 | @@ -42,6 +43,7 @@ | 远程执行 | `remote_exec.go` | `RemoteExecPage.tsx` | | Agent 任务 | `agent_task.go` | Agent 监控相关页面 | | Pipeline | `pipeline*.go` | `PipelineManager.tsx`、`PipelineEditor.tsx` | +| Jenkins 接入 | `pipeline_jenkins.go` | `JenkinsSettingsDialog.tsx`、`PipelineCard.tsx` | | 发布 | `pipeline_release.go`、`deploy*.go` | `ReleaseManager.tsx`、`AppRelease.tsx` | | Workspace | `workspace_files.go` | `FileBrowserDialog.tsx`、`WorkspaceTermDialog.tsx` | | 审批 | `approval*.go` | `ApprovalFlow.tsx` | @@ -68,6 +70,41 @@ Backend --- +## Pipeline 双模式 + +AIOps 流水线支持两种执行模式,默认仍为平台原生执行,Jenkins 作为外部 CI Provider 接入: + +| 模式 | `ci_provider` | 适用场景 | 执行方式 | +|------|---------------|----------|----------| +| AIOps 原生 | `aiops` | 新项目、希望统一在平台内完成构建/发布/日志/Workspace | AIOps 调度构建节点,本地执行 stages,并写入 `deploy_pipeline_runs` | +| Jenkins | `jenkins` | 企业已有 Jenkins Job,需要平滑纳管和统一审计 | AIOps 保存 Job 映射,调用 Jenkins API 触发构建,轮询状态与 console log | + +Jenkins 接入流程: + +1. 在流水线页面点击 `Jenkins`,填写 Jenkins 地址、用户、API Token 或密码。 +2. 后端使用 `crumbIssuer/api/json` 自动获取 CSRF crumb。 +3. 通过 `/api/json` 递归扫描 Folder/Job,按 Job URL 去重。 +4. 同步到 `deploy_pipelines`,设置 `ci_provider=jenkins`、`jenkins_job_name`、`jenkins_job_url`。 +5. 用户触发 Jenkins 类型流水线时,AIOps 按全局引用策略展示分支、Tag 或二者,并调用 `build` 或 `buildWithParameters`。 +6. Jenkins 返回队列地址后,AIOps 轮询 queue/build API,并通过 `logText/progressiveText` 增量读取 console log,按行写入 `deploy_pipeline_runs`。 + +触发参数约定: + +- Jenkins 全局配置提供 `ref_mode` 和 `ref_param`。`ref_mode=branch` 只展示分支,`tag` 只展示 Tag,`both` 允许用户二选一。// Logan.Li +- AIOps 不修改 Jenkins Job 配置,Job 的 Git 参数、Choice 参数、Shell 构建步骤仍以 Jenkins 为准;同步时只读保存参数定义、Git URL 和 Shell 摘要。// Logan.Li +- 用户触发 Jenkins Job 时,前端按 Jenkins 实际参数定义渲染表单;GitParameter 会优先通过同步到的 Git URL 只读拉取分支或 Tag 候选。 +- 用户选择 Tag 或 Branch 时,写入 Jenkins Job 实际参数名;若 Job 未返回参数名,则回退到全局 `ref_param`,默认参数名为 `SELECT`。 +- 前端/接口传入 `variables` 时,会作为 Jenkins build parameters 一并发送。 +- Jenkins 未启用或 Token 不可用时,只影响 Jenkins 类型流水线,不影响 AIOps 原生流水线。 + +安全约定: + +- Jenkins Token/密码只加密落库,不通过 GET 接口回显。 +- 列表只展示 `token_set`,前端留空保存时保留旧密钥。 +- Job 同步只保存 Job 元信息,不保存 Jenkins 构建参数里的敏感值。 + +--- + ## 日志与输出 | 类型 | 存储/通道 | 说明 | diff --git a/docs/database-migrations.md b/docs/database-migrations.md new file mode 100644 index 0000000..06ca2d7 --- /dev/null +++ b/docs/database-migrations.md @@ -0,0 +1,41 @@ +# 数据库版本化迁移 + +服务进程启动时**不会**执行数据库结构变更。数据库升级必须在备份成功、单实例维护窗口内显式运行迁移命令,避免大 SQLite 库发生隐式重建和 WAL 写放大。 // Logan.Li + +## 日常命令 + +```bash +# 查看已应用与待执行版本 +./aiops migrate status --config /srv/aiops/config.yaml + +# 全新空数据库:创建初始基线并执行后续迁移 +./aiops migrate up --config /srv/aiops/config.yaml + +# 已存在的旧数据库:人工核验、备份完成后,只写入基线记录,不改业务表 +./aiops migrate baseline --config /srv/aiops/config.yaml + +# 旧库完成 baseline 后,执行后续待迁移版本 +./aiops migrate up --config /srv/aiops/config.yaml +``` + +`baseline` 会校验 `iam_users`、`iam_roles`、`asset_servers`、`deploy_pipelines`、`monitor_agent_reports` 等核心表;校验失败时拒绝登记,不能用它绕过不完整库的恢复或专项迁移。 // Logan.Li + +## 已上线数据库的切换步骤 + +1. 停止写入或切换至维护窗口,并完成可恢复备份。 +2. 运行 `aiops migrate status`,确认已有 `0001_agent_credentials` 记录仍然存在。 +3. 运行 `aiops migrate baseline`,它只登记 `0000_baseline_schema`。 +4. 再运行 `aiops migrate up`;`0002_legacy_schema_patches` 会把既有的兼容字段补丁作为可审计迁移执行。 +5. 再次运行 `status`,确认所有版本均为 `applied`,然后启动服务。 + +线上已存在的 `sys_schema_migrations(version, applied_at)` 表保持兼容:迁移框架不会假设额外列,也不会重写既有 `0001_agent_credentials` 记录。 // Logan.Li + +若旧业务库甚至尚无 `sys_schema_migrations`,`migrate up` 会零写入拒绝;只能由维护人员先完成备份并显式执行 `migrate baseline`。 // Logan.Li + +## 新迁移规则 + +- 新版本只追加到 `backend/migrations/` 和 `migrations.All()`,禁止修改或重命名已发布版本。 +- 需要方言差异的 SQL 使用 `..sql` 三份脚本。 +- 复杂或需调用 GORM 的变更使用 Go 迁移函数,但必须返回错误;禁止吞掉 DDL/DML 错误。 +- MySQL DDL 可能隐式提交,执行迁移前必须备份;同一数据库同一时间只能运行一个迁移进程。 +- SQLite 大表迁移与 `VACUUM` 必须在停写维护窗口执行,迁移完成后再进行 WAL checkpoint 与空间核验。 diff --git a/docs/database-schema.md b/docs/database-schema.md new file mode 100644 index 0000000..4995636 --- /dev/null +++ b/docs/database-schema.md @@ -0,0 +1,201 @@ +# 数据库设计 + +## 概述 + +AIOps 使用 GORM 作为 ORM 框架,支持 SQLite / MySQL / PostgreSQL。 + +- **默认**: SQLite(零配置,数据文件 `backend/data/aiops.db`) +- **生产**: 推荐使用 MySQL 或 PostgreSQL + +首次初始化请显式执行 `aiops migrate up` 创建基线;服务启动时不会自动修改表结构。迁移流程见 [数据库版本化迁移](../database-migrations.md)。 // Logan.Li + +## 数据模型 + +### 用户体系 + +#### users - 用户表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| username | string | 用户名(唯一) | +| password | string | 密码(bcrypt 加密) | +| email | string | 邮箱 | +| phone | string | 手机号 | +| real_name | string | 真实姓名 | +| avatar | string | 头像 URL | +| status | int | 状态(1=正常 0=禁用) | +| last_login | time | 最后登录时间 | +| created_at | time | 创建时间 | +| updated_at | time | 更新时间 | + +#### roles - 角色表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| name | string | 角色名称 | +| code | string | 角色代码 | +| description | string | 角色描述 | +| sort | int | 排序 | + +#### permissions - 权限表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| name | string | 权限名称 | +| code | string | 权限代码 | +| type | string | 权限类型(menu/btn/api) | +| path | string | 路由路径 | +| method | string | HTTP 方法 | + +### 服务器管理 + +#### servers - 服务器表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| name | string | 服务器名称 | +| hostname | string | 主机名 | +| ip | string | IP 地址 | +| port | int | SSH 端口 | +| os | string | 操作系统 | +| cpu | int | CPU 核数 | +| memory | int64 | 内存(MB) | +| disk | int64 | 磁盘(GB) | +| status | string | 状态(online/offline/maintenance) | +| tags | string | 标签(JSON) | +| cloud_id | uint | 关联云账号 | +| region | string | 地域 | + +#### clouds - 云平台表 +#### cloud_accounts - 云账号表 +#### regions - 地域表 +#### domains - 域名表 + +### K8s 管理 + +#### k8s_clusters - K8s 集群表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| name | string | 集群名称 | +| version | string | K8s 版本 | +| api_server | string | API Server 地址 | +| kubeconfig | string | Kubeconfig 内容 | +| status | string | 集群状态 | + +#### k8s_nodes, k8s_pods, k8s_deployments, k8s_services, k8s_ingresses + +### 监控告警 + +#### alert_rules - 告警规则表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| name | string | 规则名称 | +| description | string | 规则描述 | +| type | string | 告警类型 | +| source | string | 数据源 | +| expression | string | PromQL 表达式 | +| severity | string | 严重级别(critical/warning/info) | +| duration | string | 持续时间 | +| notify_channels | string | 通知渠道(JSON) | +| enabled | bool | 是否启用 | + +#### alert_events - 告警事件表 +#### notify_channels - 通知渠道表 +#### metric_data - 指标数据表 + +### CI/CD + +#### pipelines - 流水线表 +#### pipeline_runs - 流水线执行记录表 +#### app_releases - 应用发布表 + +### 自动化运维 + +#### cron_jobs - 定时任务表 +#### scripts - 脚本表 +#### task_executions - 任务执行记录表 +#### playbooks - Ansible Playbook 表 + +### 审批流程 + +#### approval_templates - 审批模板表 +#### approval_processes - 审批流程表 +#### approval_steps - 审批步骤表 + +### 其他 + +#### navigation_groups - 导航分组表 +#### navigation_links - 导航链接表 +#### audit_logs - 审计日志表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| user_id | *uint | 操作用户 ID | +| module | string | 模块 | +| action | string | 操作类型 | +| resource | string | 资源标识 | +| request | string | 请求内容 | +| response | string | 响应内容 | +| error | string | 错误信息 | +| ip | string | 操作 IP | +| created_at | time | 操作时间 | + +## 表关系 + +``` +users ──< user_roles >── roles ──< role_permissions >── permissions + +servers ──> cloud_accounts ──> clouds +servers ──> regions + +alert_rules ──< alert_events +alert_rules ──> notify_channels + +pipelines ──< pipeline_runs +app_releases ──> pipelines + +approval_templates ──< approval_processes ──< approval_steps + +navigation_groups ──< navigation_links +``` + +## 版本化迁移 + +模型基线由 `backend/pkg/database/database.go` 的 `BootstrapSchema` 定义,但只允许由 `aiops migrate up` 显式调用。后续变更按版本存放在 `backend/migrations/`,并记录至 `sys_schema_migrations`;已运行的服务不会在启动期间执行迁移。 // Logan.Li + +## 切换数据库 + +### MySQL + +编辑 `backend/config.yaml`: +```yaml +database: + driver: mysql + host: localhost + port: 3306 + user: root + password: your_password + dbname: aiops + charset: utf8mb4 +``` + +### PostgreSQL + +```yaml +database: + driver: postgres + host: localhost + port: 5432 + user: postgres + password: your_password + dbname: aiops +``` diff --git a/docs/db-table-audit.md b/docs/db-table-audit.md new file mode 100644 index 0000000..08fb1a5 --- /dev/null +++ b/docs/db-table-audit.md @@ -0,0 +1,412 @@ +# 数据表与菜单域审计记录 + +> 审计范围:`backend/model`、`backend/pkg/database/database.go`、主要 controller/service/repository 引用、`backend/seed/seed.go` 默认菜单权限。 +> 目标:按菜单域整理表清单,记录表名前缀、设计合理性、疑似旧表/无用表和后续整改建议。 + +## 重建版目标设计 + +测试环境允许直接重建时,建议采用 **Schema v2 全局统一方案**:按菜单域和平台基础域重新归类,旧表不做兼容 rename,直接调整 `TableName()`、AutoMigrate 清单、种子数据和相关查询引用,然后清空数据库重建。 + +### 全局命名原则 + +1. 表名统一使用:`_`。 +2. 领域前缀与菜单域保持一致,避免同一功能散落在 `sys_`、`ops_`、`monitor_`。 +3. 关联表统一使用:`__` 或同域 `__`。 +4. 配置类表可用单数语义但表名仍用复数,例如 `deploy_build_master_configs`。 +5. 时序/日志大表必须明确后缀:`*_samples`、`*_histories`、`*_events`、`*_logs`。 +6. 不再新增无前缀表。 + +### 目标前缀 + +| 前缀 | 菜单/领域 | 说明 | +| --- | --- | --- | +| `iam_` | 身份与权限 | 用户、角色、权限、凭据、LDAP、资源级授权 | +| `sys_` | 系统基础配置 | 系统任务、站内导航、全局配置 | +| `audit_` | 安全审计 | 操作审计、登录日志、终端/RDP 会话、命令审计 | +| `asset_` | 资产/云资源 | 服务器、云账号、云区域、域名、账单、资源型号 | +| `k8s_` | Kubernetes | 集群与同步缓存资源 | +| `deploy_` | 应用发布 | 流水线、运行记录、发布、环境、构建节点、工具 | +| `monitor_` | 监控告警 | 告警、指标、Agent、SNMP、站点拨测、中间件、数据源 | +| `ops_` | 自动化运维 | 定时任务、脚本、远程执行、Ansible 如保留 | +| `approval_` | 资源申请/审批 | 模板、流程、实例、步骤 | +| `ai_` | AIOps/AI | LLM、会话、分析轨迹、Agent CLI 运行记录 | + +### 目标表清单 + +#### IAM / 系统基础 + +| 目标表 | 来源/处理 | +| --- | --- | +| `iam_users` | 保留 | +| `iam_roles` | 保留 | +| `iam_permissions` | 保留 | +| `iam_user_roles` | 保留 | +| `iam_role_permissions` | 替换 `role_permissions` | +| `iam_credentials` | 保留,作为全局凭据唯一主表 | +| `iam_ldap_configs` | 保留 | +| `iam_item_roles` | 保留 | +| `iam_user_item_roles` | 保留 | +| `iam_log_scope_roles` | 从 `monitor_log_scope_roles` 调整,日志查询权限本质是权限控制 | +| `iam_user_log_scope_roles` | 从 `monitor_user_log_scope_roles` 调整 | +| `sys_system_task_statuses` | 从 `sys_system_task_status` 调整为复数 | +| `sys_navigation_groups` | 保留 | +| `sys_navigation_links` | 替换 `navigation_links` | + +#### 安全审计 + +| 目标表 | 来源/处理 | +| --- | --- | +| `audit_operation_logs` | 替换 `sys_audit_logs` | +| `audit_settings` | 替换 `sys_audit_settings` | +| `audit_login_logs` | 替换 `sys_login_logs` | +| `audit_terminal_sessions` | 替换 `sys_terminal_sessions` | +| `audit_rdp_sessions` | 替换 `sys_rdp_sessions` | +| `audit_session_commands` | 替换 `sys_session_commands` | + +#### 资产 / 云资源 + +| 目标表 | 来源/处理 | +| --- | --- | +| `asset_servers` | 保留 | +| `asset_host_groups` | 保留 | +| `asset_cloud_platforms` | 替换 `asset_clouds` | +| `asset_cloud_accounts` | 保留,敏感字段统一转凭据 ID | +| `asset_cloud_regions` | 替换 `asset_regions` | +| `asset_domains` | 保留 | +| `asset_resource_models` | 保留 | +| `asset_cloud_billing_records` | 替换 `asset_cloud_billings`,语义更明确 | +| `asset_cloud_billing_alert_rules` | 替换 `monitor_billing_alert_rules`,账单告警属于资产账单域配置,触发事件再进监控事件 | + +#### Kubernetes + +| 目标表 | 来源/处理 | +| --- | --- | +| `k8s_clusters` | 替换 `kubernetes_clusters` | +| `k8s_nodes` | 替换 `kubernetes_nodes` | +| `k8s_pods` | 替换 `kubernetes_pods` | +| `k8s_deployments` | 替换 `kubernetes_deployments` | +| `k8s_services` | 替换 `kubernetes_services` | +| `k8s_ingresses` | 替换 `kubernetes_ingresses` | +| `k8s_topology_layouts` | 替换 `kubernetes_topology_layouts` | + +#### 应用发布 + +| 目标表 | 来源/处理 | +| --- | --- | +| `deploy_pipelines` | 替换 `ops_pipelines` | +| `deploy_pipeline_runs` | 替换 `ops_pipeline_runs` | +| `deploy_app_releases` | 保留 | +| `deploy_environments` | 保留 | +| `deploy_build_nodes` | 保留 | +| `deploy_build_master_configs` | 替换 `deploy_build_master_config` | +| `deploy_pipeline_members` | 保留 | +| `deploy_user_groups` | 保留 | +| `deploy_user_group_members` | 保留 | +| `deploy_tools` | 保留 | +| 已删除 `deploy_pipeline_repos` | 发布配置直接使用 `iam_credentials` + Pipeline 凭据字段 | +| 已删除 `deploy_pipeline_registries` | 发布配置直接使用 `iam_credentials` + Pipeline 凭据字段 | + +#### 监控告警 + +| 目标表 | 来源/处理 | +| --- | --- | +| `monitor_targets` | 保留 | +| `monitor_metrics` | 保留,作为指标定义表 | +| `monitor_metric_samples` | 合并/替换 `metric_values` 和 `monitor_metric_histories` | +| `monitor_metric_aggregates` | 替换 `monitor_metrics_aggregate` | +| `monitor_metric_tags` | 保留 | +| `monitor_alert_rules` | 保留但只保留一个模型定义 | +| `monitor_alert_conditions` | 保留 | +| `monitor_alert_events` | 保留 | +| `monitor_alert_channels` | 替换 `monitor_notify_channels` | +| `monitor_alert_contacts` | 保留 | +| `monitor_alert_contact_groups` | 保留 | +| `monitor_alert_notify_policies` | 保留 | +| `monitor_alert_notify_templates` | 保留 | +| `monitor_alert_notify_settings` | 保留 | +| `monitor_alert_upgrades` | 保留 | +| `monitor_alert_upgrade_records` | 保留 | +| `monitor_alert_silences` | 保留 | +| `monitor_agent_reports` | 保留 | +| `monitor_agent_tasks` | 保留 | +| `monitor_agent_rules` | 保留 | +| `monitor_agent_alerts` | 保留 | +| `monitor_agent_port_policies` | 替换 `monitor_agent_port_policy`,并加入迁移 | +| `monitor_health_probes` | 保留 | +| `monitor_prometheus_datasources` | 保留 | +| `monitor_zabbix_datasources` | 保留 | +| `monitor_log_datasources` | 保留 | +| `monitor_trace_datasources` | 保留 | +| `monitor_site_tasks` | 替换 `ops_site_tasks` | +| `monitor_site_results` | 替换 `ops_site_results` | +| `monitor_site_alerts` | 替换 `ops_site_alerts` | +| `monitor_snmp_devices` | 替换 `ops_snmp_devices` | +| `monitor_snmp_templates` | 替换 `ops_snmp_templates` | +| `monitor_snmp_template_oids` | 替换 `ops_snmp_template_oids` | +| `monitor_snmp_samples` | 替换 `ops_snmp_metrics`,删除 `snmp_history` | +| `monitor_snmp_interfaces` | 替换 `ops_snmp_interfaces` | +| `monitor_dashboards` | 保留一个大屏主表 | +| `monitor_dashboard_panels` | 替换 `dashboard_panels` | +| 已删除 `monitor_custom_dashboards` | 合并到 `monitor_dashboards`,用 `type/custom` 区分 | +| 删除 `monitor_metric_data` | 用 `monitor_metrics` 替代 | + +#### 自动化运维 + +| 目标表 | 来源/处理 | +| --- | --- | +| `ops_cron_jobs` | 保留 | +| `ops_scripts` | 保留 | +| `ops_task_executions` | 保留 | +| `ops_remote_executions` | 建议新增,记录远程批量执行任务级审计 | +| `ops_ansible_playbooks` | 如保留 Ansible 页面则保留,否则删除 | +| `ops_ansible_jobs` | 同上 | +| `ops_ansible_inventories` | 同上 | + +#### 审批 + +| 目标表 | 来源/处理 | +| --- | --- | +| `approval_templates` | 替换 `sys_approval_templates` | +| `approval_processes` | 保留 | +| `approval_steps` | 保留 | +| `approval_workflows` | 替换 `sys_approval_workflows` | +| `approval_workflow_steps` | 替换 `sys_approval_workflow_steps` | + +建议:模板保留表单 schema,工作流表保留审批流定义,流程实例引用 `workflow_id`,逐步弱化 `flow_config` JSON。 + +#### AIOps / AI + +| 目标表 | 来源/处理 | +| --- | --- | +| `ai_llm_configs` | 保留 | +| `ai_chat_histories` | 保留 | +| `ai_analysis_traces` | 保留 | +| `ai_runs` | 保留 | +| `ai_run_messages` | 保留 | +| `ai_run_spans` | 保留 | +| `ai_capabilities` | 保留 | +| `ai_capability_policies` | 保留 | +| `ai_ops_sessions` | 替换 `ops_sessions` | +| `ai_ops_commands` | 替换 `ops_commands` | + +### 重建执行策略 + +1. 修改所有 `TableName()`,把目标表名一次性统一。 +2. 修改 GORM many2many tag:`role_permissions` -> `iam_role_permissions`。 +3. 修改硬编码 `db.Table(...)` 和 SQL 清理逻辑。 +4. 精简 AutoMigrate:删除已合并/废弃模型,补上漏迁移模型。 +5. 更新 seed:默认权限、审批模板、工具配置、演示数据按新表生成。 +6. 删除测试库数据卷或 SQLite 文件,重新启动服务让 AutoMigrate 建表。 +7. 跑 `go build`、`npx tsc --noEmit`、集成测试和关键页面验证。 + +## 总体结论 + +当前表名前缀没有形成统一分层,主要使用 `asset_`、`monitor_`、`ops_`、`deploy_`、`sys_`、`iam_`、`ai_`、`kubernetes_`。整体可以运行,但存在几类需要后续整改的问题: + +1. **无前缀或前缀不一致表**:`approval_processes`、`approval_steps`、`navigation_links`、`metric_values`、`dashboard_panels`、`snmp_history`、`role_permissions`。 +2. **同一职责多套表并存**:审批有 `sys_approval_templates / approval_processes / approval_steps` 与 `sys_approval_workflows / sys_approval_workflow_steps`;监控大屏有 `monitor_dashboards / dashboard_panels` 与 `monitor_custom_dashboards`;告警规则有 `AlertRule` 和 `MonitorAlertRule` 共用 `monitor_alert_rules`。 +3. **表定义但迁移/引用不完整**:`SNMPHistory` 定义了 `snmp_history`,但未进入 AutoMigrate,代码实际查询 `ops_snmp_metrics`;`AgentPortPolicy` 有控制器引用,但未进入 AutoMigrate。 +4. **疑似旧表/低价值表**:`sys_log_sources`、`sys_log_entries` 属于旧日志模块,与当前日志中心 `monitor_log_datasources` + 外部 Loki/ES 架构重复;`ops_ansible_*` 有种子数据但“远程执行”现已主要走 SSH/脚本执行,需要确认是否仍保留 Ansible 页面。 +5. **设计一致性问题**:大量 JSON 字符串字段承载结构化配置,短期可接受,但要明确边界;高频时序表缺少统一保留策略和分区方案;部分关联表缺少显式外键/唯一约束命名规范。 + +## 现状前缀评估 + +> 本节是现状评估口径;重建整改时以“重建版目标设计”为准。 + +| 领域 | 当前/建议前缀 | 说明 | +| --- | --- | --- | +| IAM/权限/凭据 | `iam_` | 用户、角色、权限、LDAP、凭据、ItemRole | +| 系统基础配置 | `sys_` | 系统任务、站内导航、全局配置 | +| 安全审计 | `audit_` | 重建后从 `sys_` 拆出,避免系统配置与审计数据混在一起 | +| 资产/云资源 | `asset_` | 服务器、云账号、域名、账单、资源模型 | +| Kubernetes | `k8s_` | 重建后统一短前缀,替换当前 `kubernetes_` | +| 发布/部署 | `deploy_` | 环境、凭据、工具、发布记录、成员权限 | +| 自动化执行 | `ops_` | 定时任务、脚本、任务执行、远程执行记录 | +| 监控告警 | `monitor_` | 指标、目标、告警、数据源、站点拨测、SNMP、Agent | +| 审批 | `approval_` | 重建后模板、流程、实例统一归入审批域 | +| AI/AIOps | `ai_` | LLM 配置、会话、分析轨迹、能力策略 | + +## 问题清单 + +| 编号 | 严重度 | 表/模型 | 问题 | 建议 | +| --- | --- | --- | --- | --- | +| DB-001 | 高 | `AlertRule` / `MonitorAlertRule` -> `monitor_alert_rules` | 两个模型返回同一张表,字段定义不同。AutoMigrate 会叠加字段,后续维护容易误删/误覆盖。 | 合并为一个模型或拆成 `monitor_alert_rules` 与 `monitor_probe_alert_rules`,迁移前确认线上字段使用。 | +| DB-002 | 高 | `AgentPortPolicy` -> `monitor_agent_port_policy` | 控制器有读写,但未加入 AutoMigrate,新库端口策略接口可能报“表不存在”。 | 加入 AutoMigrate;表名建议改为复数 `monitor_agent_port_policies`,如改名需迁移旧表。 | +| DB-003 | 高 | `SNMPHistory` -> `snmp_history` | 定义未迁移,代码未引用;清理逻辑清的是 `ops_snmp_metrics`。 | 若 `ops_snmp_metrics` 已兼任历史表,删除 `SNMPHistory`;否则新增迁移并把查询/清理改到 `monitor_snmp_histories`。 | +| DB-004 | 中 | `approval_processes`、`approval_steps` | 审批流程表无前缀,而模板表是 `sys_approval_templates`。 | 重建后统一为 `approval_templates`、`approval_processes`、`approval_steps`、`approval_workflows`。 | +| DB-005 | 中 | `sys_approval_workflows`、`sys_approval_workflow_steps` | 与 `sys_approval_templates.flow_config` 和 `approval_steps` 职责有重叠。 | 明确“模板流程配置”和“流程定义管理”的关系;如果新工作流已替代模板内 JSON,应迁移数据并下线旧字段。 | +| DB-006 | 中 | `navigation_links` | 导航组是 `sys_navigation_groups`,链接表无前缀。 | 重建后改为 `sys_navigation_links`。 | +| DB-007 | 中 | `metric_values`、`monitor_metric_histories`、`monitor_metrics_aggregate` | 监控时序有三套表:原始值、简化历史、聚合;当前控制器大量使用 `monitor_metric_histories`,`metric_values` 引用少。 | 重建后合并为 `monitor_metric_samples` + `monitor_metric_aggregates`。 | +| DB-008 | 已处理 | `monitor_dashboards`、`monitor_dashboard_panels` | 自定义大屏旧模型与路由已删除,统一使用 `monitor_dashboards` + `monitor_dashboard_panels`。 | 后续仅补齐新大屏 API/页面。 | +| DB-009 | 中 | `role_permissions` | GORM many2many 明确指定为 `role_permissions`,无 `iam_` 前缀。 | 重建后改为 `iam_role_permissions`。 | +| DB-010 | 中 | `sys_log_sources`、`sys_log_entries` | 旧日志采集/入库表,与当前日志中心外部数据源架构重复。 | 重建后删除,日志中心只保留 `monitor_log_datasources` 和外部查询结果。 | +| DB-011 | 中 | `ops_site_*`、`ops_snmp_*` | 站点拨测和 SNMP 属于监控域,但表名前缀使用 `ops_`。 | 重建后改为 `monitor_site_*`、`monitor_snmp_*`。 | +| DB-012 | 中 | `ai_runs`、`ops_sessions`、`ops_commands` | AI 运行表和人机执行会话跨 `ai_` / `ops_`,边界不清。 | 重建后改为 `ai_ops_sessions`、`ai_ops_commands`。 | +| DB-013 | 低 | `backend/model/monitor.go`、`deploy_tool.go` | 模型文件超过或接近项目红线,维护成本高。 | 后续按子域拆分:target/metric/dashboard/health_probe/tool_registry。 | + +## 菜单域表清单 + +### 仪表盘 + +| 功能 | 表 | 评价 | +| --- | --- | --- | +| 首页统计 | 多域聚合,无独立主表 | 统计来自资产、告警、K8s、流水线等表,合理。 | +| 自定义监控仪表盘 | `monitor_dashboards`、`monitor_dashboard_panels` | 已统一到监控大屏主表和面板表。 | + +### 资产管理 / 云资源 + +| 功能 | 表 | 评价 | +| --- | --- | --- | +| 服务器管理 | `asset_servers` | 前缀规范;字段较多但与 Agent/RDP/SSH 场景匹配。 | +| 主机组 | `asset_host_groups` | 放资产域合理。 | +| 云平台 | `asset_clouds` | 命名偏泛,实际是云平台类型/供应商。可考虑改为 `asset_cloud_platforms`。 | +| 云账号 | `asset_cloud_accounts` | 前缀规范;凭证字段需确认是否已统一走 `iam_credentials` 加密。 | +| 云区域 | `asset_regions` | 前缀规范;如果仅云区域,建议 `asset_cloud_regions` 更清晰。 | +| 域名管理 | `asset_domains` | 前缀规范。 | +| 资源型号 | `asset_resource_models` | 前缀规范。 | +| 云账单 | `asset_cloud_billings` | 前缀规范。 | +| 账单告警规则 | `monitor_billing_alert_rules` | 归入监控域合理,但与资产账单强关联,需文档化。 | + +### Kubernetes + +| 功能 | 表 | 评价 | +| --- | --- | --- | +| 集群管理 | `kubernetes_clusters` | 当前统一 `kubernetes_` 前缀,虽较长但一致。 | +| 节点缓存 | `kubernetes_nodes` | 从集群同步缓存,合理。 | +| Pod 缓存 | `kubernetes_pods` | 从集群同步缓存,合理。 | +| Deployment 缓存 | `kubernetes_deployments` | 从集群同步缓存,合理。 | +| Service 缓存 | `kubernetes_services` | 从集群同步缓存,合理。 | +| Ingress 缓存 | `kubernetes_ingresses` | 从集群同步缓存,合理。 | +| 拓扑布局 | `kubernetes_topology_layouts` | 用户布局数据,合理。 | + +建议:重建后统一改为 `k8s_`,后续不再新增 `kubernetes_` 表。 + +### 应用发布 / CI/CD + +| 功能 | 表 | 评价 | +| --- | --- | --- | +| 流水线 | `ops_pipelines` | 历史命名属 `ops_`,但该域现在已经有 `deploy_` 前缀,建议长期统一。 | +| 流水线运行 | `ops_pipeline_runs` | 同上;日志存在 `logs longtext`,数据增长需清理策略。 | +| 应用发布 | `deploy_app_releases` | 前缀规范。 | +| Git 仓库配置 | `deploy_pipeline_repos` | 可能与统一凭据 `iam_credentials` 存在职责重叠,当前通过 `credential_id` 关联可接受。 | +| 镜像仓库配置 | `deploy_pipeline_registries` | 同上。 | +| 发布环境 | `deploy_environments` | 前缀规范。 | +| 构建节点 | `deploy_build_nodes`、`deploy_build_master_config` | 前缀规范。 | +| 流水线成员 | `deploy_pipeline_members` | 前缀规范;直接用户 + 用户组双授权设计合理。 | +| 发布用户组 | `deploy_user_groups`、`deploy_user_group_members` | 前缀规范,但与 IAM 角色/组职责需边界清晰。 | +| ItemRole | `iam_item_roles`、`iam_user_item_roles` | 放 IAM 域合理,用于流水线正则授权。 | +| 工具配置 | `deploy_tools` | 前缀规范。 | + +建议:重建后统一改为 `deploy_pipelines`、`deploy_pipeline_runs`。 + +### 监控告警 + +| 功能 | 表 | 评价 | +| --- | --- | --- | +| 告警规则/事件 | `monitor_alert_rules`、`monitor_alert_events` | 主表合理;但规则模型重复是高风险问题。 | +| 告警条件/模板/升级/静默 | `monitor_alert_conditions`、`monitor_alert_notify_templates`、`monitor_alert_upgrades`、`monitor_alert_upgrade_records`、`monitor_alert_silences` | 前缀规范;需确认哪些已接入 UI/引擎。 | +| 通知渠道/联系人 | `monitor_notify_channels`、`monitor_alert_contacts`、`monitor_alert_contact_groups`、`monitor_alert_notify_policies`、`monitor_alert_notify_settings` | 前缀规范;通知设置/策略/模板职责较多,建议后续画数据流。 | +| Agent 指标 | `monitor_agent_reports`、`monitor_metrics_aggregate` | 前缀规范;按 hostname 关联服务器,删除服务器时有清理。 | +| Agent 任务/规则/告警 | `monitor_agent_tasks`、`monitor_agent_rules`、`monitor_agent_alerts` | 前缀规范。 | +| Agent 端口策略 | `monitor_agent_port_policy` | 未迁移,且表名非复数。 | +| 监控目标/指标 | `monitor_targets`、`monitor_metrics`、`monitor_metric_histories`、`monitor_metric_tags`、`metric_values` | 需要统一时序表职责和命名前缀。 | +| 健康探针 | `monitor_health_probes` | 前缀规范;结果不独立入库,随 Agent 心跳更新。 | +| Prometheus/Zabbix/日志/链路数据源 | `monitor_prometheus_datasources`、`monitor_zabbix_datasources`、`monitor_log_datasources`、`monitor_trace_datasources` | 前缀规范。 | +| 站点拨测 | `ops_site_tasks`、`ops_site_results`、`ops_site_alerts` | 功能属监控,前缀却是 `ops_`。 | +| SNMP | `ops_snmp_devices`、`ops_snmp_templates`、`ops_snmp_template_oids`、`ops_snmp_metrics`、`ops_snmp_interfaces`、`snmp_history` | 功能属监控;`snmp_history` 未迁移/未引用。 | +| 指标目录旧表 | `monitor_metric_data` | 需确认是否仍有 UI/引擎依赖;可能与 `monitor_metrics` 重复。 | + +### 自动化运维 + +| 功能 | 表 | 评价 | +| --- | --- | --- | +| 定时任务 | `ops_cron_jobs` | 前缀规范。 | +| 脚本库 | `ops_scripts` | 前缀规范。 | +| 执行记录 | `ops_task_executions` | 前缀规范。 | +| Ansible | `ops_ansible_playbooks`、`ops_ansible_jobs`、`ops_ansible_inventories` | 有模型和种子数据;需确认 UI 是否仍作为主功能。若已由 Go-native SSH 替代,可降级为 deprecated。 | + +### AIOps / AI + +| 功能 | 表 | 评价 | +| --- | --- | --- | +| LLM 配置 | `ai_llm_configs` | 前缀规范。 | +| 对话历史 | `ai_chat_histories` | 前缀规范。 | +| 告警分析轨迹 | `ai_analysis_traces` | 前缀规范。 | +| Agent CLI 运行 | `ai_runs`、`ai_run_messages`、`ai_run_spans` | 前缀规范。 | +| 能力注册/策略 | `ai_capabilities`、`ai_capability_policies` | 前缀规范。 | +| 人机执行会话 | `ops_sessions`、`ops_commands` | 与 AI Agent 强关联但用 `ops_`,边界需确认。 | + +### 资源申请 / 审批 + +| 功能 | 表 | 评价 | +| --- | --- | --- | +| 审批模板 | `sys_approval_templates` | 前缀规范。 | +| 审批实例 | `approval_processes` | 无前缀。 | +| 审批步骤 | `approval_steps` | 无前缀。 | +| 审批工作流 | `sys_approval_workflows`、`sys_approval_workflow_steps` | 前缀规范,但与模板内 `flow_config` 和实例步骤存在职责重叠。 | + +建议:先确认现行前端使用旧模板流程还是新 workflow;再决定迁移方向。 + +### 安全审计 + +| 功能 | 表 | 评价 | +| --- | --- | --- | +| 操作审计 | `sys_audit_logs` | 前缀规范。 | +| 审计策略 | `sys_audit_settings` | 前缀规范。 | +| 登录日志 | `sys_login_logs` | 前缀规范。 | +| 终端会话 | `sys_terminal_sessions` | 前缀规范。 | +| RDP 会话 | `sys_rdp_sessions` | 前缀规范。 | +| 会话命令 | `sys_session_commands` | 前缀规范。 | + +### 系统管理 / IAM + +| 功能 | 表 | 评价 | +| --- | --- | --- | +| 用户 | `iam_users` | 前缀规范。 | +| 角色 | `iam_roles` | 前缀规范。 | +| 权限 | `iam_permissions` | 前缀规范。 | +| 用户角色关系 | `iam_user_roles` | GORM many2many 指定,前缀规范。 | +| 角色权限关系 | `role_permissions` | GORM many2many 指定,无 `iam_` 前缀。 | +| LDAP | `iam_ldap_configs` | 前缀规范。 | +| 凭据 | `iam_credentials` | 前缀规范;建议继续作为全局凭据统一入口。 | +| 日志范围权限 | `monitor_log_scope_roles`、`monitor_user_log_scope_roles` | 属日志中心/监控域,放 `monitor_` 合理。 | +| 系统任务状态 | `sys_system_task_status` | 前缀规范。 | +| 站内导航 | `sys_navigation_groups`、`navigation_links` | 链接表无前缀。 | + +## 疑似可清理候选 + +测试库重建时可直接删除;生产整改前仍需查数据量、最近写入时间、controller/frontend 是否仍有入口。 + +| 表/模型 | 判断依据 | 建议动作 | +| --- | --- | --- | +| `snmp_history` / `SNMPHistory` | 未 AutoMigrate,未被业务引用。 | 重建后删除模型。 | +| `sys_log_sources`、`sys_log_entries` | 旧日志入库模块,与日志中心外部数据源重复。 | 重建后删除。 | +| `monitor_custom_dashboards` | 与 `monitor_dashboards` 重复。 | 已删除模型与路由,重建后不再创建。 | +| `metric_values`、`monitor_metric_histories` | 时序职责重复。 | 重建后合并为 `monitor_metric_samples`。 | +| `ops_ansible_*` | 远程执行主路径已偏 Go-native SSH。 | 若确认无页面入口,重建后删除;否则保留在 `ops_`。 | +| `deploy_pipeline_repos`、`deploy_pipeline_registries` | Pipeline 已直接使用 `GitCredentialID` / `RegistryCredentialID`。 | 已删除模型、路由和前端旧配置入口,重建后不再创建。 | + +## 迁移清单之外的 8 个模型决策(2026-08-04) + +以下清单由 `TableName()` 与原 AutoMigrate 列表逐项比对得出。生产库不得再通过启动期 AutoMigrate 隐式创建这些表;后续仅允许由版本化迁移创建已确认保留的表。 + +| 模型 / 表 | 决策 | 依据与后续动作 | +| --- | --- | --- | +| `LogSource` / `sys_log_sources` | 保留 | 已有日志源 CRUD 路由。纳入首批基线迁移并保留数据。 | +| `LogEntry` / `sys_log_entries` | 保留 | 已有查询与 Agent 日志写入逻辑。纳入首批基线迁移,并在保留策略中按高频日志数据处理。 | +| `MetricData` / `monitor_metric_data` | DTO 化 | 仅被仪表盘作为 API 响应结构使用,不读写该表。后续移除 `TableName()`,不再创建或写入该表。 | +| `MetricTag` / `monitor_metric_tags` | 暂不建表 | 当前没有 controller、service 或前端调用。保留类型定义待“指标样本模型”统一设计完成,再以独立迁移启用。 | +| `MetricValue` / `monitor_metric_values` | 暂不建表 | 当前没有读写路径,且与 `monitor_metric_histories` 职责重叠。统一时序模型前不得创建新表。 | +| `MonitorDashboard` / `monitor_dashboards` | 废弃 | 无业务引用;当前大屏走既有配置与外部数据源路径。后续删除模型定义,不纳入迁移。 | +| `DashboardPanel` / `monitor_dashboard_panels` | 废弃 | 仅被废弃 `MonitorDashboard` 关联引用。后续随父模型删除,不纳入迁移。 | +| `SNMPHistory` / `snmp_history` | 废弃 | 无业务引用,SNMP 当前实际数据表为 `ops_snmp_metrics`。后续删除模型,不纳入迁移。 | + +> 上述“暂不建表”和“废弃”均不执行生产 DROP TABLE;删除旧表须先完成数据量、最近写入时间、备份与恢复演练检查。 + +## 后续整改顺序 + +1. **先统一命名和模型归属**:按 Schema v2 修改 `TableName()`、many2many tag、硬编码 SQL。 +2. **处理重复模型**:合并 `AlertRule` / `MonitorAlertRule`、大屏表、指标时序表、审批 workflow/模板关系。 +3. **删除废弃模型**:`SNMPHistory`、旧日志入库、旧仓库/镜像仓库配置、确认不用的 Ansible 表。 +4. **重建测试库**:删除 SQLite 文件或 Docker 数据卷,让 AutoMigrate 从零建表。 +5. **补充数据生命周期**:流水线日志、Agent 报告、指标样本、SNMP 样本、站点拨测结果、审计日志都需要保留期和清理任务。 diff --git a/docs/deployment.md b/docs/deployment.md index 5680455..4eae69f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -160,9 +160,14 @@ mysql:8.0 + redis:7-alpine + aiops (Dockerfile) 环境变量(可选): ```bash -VERSION=v1.5.0 MYSQL_PASSWORD=xxx ./run.sh docker up +cp .env.example .env +chmod 600 .env +# 填写 MYSQL_ROOT_PASSWORD、MYSQL_PASSWORD、JWT_SECRET 后再启动 +VERSION=v1.5.0 ./run.sh docker up ``` +Compose 不再为密码或 JWT 提供不安全默认值;缺少任一必要值会拒绝启动。MySQL/Redis 端口仅绑定本机回环地址,Docker Socket 不会挂载给 AIOps 容器。 + ### 推送镜像 ```bash @@ -172,12 +177,14 @@ VERSION=v1.5.0 MYSQL_PASSWORD=xxx ./run.sh docker up ### 连接外部数据库 -使用 `docker-compose.k8s.yml`(仅单应用容器,挂载 `backend/config.external.yaml`): +使用 `docker-compose.k8s.yml`(仅单应用容器,连接外部 MySQL/Redis): ```bash VERSION=v1.5.0 docker compose -f docker-compose.k8s.yml up -d --build ``` +该方式要求在 `.env`(权限 `600`)提供 `DATABASE_HOST`、`DATABASE_USER`、`DATABASE_PASSWORD`、`REDIS_HOST` 和 `JWT_SECRET`;uploads 使用命名卷持久化。 + ### 管理命令 ```bash @@ -268,10 +275,12 @@ K8s 探针使用 `/version`。 ## 生产配置建议 1. **数据库**:生产使用 MySQL + Redis(`MODE=release`),local 模式仅适合单副本 -2. **JWT Secret**:更换默认密钥 -3. **版本**:生产环境 pin 镜像 tag(`lghost/aiops:v1.5.0`),避免使用 `:dev` -4. **HTTPS**:Ingress 或前置 Nginx 终止 TLS -5. **备份**:定期备份 MySQL 数据和 PVC +2. **Secret**:通过 Secret 管理系统或权限为 `600` 的 `.env` 注入数据库密码和至少 32 字节 JWT;不得提交真实值 +3. **运行身份**:镜像默认以非 root UID `10001` 运行;不得恢复 Docker Socket 或 `hostNetwork` 挂载 +4. **持久化**:备份 MySQL 数据、SQLite/redka PVC 和 uploads PVC,并定期验证恢复 +5. **网络**:K8s 的 `deployment.yaml` 内置 MySQL/Redis NetworkPolicy;在收集 Ingress/Agent 出口 CIDR 后继续收紧 AIOps 入站规则 +6. **版本**:生产环境 pin 镜像 tag(`lghost/aiops:v1.5.0`),避免使用 `:dev` +7. **HTTPS**:Ingress 或前置 Nginx 终止 TLS --- @@ -283,4 +292,4 @@ K8s 探针使用 `/version`。 | `Dockerfile` | embed 单容器镜像(推荐) | | `docker-compose.yml` | MySQL + Redis + AIOps | | `deploy/kubernetes/deployment.yaml` | K8s Deployment + NodePort | -| `.gitignore` | 忽略 `aiops`、`backend/aiops*` 等编译产物 | \ No newline at end of file +| `.gitignore` | 忽略 `aiops`、`backend/aiops*` 等编译产物 | diff --git a/docs/development-guide.md b/docs/development-guide.md index 45005f2..8ea2a29 100644 --- a/docs/development-guide.md +++ b/docs/development-guide.md @@ -51,7 +51,7 @@ feat: 添加服务器批量导入功能 | `middleware/` | JWT / 日志 / CORS 中间件 | | `router/` | 路由注册 | | `service/` / `repository/` | 业务逻辑 / 数据访问 | -| `pkg/database/` | 数据库连接 + AutoMigrate | +| `pkg/database/` | 数据库连接 + 显式基线建表能力 | | `pkg/agentcli/` | Agent CLI 引擎(ReAct + Evolver) | | `pkg/remoteexec/` | Go-native SSH 远程执行 | | `static/` | 前端 embed 静态资源 | @@ -235,6 +235,7 @@ go build -o aiops . cd frontend npm run build npx tsc --noEmit # 类型检查(提交前零错误) +npm run lint # ESLint(提交前零错误) ``` ### 独立二进制构建 @@ -247,9 +248,15 @@ npx tsc --noEmit # 类型检查(提交前零错误) ### 集成测试 ```bash -python3 tests/integration_test.py --host localhost --port 8080 +# 测试账号必须显式传入,避免对生产或共享环境误用默认凭证 +AIOPS_TEST_USERNAME=admin AIOPS_TEST_PASSWORD='测试密码' \ + python3 tests/integration_test.py --host localhost --port 8080 ``` +### GitHub Actions CI + +`.github/workflows/ci.yml` 在 `master` / `dev` 的推送与 Pull Request 上并行执行后端、Agent、前端和 SQLite + redka API 集成测试。前端 ESLint 采用阻断模式;仓库已有的 lint 错误会使 CI 失败,必须修复后才会恢复通过,不能以忽略错误的方式放行。 + ## 日志 | 级别 | 使用场景 | diff --git a/docs/quick-start.md b/docs/quick-start.md index 3910c23..7459f5b 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -112,7 +112,7 @@ docker login ### 开发模式 -默认 SQLite,数据库由后端自动迁移(AutoMigrate)。 +默认 SQLite。首次初始化请显式执行 `./aiops migrate up`;服务启动不会自动修改数据库结构。详见 [数据库版本化迁移](database-migrations.md)。 // Logan.Li ### local 二进制模式 diff --git a/docs/reference/database-schema.md b/docs/reference/database-schema.md index 1773a0a..4995636 100644 --- a/docs/reference/database-schema.md +++ b/docs/reference/database-schema.md @@ -7,7 +7,7 @@ AIOps 使用 GORM 作为 ORM 框架,支持 SQLite / MySQL / PostgreSQL。 - **默认**: SQLite(零配置,数据文件 `backend/data/aiops.db`) - **生产**: 推荐使用 MySQL 或 PostgreSQL -首次启动时,`AutoMigrate` 会自动创建所有表。 +首次初始化请显式执行 `aiops migrate up` 创建基线;服务启动时不会自动修改表结构。迁移流程见 [数据库版本化迁移](../database-migrations.md)。 // Logan.Li ## 数据模型 @@ -168,18 +168,9 @@ approval_templates ──< approval_processes ──< approval_steps navigation_groups ──< navigation_links ``` -## AutoMigrate +## 版本化迁移 -表结构在 `backend/pkg/database/database.go` 中定义,服务启动时自动执行: - -```go -func AutoMigrate(db *gorm.DB) error { - return db.AutoMigrate( - &models.User{}, &models.Role{}, &models.Permission{}, - &models.Server{}, &models.Cloud{}, ... - ) -} -``` +模型基线由 `backend/pkg/database/database.go` 的 `BootstrapSchema` 定义,但只允许由 `aiops migrate up` 显式调用。后续变更按版本存放在 `backend/migrations/`,并记录至 `sys_schema_migrations`;已运行的服务不会在启动期间执行迁移。 // Logan.Li ## 切换数据库 diff --git a/docs/specs/README.md b/docs/specs/README.md new file mode 100644 index 0000000..acd87a2 --- /dev/null +++ b/docs/specs/README.md @@ -0,0 +1,105 @@ +# AIOps 规格驱动开发规范 + +本目录保存会改变系统行为、接口、数据或运维方式的工程规格。规格的作用是让需求、实现、测试和发布使用同一套可验证契约。 + +## 1. 适用范围 + +按最高影响级别判定,不以改动行数判断: + +| 级别 | 范围 | 必需产物 | +|------|------|----------| +| S0 | 文案、格式、注释、无行为变化的机械修改 | 验证记录 | +| S1 | 局部缺陷、内部重构、单模块小改动 | 任务计划中的问题、约束和验收标准 | +| S2 | 新功能、跨模块流程、API、配置、数据模型变更 | `docs/specs//.md` | +| S3 | 权限、安全、账单、迁移、数据清理、生产部署 | S2 规格 + 风险、兼容、可观测、发布、回滚 | + +连续开发同一能力时更新已有规格,不重复创建。纯探索或评估可以先输出调研结论;一旦进入实现,必须补齐对应级别的规格。 + +## 2. 事实与优先级 + +规格必须基于仓库和运行环境的真实证据。发生冲突时按以下顺序处理: + +1. 用户当前明确需求与已确认决策。 +2. 已接受规格中的验收标准和契约。 +3. 安全、数据保护和生产变更规则。 +4. 自动化测试表达的现有行为。 +5. 当前实现与一般文档。 + +当前实现与规格不一致时不得自行选择其一:先确认这是缺陷、兼容要求还是规格过期,然后更新规格中的决策记录。 + +## 3. 生命周期 + +规格状态使用:`Draft`、`Accepted`、`Implemented`、`Verified`、`Superseded`。 + +1. **Discover**:定位入口、调用链、数据模型、权限、外部依赖、现有测试和工作区改动。 +2. **Specify**:定义目标和非目标,用 `FR-*`、`NFR-*`、`AC-*` 编号描述行为与验收。 +3. **Design**:明确模块边界、契约、失败路径、迁移、观测和回滚,记录关键取舍。 +4. **Implement**:小步实现并保持范围受控;新增发现通过规格的决策记录显式吸收。 +5. **Verify**:建立 `AC → 测试/检查 → 结果` 的可追踪关系,覆盖成功、失败和边界路径。 +6. **Deliver**:更新状态、列出变更、验证证据、部署结果、遗留风险与后续工作。 + +用户需求已经足够明确时,维护者可将规格标记为 `Accepted` 并继续实现。只有未决选择会改变用户可见行为、数据安全、权限或部署风险时才需要等待确认。 + +## 4. 规格质量要求 + +### 4.1 需求必须可验证 + +- 使用“当……时,系统必须……”描述行为,避免“优化一下”“尽量”“支持更多”等不可验证表述。 +- 每项功能需求使用 `FR-01` 编号,每项非功能需求使用 `NFR-01` 编号。 +- 每条验收标准使用 `AC-01` 编号,并给出输入、动作和可观察结果。 +- 非目标必须明确,防止实现阶段自然扩张范围。 + +### 4.2 契约必须先于实现 + +- HTTP/API:路径、方法、请求、响应、错误码、鉴权、幂等和分页口径。 +- 数据:表/字段、约束、索引、默认值、迁移顺序、回填、兼容窗口和回滚限制。 +- UI:入口、权限、加载/空/错误/禁用状态、响应式行为和中英文文案。 +- 任务/事件:触发方式、重试、超时、去重、并发、状态机和审计记录。 +- 外部系统:限流、超时、部分失败、降级、凭证边界和供应商差异。 + +### 4.3 AIOps 领域约束 + +- 金额:原币、报告币、汇率来源、生效时间、舍入精度和汇总维度必须明确。 +- 时间:时区、采样区间、自然日/月边界、数据截止时间和缺失数据处理必须明确。 +- 监控:指标单位、标签基数、采样频率、保留期、告警迟滞和恢复条件必须明确。 +- 自动化:执行身份、目标范围、危险操作确认、超时、取消、重试和审计必须明确。 +- AI:输入数据范围、提示上下文、置信度含义、不可用降级和结果免责声明必须明确。 + +## 5. 验证门禁 + +验证范围随风险扩大,但至少包括受影响模块: + +| 变更 | 最低验证 | +|------|----------| +| Go 后端 | 相关 `go test`、`go vet`、`go build` | +| React 前端 | `tsc --noEmit`、目标 ESLint、生产构建、关键页面验证 | +| Agent | `go test ./...`、`CGO_ENABLED=0 go build`、协议兼容检查 | +| API/跨模块 | Handler/服务测试 + 前后端契约检查 + 关键流程验证 | +| 数据库 | 各支持方言迁移测试、幂等性、备份与回滚演练说明 | +| 生产部署 | `/version`、迁移状态、进程/日志、关键只读接口、回滚点 | + +不能运行的检查必须记录具体原因和残余风险。既有失败要区分是否由本次变更引入,不能静默忽略。 + +## 6. 完成定义 + +只有同时满足以下条件,规格才可标记为 `Verified`: + +- 所有 `FR-*` 已实现,所有 `AC-*` 都有通过证据。 +- API、数据、权限、审计、错误状态和兼容行为与规格一致。 +- 相关自动化测试、静态检查和构建通过。 +- S3 已验证备份、发布、健康检查和回滚路径。 +- 用户文档、运维文档、配置示例和规格状态已同步。 +- 未完成项和已知风险已显式记录,不用模糊措辞掩盖。 + +## 7. 文件组织 + +```text +docs/specs/ +├── README.md +├── _template.md +├── cloud/billing-normalization.md +├── monitor/site-probe-retention.md +└── deploy/pipeline-jenkins.md +``` + +文件名使用小写短横线。规格中引用代码、API、迁移和测试的实际路径。重大方向被替代时保留原规格并标记 `Superseded`,不要删除决策历史。 diff --git a/docs/specs/_template.md b/docs/specs/_template.md new file mode 100644 index 0000000..92536cc --- /dev/null +++ b/docs/specs/_template.md @@ -0,0 +1,88 @@ +# <规格名称> + +| 字段 | 内容 | +|------|------| +| 状态 | Draft | +| 级别 | S2 / S3 | +| 负责人 | Logan.Li | +| 创建日期 | YYYY-MM-DD | +| 最后更新 | YYYY-MM-DD | +| 关联任务 | <链接或说明> | + +## 1. 背景与证据 + +描述当前行为、问题和触发本变更的证据,引用实际代码、接口、日志或数据。 + +## 2. 目标与非目标 + +### 目标 + +- <本次必须达成的结果> + +### 非目标 + +- <明确不在本次处理的事项> + +## 3. 用户场景 + +- 当 <角色> 在 <条件> 下执行 <动作> 时,系统必须 <可观察结果>。 + +## 4. 需求 + +### 功能需求 + +- `FR-01`:<确定、可验证的行为要求>。 + +### 非功能需求 + +- `NFR-01`:<性能、安全、可靠性、兼容性或可维护性要求>。 + +## 5. 设计与契约 + +### 模块边界 + +说明复用点、所有权和不应新增的重复能力。 + +### API / 事件 + +列出路径、方法、请求、响应、错误码、权限、幂等、超时与重试。 + +### 数据与迁移 + +列出字段、约束、索引、默认值、回填、兼容窗口、数据量影响和迁移版本。 + +### UI / 交互 + +列出入口、权限、加载、空、错误、禁用、响应式和国际化状态。 + +## 6. 风险与可观测性 + +| 风险/失败模式 | 防护或降级 | 观测信号 | +|---------------|------------|----------| +| <风险> | <处理方式> | <日志/指标/任务状态> | + +## 7. 验收标准 + +- `AC-01`:给定 <前置条件>,当 <动作>,则 <可观察结果>。 +- `AC-02`:给定 <异常或边界条件>,当 <动作>,则 <失败行为或降级结果>。 + +## 8. 验证追踪 + +| 验收标准 | 测试或检查 | 结果 | +|----------|------------|------| +| AC-01 | `<命令、测试名或人工步骤>` | Pending | +| AC-02 | `<命令、测试名或人工步骤>` | Pending | + +## 9. 发布与回滚 + +说明发布顺序、配置/迁移前置条件、健康检查、备份、回滚步骤和不可逆事项。S3 必填。 + +## 10. 决策记录 + +| 日期 | 决策 | 原因与影响 | +|------|------|------------| +| YYYY-MM-DD | <决定> | <依据、取舍和影响范围> | + +## 11. 遗留事项 + +- <不阻塞本次验收但需要后续跟踪的工作> diff --git a/docs/specs/repository/source-reconciliation.md b/docs/specs/repository/source-reconciliation.md new file mode 100644 index 0000000..eaa7d2b --- /dev/null +++ b/docs/specs/repository/source-reconciliation.md @@ -0,0 +1,92 @@ +# 本地、生产服务器与远程仓库源码统一 + +| 字段 | 内容 | +|------|------| +| 状态 | Accepted | +| 级别 | S3 | +| 负责人 | Logan.Li | +| 创建日期 | 2026-08-05 | +| 最后更新 | 2026-08-05 | +| 关联任务 | 以 `/srv/aiops` 为主统一本地、服务器和 Git 远程仓库 | + +## 1. 背景与证据 + +本地工作区位于 `main@7a16808`,服务器工作区位于 `master@f22e575`,两端分别与对应远程分支一致,但都有大量未提交改动。内容审计发现服务器有 131 个本地不存在的源码文件,本地有 12 个服务器不存在的近期工程文件,另有 39 个同路径文件内容不同。当前生产二进制包含部分本地独有后端能力,因此直接单向覆盖任一工作区都会造成代码或可复现性丢失。 + +## 2. 目标与非目标 + +### 目标 + +- 以服务器源码结构和行为为主形成唯一可构建源码。 +- 保留本地独有且已验证或已进入生产二进制的功能,不丢失近期账单、迁移、数据保留和 CI 工程能力。 +- 让远程 `master` 与 `main`、本地工作区和服务器源码指向同一提交。 +- 保留变更前完整备份,并验证构建、测试、迁移和生产健康状态。 + +### 非目标 + +- 不提交生产 `config.yaml`、密钥、数据库、日志、备份、运行 PID 或历史二进制。 +- 不在本次整理业务数据或执行数据库清理。 +- 不改变生产运行参数和用户凭据。 + +## 3. 需求 + +### 功能需求 + +- `FR-01`:统一提交必须包含服务器权威源码和本地独有的有效工程文件。 +- `FR-02`:远程 `master` 与 `main` 必须最终解析到同一提交。 +- `FR-03`:本地和服务器的受管源码必须与统一提交一致。 +- `FR-04`:生产配置和运行数据必须保留在 Git 管理范围之外或保持未被覆盖。 + +### 非功能需求 + +- `NFR-01`:任何覆盖、删除或分支更新前必须生成可验证备份。 +- `NFR-02`:统一源码必须通过后端、前端和 Agent 的最低构建门禁。 +- `NFR-03`:生产服务切换后 `/version`、迁移状态、进程和错误日志必须正常。 + +## 4. 设计与边界 + +从 `origin/master` 创建独立 Git worktree。将服务器源码复制到该工作区,排除 `.git`、数据库、配置密钥、日志、缓存、备份、构建产物和历史二进制;再合并本地独有功能文件与本规格。测试通过后创建单一整合提交,并以普通快进或带租约保护的方式更新远程分支。 + +本地与服务器现有工作区在对齐前分别归档。对齐时仅重置受 Git 管理的源码;生产 `config.yaml`、数据库、日志、备份和运行文件保持不变。 + +## 5. 风险与可观测性 + +| 风险/失败模式 | 防护或降级 | 观测信号 | +|---------------|------------|----------| +| 服务器重构覆盖本地功能 | 隔离合并并保留本地独有文件,构建测试后才提交 | Git diff、测试结果 | +| 误提交生产密钥或数据 | 明确排除运行配置、数据库、日志和备份,并审计提交文件 | `git status`、提交清单 | +| 两个远程分支历史不同 | 先保存 bundle,再统一到经过验证的整合提交 | `git ls-remote` | +| 生产源码与二进制不一致 | 用统一源码重建 Linux 二进制并核对版本 | `/version`、二进制 SHA-256 | + +## 6. 验收标准 + +- `AC-01`:给定统一提交,`origin/master` 与 `origin/main` 返回相同提交哈希。 +- `AC-02`:给定统一提交,本地和服务器受管源码执行内容校验时没有差异。 +- `AC-03`:统一源码通过 Go 后端、Agent、TypeScript、ESLint 和生产前端构建检查。 +- `AC-04`:生产服务使用统一源码构建的 Linux 二进制启动,`/version`、迁移状态和日志正常。 +- `AC-05`:生产配置、数据库、日志和备份未进入提交且未被覆盖。 + +## 7. 验证追踪 + +| 验收标准 | 测试或检查 | 结果 | +|----------|------------|------| +| AC-01 | `git ls-remote origin refs/heads/main refs/heads/master` | Pending | +| AC-02 | 双向 `rsync --dry-run --checksum --delete`(排除运行文件) | Pending | +| AC-03 | `go test/go vet/go build`、`tsc`、ESLint、Vite build、Agent build | Pass(2026-08-05) | +| AC-04 | Linux 构建、`/version`、`migrate status`、进程和日志检查 | Pending | +| AC-05 | 提交文件审计与生产文件哈希/存在性检查 | Pending | + +## 8. 发布与回滚 + +发布前保存本地和服务器源码归档、Git bundle、生产二进制及数据库现有备份。若统一源码验证失败,不更新远程分支或现有工作区。若生产切换失败,恢复上一二进制并保留统一提交供离线修复。远程分支更新使用预期旧哈希或 `--force-with-lease` 防止覆盖并发提交。 + +## 9. 决策记录 + +| 日期 | 决策 | 原因与影响 | +|------|------|------------| +| 2026-08-05 | 服务器源码为结构基线,本地独有有效能力采用合并而非删除 | 服务器包含较新的组件拆分,本地包含当前生产二进制所需的新能力 | +| 2026-08-05 | 生产配置与运行数据不纳入源码一致性 | 防止泄密和破坏运行环境 | + +## 10. 遗留事项 + +- 统一完成后应使用同一主分支开发,禁止继续在生产工作区直接形成长期未提交改动。 diff --git a/docs/sqlite-maintenance-runbook.md b/docs/sqlite-maintenance-runbook.md new file mode 100644 index 0000000..a372a21 --- /dev/null +++ b/docs/sqlite-maintenance-runbook.md @@ -0,0 +1,30 @@ +# SQLite 维护与恢复演练 Runbook + +适用范围:独立二进制或 local 模式的 SQLite 数据库。生产维护只能在批准的维护窗口执行;高峰期及存在活跃写入时禁止 `VACUUM`。 + +## 维护前检查 + +1. 确认近期备份可读取,并记录当前数据库、WAL 文件大小。 +2. 在发布窗口停止 AIOps 写入进程;`VACUUM` 期间必须保持停写。 +3. 使用绝对路径,禁止将 `/`、工作区或未核实的路径作为维护目标。 + +```bash +./scripts/sqlite-maintenance.sh \ + --maintenance-window \ + --db /srv/aiops/sqlite3.db \ + --backup-dir /srv/aiops/backups/sqlite \ + --pid-file /srv/aiops/.aiops.pid +``` + +脚本会先执行 SQLite `.backup`,对备份运行 `PRAGMA integrity_check`,再执行 `wal_checkpoint(TRUNCATE)`、`VACUUM` 和最终完整性检查。备份校验失败时不会进入空间回收步骤。 + +## 恢复演练 + +1. 在隔离测试目录复制一份备份,绝不直接覆盖生产库。 +2. 用同版本二进制指向副本启动,并验证 `/version`、管理员登录、服务器列表和近期 Agent 指标查询。 +3. 对副本执行 `PRAGMA integrity_check`,并记录恢复耗时、RPO、RTO 与验证结果。 +4. 仅当演练通过后,更新备份巡检记录;生产恢复须由值班负责人批准并保留原库只读副本。 + +## 线上当前风险 + +`sqlite3.db` 与 WAL 已达到多 GB 级别。请先完成备份恢复演练,再安排维护窗口;本次整改不会在在线服务上直接运行 `VACUUM`。 diff --git a/frontend/node_modules b/frontend/node_modules new file mode 120000 index 0000000..aa0be99 --- /dev/null +++ b/frontend/node_modules @@ -0,0 +1 @@ +/Users/loganli/devops/web全栈开发/aiops/frontend/node_modules \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7d6828d..3e60457 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -62,9 +62,10 @@ "react-i18next": "^17.0.8", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.2.2", - "react-router-dom": "^7.13.2", + "react-router-dom": "^7.18.2", "recharts": "^2.15.4", "remark-gfm": "^4.0.1", + "simple-icons": "^16.27.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "vaul": "^1.1.2", @@ -109,13 +110,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -124,9 +125,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -134,21 +135,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -165,14 +166,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -195,14 +196,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -269,9 +270,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -293,29 +294,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -338,9 +339,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -398,9 +399,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -408,9 +409,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -418,9 +419,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -443,27 +444,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.8", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -1106,16 +1107,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.8", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" }, "engines": { "node": ">=6.9.0" @@ -1812,33 +1813,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -1846,14 +1847,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "http://mirrors.tencentyun.com/npm/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2798,6 +2799,26 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "http://mirrors.tencentyun.com/npm/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4453,9 +4474,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", "cpu": [ "arm" ], @@ -4467,9 +4488,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", "cpu": [ "arm64" ], @@ -4481,9 +4502,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", "cpu": [ "arm64" ], @@ -4495,9 +4516,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", "cpu": [ "x64" ], @@ -4509,9 +4530,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", "cpu": [ "arm64" ], @@ -4523,9 +4544,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", "cpu": [ "x64" ], @@ -4537,13 +4558,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4551,13 +4575,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4565,13 +4592,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4579,13 +4609,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4593,13 +4626,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4607,13 +4643,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4621,13 +4660,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4635,13 +4677,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4649,13 +4694,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4663,13 +4711,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4677,13 +4728,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4691,13 +4745,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4705,13 +4762,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4719,9 +4779,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ "x64" ], @@ -4733,9 +4793,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -4747,9 +4807,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -4761,9 +4821,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ "ia32" ], @@ -4775,9 +4835,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ "x64" ], @@ -4789,9 +4849,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -5006,9 +5066,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "http://mirrors.tencentyun.com/npm/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -5302,9 +5362,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.4", + "resolved": "http://mirrors.tencentyun.com/npm/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -5312,13 +5372,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "http://mirrors.tencentyun.com/npm/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -5486,9 +5546,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "http://mirrors.tencentyun.com/npm/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5540,9 +5600,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "http://mirrors.tencentyun.com/npm/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -5698,9 +5758,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "http://mirrors.tencentyun.com/npm/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -6009,7 +6069,7 @@ }, "node_modules/cookie": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "resolved": "http://mirrors.tencentyun.com/npm/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { @@ -6758,9 +6818,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.4", + "resolved": "http://mirrors.tencentyun.com/npm/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -7179,10 +7239,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.1", + "resolved": "http://mirrors.tencentyun.com/npm/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7318,10 +7388,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" + "version": "4.18.1", + "resolved": "http://mirrors.tencentyun.com/npm/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -8268,9 +8337,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "http://mirrors.tencentyun.com/npm/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -8281,9 +8350,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "http://mirrors.tencentyun.com/npm/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -8312,9 +8381,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.17", + "resolved": "http://mirrors.tencentyun.com/npm/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { @@ -8506,9 +8575,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "http://mirrors.tencentyun.com/npm/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -8539,9 +8608,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.25", + "resolved": "http://mirrors.tencentyun.com/npm/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -8559,7 +8628,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8955,9 +9024,9 @@ } }, "node_modules/react-router": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", - "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", + "version": "7.18.2", + "resolved": "http://mirrors.tencentyun.com/npm/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -8977,12 +9046,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", - "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", + "version": "7.18.2", + "resolved": "http://mirrors.tencentyun.com/npm/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "react-router": "7.13.2" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" @@ -9069,9 +9138,9 @@ } }, "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "http://mirrors.tencentyun.com/npm/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -9280,13 +9349,13 @@ } }, "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "version": "4.62.4", + "resolved": "http://mirrors.tencentyun.com/npm/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -9296,31 +9365,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" } }, @@ -9366,7 +9436,7 @@ }, "node_modules/set-cookie-parser": { "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "resolved": "http://mirrors.tencentyun.com/npm/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, @@ -9393,6 +9463,25 @@ "node": ">=8" } }, + "node_modules/simple-icons": { + "version": "16.27.0", + "resolved": "https://registry.npmmirror.com/simple-icons/-/simple-icons-16.27.0.tgz", + "integrity": "sha512-Rk2dCRVCPHnla9/mHmztJOlbkfncPDUMSOPozQO3+c+vr6Uwy9jhNHeuxxvNZgbF9ZwpoT1BCu1LNB4Q07VmZQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/simple-icons" + }, + { + "type": "github", + "url": "https://github.com/sponsors/simple-icons" + } + ], + "license": "CC0-1.0", + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -10049,13 +10138,12 @@ } }, "node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "version": "7.3.6", + "resolved": "http://mirrors.tencentyun.com/npm/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, - "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", diff --git a/frontend/package.json b/frontend/package.json index 9e4e986..a488307 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit", "check": "npm run typecheck", "check:strict": "npm run lint && npm run typecheck", + "check:i18n": "node scripts/check-i18n-keys.mjs", "lint": "eslint .", "preview": "vite preview" }, @@ -67,9 +68,10 @@ "react-i18next": "^17.0.8", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.2.2", - "react-router-dom": "^7.13.2", + "react-router-dom": "^7.18.2", "recharts": "^2.15.4", "remark-gfm": "^4.0.1", + "simple-icons": "^16.27.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "vaul": "^1.1.2", diff --git a/frontend/scripts/check-i18n-keys.mjs b/frontend/scripts/check-i18n-keys.mjs new file mode 100644 index 0000000..4b13e94 --- /dev/null +++ b/frontend/scripts/check-i18n-keys.mjs @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const sourceRoot = path.resolve('src'); +const locale = JSON.parse(fs.readFileSync(path.resolve('src/locales/zh-CN.json'), 'utf8')); +const files = []; + +function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) walk(file); + else if (/\.(ts|tsx)$/.test(entry.name)) files.push(file); + } +} + +function normalizeLegacyKeys(resource) { + const legacyEntries = []; + const collect = (node, parentPath = []) => { + for (const [key, value] of Object.entries(node)) { + if (typeof value === 'string' && key.includes('.')) legacyEntries.push([parentPath, key, value]); + if (value && typeof value === 'object' && !Array.isArray(value)) collect(value, [...parentPath, key]); + } + }; + const setIfMissing = (segments, value) => { + let node = resource; + for (const segment of segments.slice(0, -1)) { + if (!node[segment] || typeof node[segment] !== 'object') node[segment] = {}; + node = node[segment]; + } + const last = segments.at(-1); + if (node[last] === undefined) node[last] = value; + }; + collect(resource); + legacyEntries.forEach(([parentPath, key, value]) => { + setIfMissing([...parentPath, ...key.split('.')], value); + setIfMissing(key.split('.'), value); + }); +} + +function hasKey(resource, key) { + return key.split('.').every((segment) => { + if (!resource || !Object.hasOwn(resource, segment)) return false; + resource = resource[segment]; + return true; + }); +} + +normalizeLegacyKeys(locale); +walk(sourceRoot); +const missing = new Map(); +const pattern = /(?:\bi18n\.)?t\(\s*['"]([^'"]+)['"]/g; +for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + for (const match of source.matchAll(pattern)) { + const key = match[1]; + // 聚焦历史文案工具生成的哈希键,避免新业务文案的翻译建设阻塞构建。 // Logan.Li + if (/\.s\d[a-z0-9]{7,}$/i.test(key) && !hasKey(locale, key)) missing.set(key, file); + } +} + +if (missing.size > 0) { + for (const [key, file] of missing) console.error(`${key}: ${path.relative(process.cwd(), file)}`); + process.exit(1); +} +console.log(`i18n check passed: ${files.length} source files scanned`); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a4f812c..1130ae9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { Suspense, lazy } from 'react'; +import { Suspense, lazy, useEffect, useRef } from 'react'; import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '@/hooks/useTheme'; @@ -10,6 +10,7 @@ import { PageTransition } from '@/components/layout/PageTransition'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; import { Toaster } from '@/components/ui/sonner'; import i18n from '@/i18n'; +import { toast } from 'sonner'; // Lazy load page components for code splitting const Dashboard = lazy(() => import('@/components/dashboard/Dashboard').then(m => ({ default: m.Dashboard }))); @@ -85,7 +86,7 @@ function LazyPage({ children }: { children: React.ReactNode }) { const location = useLocation(); return ( - + }> {children} @@ -104,12 +105,63 @@ const queryClient = new QueryClient({ }, }); +function AssetVersionGuard() { + const currentVersionRef = useRef(''); + const notifiedRef = useRef(false); + + useEffect(() => { + const readVersion = async () => { + try { + const resp = await fetch(`/version?t=${Date.now()}`, { cache: 'no-store' }); + if (!resp.ok) return; + const data = await resp.json(); + const nextVersion = [data.version, data.git_commit, data.build_time].filter(Boolean).join(':'); + if (!nextVersion) return; + if (!currentVersionRef.current) { + currentVersionRef.current = nextVersion; + return; + } + if (currentVersionRef.current !== nextVersion && !notifiedRef.current) { + notifiedRef.current = true; + toast.info(i18n.t('common.appUpdated'), { + description: i18n.t('common.appUpdatedDesc'), + action: { + label: i18n.t('common.reloadPage'), + onClick: () => window.location.reload(), + }, + duration: Infinity, + }); + } + } catch { + // 版本检查失败不影响业务页面,后续定时器会重试。 // Logan.Li + } + }; + + void readVersion(); + const timer = window.setInterval(readVersion, 60_000); + const handleFocus = () => void readVersion(); + const handleVisibility = () => { + if (document.visibilityState === 'visible') void readVersion(); + }; + window.addEventListener('focus', handleFocus); + document.addEventListener('visibilitychange', handleVisibility); + return () => { + window.clearInterval(timer); + window.removeEventListener('focus', handleFocus); + document.removeEventListener('visibilitychange', handleVisibility); + }; + }, []); + + return null; +} + function App() { return ( + } /> @@ -186,7 +238,6 @@ function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/frontend/src/api/aiops/agent-cli.ts b/frontend/src/api/aiops/agent-cli.ts index a78503c..72e928d 100644 --- a/frontend/src/api/aiops/agent-cli.ts +++ b/frontend/src/api/aiops/agent-cli.ts @@ -2,44 +2,47 @@ import { http } from '@/utils/request'; import { useUserStore } from '@/store'; -// 会话与消息类型(与后端 model.AgentCLISession/Message 对应) // Logan.Li +// 会话与消息类型(与后端 model.AIRun/AIRunMessage 对应) // Logan.Li export interface AgentCliSession { id: number; + run_id: string; + parent_run_id: string; user_id: number; + feature: string; mode: string; // host | kubernetes - server_id: number; - server_name: string; - server_host: string; - cluster_id: number; - cluster_name: string; - namespace: string; - pod_name: string; - container: string; + subject_type: string; + subject_id: string; + subject_name: string; + subject_scope: string; title: string; status: string; // running/done/aborted/error provider: string; model: string; + safety_mode: string; + context_pack_id: string; + prompt_tokens: number; + completion_tokens: number; rounds: number; total_tokens: number; duration: number; error?: string; + started_at: string; + finished_at?: string; created_at: string; updated_at: string; } export interface AgentCliMessage { id: number; - session_id: number; + run_id: string; round: number; + seq: number; type: string; // thought/tool_call/tool_result/done/error/user role: string; content: string; tool_name: string; - tool_args: string; - tool_output: string; - risk: string; - success: boolean; - duration: string; + tool_call_id: string; + span_id: string; tokens: number; created_at: string; } @@ -96,4 +99,4 @@ export function buildAgentCliWsUrl(params: AgentCliWsParams | number | string): } return `${base}?mode=host&id=${params.serverId}&token=${token}`; -} \ No newline at end of file +} diff --git a/frontend/src/api/cloud/account.ts b/frontend/src/api/cloud/account.ts index ebfa3a6..c21bf15 100644 --- a/frontend/src/api/cloud/account.ts +++ b/frontend/src/api/cloud/account.ts @@ -47,4 +47,7 @@ export const cloudApi = { deleteDomain: async (id: number | string) => { return http.delete(`/domains/${id}`); }, + checkDomainSSL: async (id: number | string) => { + return http.post<{ ssl_expire_date: string; ssl_days_remaining: number; ssl_issuer: string }>(`/domains/${id}/check-ssl`); + }, }; diff --git a/frontend/src/api/cloud/billing.ts b/frontend/src/api/cloud/billing.ts index 9fb0b76..a948e5d 100644 --- a/frontend/src/api/cloud/billing.ts +++ b/frontend/src/api/cloud/billing.ts @@ -3,13 +3,20 @@ import { http, type ListResponse } from '@/utils/request'; export const billingApi = { - getStats: async (currency = 'CNY') => { - return http.get('/cloud-billings/stats', { currency }); + getSettings: async () => { + return http.get<{ base_currency: string; usd_cny_rate: number; updated_at?: string; updated_by?: string }>('/cloud-billings/settings'); + }, + saveSettings: async (usdCnyRate: number) => { + return http.put<{ base_currency: string; usd_cny_rate: number }>('/cloud-billings/settings', { usd_cny_rate: usdCnyRate }); + }, + getStats: async (params?: { currency?: string }) => { + return http.get('/cloud-billings/stats', params); }, - getCostAnalysis: async () => { - return http.get('/cloud-billings/cost-analysis'); + /** 获取按月聚合的多云费用分析数据。 // Logan.Li */ + getCostAnalysis: async (params?: { start_month?: string; end_month?: string; dimension?: string; currency?: string; provider?: string; cloud_account_id?: number }) => { + return http.get('/cloud-billings/cost-analysis', params); }, - list: async (params?: { provider?: string; cloud_account_id?: number; currency?: string; start_date?: string; end_date?: string; page?: number; page_size?: number }) => { + list: async (params?: { provider?: string; cloud_account_id?: number; start_date?: string; end_date?: string; page?: number; page_size?: number }) => { return http.get('/cloud-billings', params); }, create: async (data: Record) => { diff --git a/frontend/src/api/deploy/config.ts b/frontend/src/api/deploy/config.ts index 5255752..a56b41f 100644 --- a/frontend/src/api/deploy/config.ts +++ b/frontend/src/api/deploy/config.ts @@ -3,18 +3,6 @@ import { http, type ListResponse } from '@/utils/request'; export const deployApi = { - // Git 仓库认证 - getRepos: () => http.get('/deploy/repos'), - getRepo: (id: number | string) => http.get(`/deploy/repos/${id}`), - createRepo: ( data: Record) => http.post('/deploy/repos', data), - updateRepo: (id: number | string, data: Record) => http.put(`/deploy/repos/${id}`, data), - deleteRepo: (id: number | string) => http.delete(`/deploy/repos/${id}`), - // 镜像仓库认证 - getRegistries: () => http.get('/deploy/registries'), - getRegistry: (id: number | string) => http.get(`/deploy/registries/${id}`), - createRegistry: ( data: Record) => http.post('/deploy/registries', data), - updateRegistry: (id: number | string, data: Record) => http.put(`/deploy/registries/${id}`, data), - deleteRegistry: (id: number | string) => http.delete(`/deploy/registries/${id}`), // 环境管理 getEnvironments: () => http.get('/deploy/environments'), createEnvironment: ( data: Record) => http.post('/deploy/environments', data), @@ -45,4 +33,4 @@ export const deployApi = { uninstallTool: (id: number | string) => http.post(`/deploy/tools/${id}/uninstall`), checkTool: (id: number | string) => http.post(`/deploy/tools/${id}/check`), getToolVersions: (id: number | string) => http.get(`/deploy/tools/${id}/versions`), -}; \ No newline at end of file +}; diff --git a/frontend/src/api/deploy/pipeline.ts b/frontend/src/api/deploy/pipeline.ts index 1a10d6f..20c9285 100644 --- a/frontend/src/api/deploy/pipeline.ts +++ b/frontend/src/api/deploy/pipeline.ts @@ -49,7 +49,7 @@ export const pipelineApi = { getByCode: (code: string) => http.get(`/pipelines/${code}`), getRunsByCode: (code: string, pageSize = 50) => http.get(`/pipelines/${code}/runs`, { page_size: pageSize }), - triggerByCode: (code: string) => http.post(`/pipelines/${code}/trigger`, {}), + triggerByCode: (code: string, data?: Record) => http.post(`/pipelines/${code}/trigger`, data || {}), stopRunByCode: (code: string, runId: number | string) => http.post(`/pipelines/${code}/runs/${runId}/stop`, {}), batchDeleteRuns: (code: string, ids: number[]) => @@ -61,4 +61,11 @@ export const pipelineApi = { getWorkspace: (runId: number | string) => http.get(`/pipelines/runs/${runId}/workspace`), listRunFiles: (runId: number | string, path?: string) => http.get(`/pipelines/runs/${runId}/files`, path ? { path } : undefined), -}; \ No newline at end of file + getJenkinsConfig: () => http.get('/deploy/jenkins/config'), + saveJenkinsConfig: (data: Record) => http.put('/deploy/jenkins/config', data), + testJenkinsConfig: () => http.post('/deploy/jenkins/test', {}), + syncJenkinsJobs: (data?: { prefix?: string; overwrite?: boolean }) => + http.post('/deploy/jenkins/sync', data || {}), + getJenkinsRefs: (code: string, type: 'branch' | 'tag') => + http.get('/deploy/jenkins/refs', { code, type }), +}; diff --git a/frontend/src/api/server/server.ts b/frontend/src/api/server/server.ts index c8d22b1..512a77f 100644 --- a/frontend/src/api/server/server.ts +++ b/frontend/src/api/server/server.ts @@ -3,7 +3,7 @@ import { http, type ListResponse } from '@/utils/request'; export const serverApi = { - getServers: async (params?: { page?: number; page_size?: number; keyword?: string; status?: string; sort?: 'hostname'; order?: 'asc' | 'desc' }) => { + getServers: async (params?: { page?: number; page_size?: number; keyword?: string; status?: string }) => { return http.get('/servers', params); }, getServerDetail: async (id: number | string) => { @@ -39,12 +39,16 @@ export const serverApi = { return http.get('/dashboard/metrics'); }, installAgent: async (id: number | string, token: string, creds?: { username?: string; password?: string; ssh_key?: string; auth_type?: string }) => { - const body: Record = { token }; + // 浏览器当前 origin 是用户真实访问域名,避免反向代理把上游内网地址传给 Agent。 // Logan.Li + const serverUrl = typeof window !== 'undefined' ? window.location.origin : ''; + const body: Record = { token, server_url: serverUrl }; if (creds) Object.assign(body, creds); return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent`, body); }, batchInstallAgent: async (ids: number[], token: string, credentials?: Record) => { - const body: Record = { server_ids: ids }; + // 批量异步任务同样显式携带公网 origin,后台 goroutine 不再依赖代理头推断。 // Logan.Li + const serverUrl = typeof window !== 'undefined' ? window.location.origin : ''; + const body: Record = { server_ids: ids, server_url: serverUrl }; if (credentials) body.credentials = credentials; return http.post<{ total: number; success: number; results: Record[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, body); }, diff --git a/frontend/src/components/agent/AgentAlerts.tsx b/frontend/src/components/agent/AgentAlerts.tsx index ccb1ae7..57bf8c1 100644 --- a/frontend/src/components/agent/AgentAlerts.tsx +++ b/frontend/src/components/agent/AgentAlerts.tsx @@ -53,7 +53,7 @@ export function AgentAlerts() { setRules(asList(rulesRes)); } catch (err) { console.error(err); - toast.error(i18n.t('agent.alerts.loadFailed')); + toast.error(i18n.t('agent.agentalertstsx.s7ffaa448')); } finally { setLoading(false); } @@ -65,11 +65,11 @@ export function AgentAlerts() { // ============ 告警事件 ============ const handleResolve = async (id: number) => { - const ok = await confirm({ title: i18n.t('agent.alerts.resolveAlert'), description: i18n.t('agent.alerts.resolveConfirm'), confirmText: i18n.t('kubernetes.deployment.resume') }); + const ok = await confirm({ title: i18n.t('agent.agentalertstsx.sd56d01fa'), description: i18n.t('agent.agentalertstsx.s99f0fb65'), confirmText: i18n.t('kubernetes.deployment.resume') }); if (!ok) return; try { await agentApi.resolveAlert(id); - toast.success(i18n.t('agent.alerts.resolved')); + toast.success(i18n.t('agent.agentalertstsx.s0e23522e')); loadData(); } catch (err) { toast.error(err?.message || i18n.t('cloud.operationFailed')); @@ -88,11 +88,11 @@ export function AgentAlerts() { }; const handleDeleteRule = async (id: number) => { - const ok = await confirm({ title: i18n.t('monitor.deleteRule'), description: i18n.t('agent.alerts.deleteRule'), confirmText: i18n.t('kubernetes.storage.delete2'), variant: 'destructive' }); + const ok = await confirm({ title: i18n.t('monitor.deleteRule'), description: i18n.t('monitor.alertrulestabts.sd301558d'), confirmText: i18n.t('kubernetes.storage.delete2'), variant: 'destructive' }); if (!ok) return; try { await agentApi.deleteRule(id); - toast.success(i18n.t('agent.alerts.resolved')); + toast.success(i18n.t('agent.agentalertstsx.s8b2798c4')); loadData(); } catch (err) { toast.error(err?.message || i18n.t('kubernetes.msg.deleteFailed2')); @@ -113,18 +113,18 @@ export function AgentAlerts() { {/* 页头 */}
-

{i18n.t('agent.alerts.title')}

-

{i18n.t('agent.alerts.thresholdTriggered')}

+

Agent 告警

+

{i18n.t('agent.agentalertstsx.s03a08cd6')}

- {i18n.t('kubernetes.eventsPage.alertEvents')} + {i18n.t('kubernetes.eventspagetsx.s0e5b83cc')} {i18n.t('monitor.alertRules')} @@ -137,7 +137,7 @@ export function AgentAlerts() { onClick={() => setResolvedView(false)} className={!resolvedView ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'dg-input text-[var(--dg-text-secondary)]'} > - {i18n.t('agent.alerts.unresolved')} + 未恢复 @@ -160,7 +160,7 @@ export function AgentAlerts() { -

{resolvedView ? i18n.t('agent.alerts.noResolved') : i18n.t('agent.alerts.noActive')}

+

{resolvedView ? i18n.t('agent.agentalertstsx.se459f480') : i18n.t('agent.agentalertstsx.s778455b9')}

) : ( @@ -178,18 +178,18 @@ export function AgentAlerts() { {severityBadge(alert.severity)} {alert.rule_name || alert.name || alert.title || i18n.t('monitor.hostDashboard.alert')} -

{alert.message || alert.description || i18n.t('agent.alerts.thresholdTriggered')}

+

{alert.message || alert.description || i18n.t('agent.agentalertstsx.sddab4a17')}

{alert.hostname || '--'} - {i18n.t('agent.agentAlerts.metric')} {getMetricLabel(alert.metric)} + {i18n.t('agent.agentalertstsx.sbfdcf242')} {getMetricLabel(alert.metric)} {alert.value != null && ( - {i18n.t('agent.agentAlerts.currentValue')} {alert.value} + {i18n.t('agent.agentalertstsx.s2d13763b')} {alert.value} )} @@ -207,7 +207,7 @@ export function AgentAlerts() { className="shrink-0 h-8 dg-input dg-text-green hover:bg-emerald-500/10 hover:dg-text-green" > - {i18n.t('agent.alerts.resolve')} + 恢复 )}
@@ -223,7 +223,7 @@ export function AgentAlerts() {
@@ -241,7 +241,7 @@ export function AgentAlerts() {

{i18n.t('monitor.noRules')}

@@ -273,9 +273,9 @@ export function AgentAlerts() { )} {rule.metric === 'port_down' && rule.port ? ( - {i18n.t("common.port")} {rule.port} + 端口 {rule.port} ) : null} - {rule.duration ? {i18n.t("common.duration")} {rule.duration}s : null} + {rule.duration ? 持续 {rule.duration}s : null} diff --git a/frontend/src/components/agent/AgentMonitor.tsx b/frontend/src/components/agent/AgentMonitor.tsx index eec08a6..1625b23 100644 --- a/frontend/src/components/agent/AgentMonitor.tsx +++ b/frontend/src/components/agent/AgentMonitor.tsx @@ -71,7 +71,7 @@ export function AgentMonitor() { if (fresh) setSelectedHost(fresh); } } catch (err) { - if (!silent) toast.error(i18n.t('agent.monitor.loadHostFailed')); + if (!silent) toast.error(i18n.t('agent.agentmonitortsx.s5760f667')); console.error(err); } finally { setLoading(false); @@ -100,7 +100,7 @@ export function AgentMonitor() { // 提交远程命令(直连 SSH 即时返回) const submitCommand = async () => { if (!cmdCommand.trim()) { - toast.error(i18n.t('agent.monitor.enterCommand')); + toast.error(i18n.t('agent.agentmonitortsx.s9358aada')); return; } setCmdSubmitting(true); @@ -114,7 +114,7 @@ export function AgentMonitor() { const resData = (res?.data ?? {}) as Record; setCmdResult({ status: res?.code === 200 ? 'completed' : 'failed', - result: (resData.output as string) || (res?.message as string) || i18n.t('agent.monitor.noOutput'), + result: (resData.output as string) || (res?.message as string) || i18n.t('agent.agentmonitortsx.s26d45bea'), error: resData.error as string | undefined, }); } catch (err) { @@ -142,8 +142,8 @@ export function AgentMonitor() { {/* 页头 */}
-

{t("agent.agentMonitor.title")}

-

{i18n.t('agent.agentMonitor.subtitle')}

+

{t("agent.agentmonitortsx.s1a2b3c4d")}

+

{i18n.t('agent.agentmonitortsx.sa75d7519')}

@@ -303,7 +303,7 @@ export function AgentMonitor() { {t("agent.agentmonitortsx.s8h9i0j1k")} - {t('common.executeOn', { host: cmdHost })} + 在 {cmdHost} 上执行命令 @@ -325,7 +325,7 @@ export function AgentMonitor() {