chore: 统一本地服务器与远程仓库源码
- 合并 main 与 master 历史并以服务器结构为基线 - 保留迁移、数据保留、AWS 账单与部署安全能力 - 建立 SDD 规格和 CI 验证闭环
This commit is contained in:
commit
fd60361d93
439 files changed
+37419
-23798
No files matched your search
+6
-3
@@ -1,9 +1,12 @@
|
|||||||
# AIOps Docker Compose 环境变量
|
# AIOps Docker Compose 环境变量
|
||||||
# 复制为 .env 并修改密码
|
# 复制为 .env 后填入随机生产密钥;.env 不可提交,权限应为 600。 // Logan.Li
|
||||||
|
|
||||||
# MySQL 数据库密码(生产环境务必修改)
|
# MySQL 数据库密码(生产环境务必修改)
|
||||||
MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD
|
MYSQL_ROOT_PASSWORD=REPLACE_WITH_A_RANDOM_ROOT_PASSWORD
|
||||||
MYSQL_PASSWORD=CHANGE_ME_USER_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 数据库名和用户名(通常无需修改)
|
||||||
MYSQL_DATABASE=aiops
|
MYSQL_DATABASE=aiops
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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/<domain>/<slug>.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. 项目概述
|
## 1. 项目概述
|
||||||
|
|
||||||
**AIOps** 是一个企业级云原生智能运维平台,聚焦三大核心能力:
|
**AIOps** 是一个企业级云原生智能运维平台,聚焦三大核心能力:
|
||||||
@@ -20,8 +65,8 @@
|
|||||||
| 产物 | 技术 | 说明 |
|
| 产物 | 技术 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `frontend/` | React 19 + TypeScript + Vite | Web 控制台 |
|
| `frontend/` | React 19 + TypeScript + Vite | Web 控制台 |
|
||||||
| `backend/` | Go 1.24 + Gin + GORM | HTTP API + WebSocket + 前端静态资源服务 |
|
| `backend/` | Go 1.25 + Gin + GORM | HTTP API + WebSocket + 前端静态资源服务 |
|
||||||
| `agent/` | Go 1.24(CGO_ENABLED=0) | 主机指标采集 + 远程任务执行 |
|
| `agent/` | Go 1.22 模块(Go 1.25 工具链,CGO_ENABLED=0) | 主机指标采集 + 远程任务执行 |
|
||||||
|
|
||||||
### 1.2 默认账号(生产必须修改)
|
### 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 工具链) |
|
| `agent/go.mod` | Agent 独立模块,模块名 `ezops/agent`,Go 1.22(与后端共用 1.24 工具链) |
|
||||||
| `frontend/package.json` | 前端 npm 依赖与脚本 |
|
| `frontend/package.json` | 前端 npm 依赖与脚本 |
|
||||||
| `frontend/vite.config.ts` | Vite 配置:端口 5173,代理 `/api`、`/ws`、`/health`、`/version`、`/agent` 到 `localhost:8080` |
|
| `frontend/vite.config.ts` | Vite 配置:端口 5173,代理 `/api`、`/ws`、`/health`、`/version`、`/agent` 到 `localhost:8080` |
|
||||||
@@ -60,7 +105,7 @@
|
|||||||
|
|
||||||
### 2.2 后端
|
### 2.2 后端
|
||||||
|
|
||||||
- **语言 / 版本**:Go 1.24+
|
- **语言 / 版本**:Go 1.25+
|
||||||
- **Web 框架**:Gin v1.9.1
|
- **Web 框架**:Gin v1.9.1
|
||||||
- **ORM**:GORM v1.30.0
|
- **ORM**:GORM v1.30.0
|
||||||
- **数据库**:SQLite(local 模式,开发本机)、MySQL 8.0(生产)、PostgreSQL(可选)
|
- **数据库**:SQLite(local 模式,开发本机)、MySQL 8.0(生产)、PostgreSQL(可选)
|
||||||
@@ -91,7 +136,7 @@
|
|||||||
- **数据请求**:TanStack Query 5+
|
- **数据请求**:TanStack Query 5+
|
||||||
- **路由**:React Router 7+
|
- **路由**:React Router 7+
|
||||||
- **图表**:Recharts
|
- **图表**:Recharts
|
||||||
- **图标**:Lucide React
|
- **图标**:Lucide React(通用 UI)+ Simple Icons(技术品牌按需导入)
|
||||||
- **编辑器**:CodeMirror 6(`@uiw/react-codemirror` + `@codemirror/lang-yaml`)
|
- **编辑器**:CodeMirror 6(`@uiw/react-codemirror` + `@codemirror/lang-yaml`)
|
||||||
- **终端**:xterm.js
|
- **终端**:xterm.js
|
||||||
- **国际化**:i18next + react-i18next(中/英双语)
|
- **国际化**:i18next + react-i18next(中/英双语)
|
||||||
@@ -361,7 +406,7 @@ go build -o aiops .
|
|||||||
3. **新增 handler 不直接绑 model**,使用独立 request struct。
|
3. **新增 handler 不直接绑 model**,使用独立 request struct。
|
||||||
4. **GORM 指针字段必须解引用**后再使用(`*field`)。
|
4. **GORM 指针字段必须解引用**后再使用(`*field`)。
|
||||||
5. **Update handler 禁止复用 CreateRequest**,使用 `map[string]interface{}` 接收。
|
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()` 代替。
|
7. **GORM `First()` 连续查询会带上次 ID**,请用 `Take()` 代替。
|
||||||
8. 文件名使用 `snake_case.go`;类型/方法 `PascalCase`;变量 `camelCase`。
|
8. 文件名使用 `snake_case.go`;类型/方法 `PascalCase`;变量 `camelCase`。
|
||||||
|
|
||||||
@@ -373,6 +418,8 @@ go build -o aiops .
|
|||||||
4. **Token 双保险**:`useUserStore.getState().token || localStorage.getItem('token')`。
|
4. **Token 双保险**:`useUserStore.getState().token || localStorage.getItem('token')`。
|
||||||
5. ESLint 禁止显式 `any`(`@typescript-eslint/no-explicit-any: error`)。
|
5. ESLint 禁止显式 `any`(`@typescript-eslint/no-explicit-any: error`)。
|
||||||
6. 组件文件 `PascalCase.tsx`;工具文件 `camelCase.ts`。
|
6. 组件文件 `PascalCase.tsx`;工具文件 `camelCase.ts`。
|
||||||
|
7. 通用操作、状态和导航使用 `lucide-react`;云厂商、CI/CD、监控和中间件等产品实体使用 `TechnologyBrandIcon` 或 `CloudBrandIcon`。
|
||||||
|
8. 技术品牌图标从 `simple-icons` 按需导入并随构建产物发布;禁止使用 CDN、远程图片 URL 或 `master` 分支资源。云厂商缺失图标使用仓库本地 SVG 或官方资源。
|
||||||
|
|
||||||
### 6.5 UI 设计原则
|
### 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:...}` |
|
| Go2 | handler 不包裹 Response | 必须 `model.Response{Code:200, Data:...}` |
|
||||||
| Go3 | GORM 指针字段 | `*field` 解引用后再用 |
|
| Go3 | GORM 指针字段 | `*field` 解引用后再用 |
|
||||||
| Go4 | Update 复用 CreateRequest | 用 `map[string]interface{}` |
|
| Go4 | Update 复用 CreateRequest | 用 `map[string]interface{}` |
|
||||||
|
|||||||
@@ -5,6 +5,27 @@
|
|||||||
> 作者:Logan.Li
|
> 作者:Logan.Li
|
||||||
> 语言:中文(代码注释和文档主要使用中文,注释署名 `// 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** 是一个企业级智能运维平台,聚焦三大核心能力:
|
**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 |
|
| 前端 | 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
|
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 .
|
go run .
|
||||||
@@ -262,7 +283,7 @@ WebSocket 无法设自定义 header → 后端 JWTAuth 支持 `?token=` query pa
|
|||||||
### ⛔ Go 代码规范
|
### ⛔ Go 代码规范
|
||||||
|
|
||||||
1. **所有 handler 返回必须用 `models.Response{Code:200, Data:...}` 包裹**
|
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
|
3. **新增 handler 不绑 model** — 用独立 request struct
|
||||||
4. **GORM 指针字段必须解引用** — `*field` 后再用
|
4. **GORM 指针字段必须解引用** — `*field` 后再用
|
||||||
5. **Update handler 禁止复用 CreateRequest** — 用 `map[string]interface{}` 接收
|
5. **Update handler 禁止复用 CreateRequest** — 用 `map[string]interface{}` 接收
|
||||||
@@ -276,6 +297,8 @@ WebSocket 无法设自定义 header → 后端 JWTAuth 支持 `?token=` query pa
|
|||||||
2. **禁止 `window.confirm()`/`alert()`** — 用 `useConfirm()` + `toast`
|
2. **禁止 `window.confirm()`/`alert()`** — 用 `useConfirm()` + `toast`
|
||||||
3. **Radix Dialog 必须有 `<DialogDescription>`**
|
3. **Radix Dialog 必须有 `<DialogDescription>`**
|
||||||
4. **Token 双保险**: `useUserStore.getState().token || localStorage.getItem('token')`
|
4. **Token 双保险**: `useUserStore.getState().token || localStorage.getItem('token')`
|
||||||
|
5. **图标分层**:操作、状态和导航使用 `lucide-react`;云厂商、CI/CD、监控和中间件等产品实体使用本地 `TechnologyBrandIcon` 或 `CloudBrandIcon`。
|
||||||
|
6. **品牌图标本地化**:技术品牌图标统一从 `simple-icons` 按需导入,禁止使用 jsDelivr、CDN 或远程图片 URL;云厂商缺失图标使用仓库本地 SVG 或官方资源。
|
||||||
|
|
||||||
### ⛔ UI 设计原则
|
### ⛔ 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:...}` |
|
| Go2 | handler 不包裹 Response | 必须 `models.Response{Code:200, Data:...}` |
|
||||||
| Go3 | GORM 指针字段 | `*field` 解引用后再用 |
|
| Go3 | GORM 指针字段 | `*field` 解引用后再用 |
|
||||||
| Go4 | Update 复用 CreateRequest | 用 `map[string]interface{}` |
|
| Go4 | Update 复用 CreateRequest | 用 `map[string]interface{}` |
|
||||||
|
|||||||
+12
-9
@@ -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 && \
|
/tmp/install-kubectl.sh /usr/local/bin/kubectl && \
|
||||||
rm -f /tmp/setup-alpine-mirrors.sh /tmp/install-kubectl.sh
|
rm -f /tmp/setup-alpine-mirrors.sh /tmp/install-kubectl.sh
|
||||||
|
|
||||||
# Maven 阿里云镜像加速(maven 懒加载后生效)
|
|
||||||
RUN mkdir -p /root/.m2 && \
|
|
||||||
echo '<?xml version="1.0" encoding="UTF-8"?><settings><mirrors><mirror><id>aliyun</id><mirrorOf>central</mirrorOf><name>Aliyun Maven</name><url>https://maven.aliyun.com/repository/public</url></mirror></mirrors></settings>' > /root/.m2/settings.xml
|
|
||||||
|
|
||||||
# npm 淘宝镜像加速(node 懒加载后生效)
|
|
||||||
RUN npm config set registry https://registry.npmmirror.com 2>/dev/null || true
|
|
||||||
|
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -126,8 +119,13 @@ WORKDIR /app
|
|||||||
# 复制后端二进制(前端已嵌入)
|
# 复制后端二进制(前端已嵌入)
|
||||||
COPY --from=backend-builder /app/aiops .
|
COPY --from=backend-builder /app/aiops .
|
||||||
|
|
||||||
# 创建运行时目录
|
# 创建非 root 运行用户及应用可写目录。工具懒加载目录由该用户写入。 // Logan.Li
|
||||||
RUN mkdir -p /app/agent/bin /app/agent/deploy
|
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
|
# 复制 agent 二进制。 // Logan.Li
|
||||||
COPY --from=agent-builder /agent/bin/aiops-agent-linux-${TARGETARCH} /app/agent/bin/aiops-agent-linux-${TARGETARCH}
|
COPY --from=agent-builder /agent/bin/aiops-agent-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/install.ps1 /app/agent/deploy/
|
||||||
COPY agent/deploy/aiops-agent.service /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
|
EXPOSE 8080
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
|||||||
@@ -80,7 +80,6 @@
|
|||||||
| 监控大屏(自定义仪表盘) | ✅ custom_dashboard.go CRUD | ✅ GrafanaDashboard 自定义 Tab + GrafanaPanelContent PromQL 渲染 | ✅ F4 done |
|
| 监控大屏(自定义仪表盘) | ✅ custom_dashboard.go CRUD | ✅ GrafanaDashboard 自定义 Tab + GrafanaPanelContent PromQL 渲染 | ✅ F4 done |
|
||||||
| 链路追踪 | ✅ trace_proxy.go Jaeger/Tempo 代理 | ✅ TraceManager (587L) — traceApi 对接真实后端 + demo 数据兜底 | ✅ F5 done |
|
| 链路追踪 | ✅ trace_proxy.go Jaeger/Tempo 代理 | ✅ TraceManager (587L) — traceApi 对接真实后端 + demo 数据兜底 | ✅ F5 done |
|
||||||
| **中间件监控** | ✅ middleware_monitor.go 后端直连采集 Redis/MySQL | ✅ MiddlewareMonitor 完整管理页面 (CRUD + 测试 + 概览) | ✅ M15 done |
|
| **中间件监控** | ✅ middleware_monitor.go 后端直连采集 Redis/MySQL | ✅ MiddlewareMonitor 完整管理页面 (CRUD + 测试 + 概览) | ✅ M15 done |
|
||||||
| **主机监控大屏** | ✅ serverApi 复用 | ✅ HostMonitorDashboard (Grafana 风格大屏 + CPU/内存 Top5 排行 + 统计卡片) | ✅ U13 done |
|
|
||||||
|
|
||||||
### 2.5 自动化运维 ✅ 完善
|
### 2.5 自动化运维 ✅ 完善
|
||||||
|
|
||||||
@@ -129,7 +128,7 @@
|
|||||||
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
| ~~`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) |
|
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
||||||
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
| ~~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 🟡 中优先级
|
### 3.2 🟡 中优先级
|
||||||
|
|
||||||
@@ -137,8 +136,8 @@
|
|||||||
|------|------|:----------:|
|
|------|------|:----------:|
|
||||||
| 前端未使用变量/导入(ESLint no-unused-vars) | ~~218 处~~ → **0 处** | ~~P1~~ ✅ done |
|
| 前端未使用变量/导入(ESLint no-unused-vars) | ~~218 处~~ → **0 处** | ~~P1~~ ✅ done |
|
||||||
| 前端 console.log/console.error | ~~93 处~~ → **3 处** | ~~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 |
|
| 前端 React Hooks exhaustive-deps 缺失 | 38 处 | P1 |
|
||||||
| 后端 `_ =` 忽略错误 | ~~13 处(3 处中风险)~~ → **controller/middleware 层 DB 写入零忽略**(Q4+Q5 全量治理) | ~~P2~~ ✅ done |
|
| 后端 `_ =` 忽略错误 | 13 处(3 处中风险) | P2 |
|
||||||
| 前端硬编码内网 IP(10.10.10.75 等) | 6+ 处 | P2 |
|
| 前端硬编码内网 IP(10.10.10.75 等) | 6+ 处 | P2 |
|
||||||
| 后端告警引擎分布式安全 | ✅ Redis 分布式锁(SetNX + 60s TTL) | 后端 | ~~P2~~ done |
|
| 后端告警引擎分布式安全 | ✅ Redis 分布式锁(SetNX + 60s TTL) | 后端 | ~~P2~~ done |
|
||||||
|
|
||||||
@@ -146,9 +145,9 @@
|
|||||||
|
|
||||||
| 问题 | 数量 |
|
| 问题 | 数量 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| ~~前端死代码(HostMonitorDashboard lazy 未挂载)~~ → **U13 done: 已挂载为 `/host-dashboard` 路由 + 监控菜单「主机大屏」** | ~~1 处~~ ✅ done |
|
| 前端 `set-state-in-effect`(潜在无限循环) | 7 处 |
|
||||||
| ~~前端 `set-state-in-effect`(潜在无限循环)~~ → **U12 done: 0 处** | ~~7 处~~ ✅ done |
|
| 前端 `react-hooks/purity`(render 调 Date.now) | 6 处 |
|
||||||
| ~~前端 `react-hooks/purity`(render 调 Date.now)~~ → **U12 done: 0 处** | ~~6 处~~ ✅ done |
|
| 前端死代码(HostMonitorDashboard lazy 未挂载) | 1 处 |
|
||||||
| 后端 `exec.Command` + `bash -c`(命令注入面) | 18 处(需审计用户输入路径) |
|
| 后端 `exec.Command` + `bash -c`(命令注入面) | 18 处(需审计用户输入路径) |
|
||||||
|
|
||||||
### 3.4 Mock API / 未对接后端
|
### 3.4 Mock API / 未对接后端
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
- CodeMirror 6 (YAML 编辑器) / xterm.js (WebSocket 终端)
|
- 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 模式)
|
- GORM (ORM) / MySQL 8.0 / Redis 7 / SQLite (local 模式)
|
||||||
- JWT 认证 / Zap 日志 / Viper 配置
|
- JWT 认证 / Zap 日志 / Viper 配置
|
||||||
- WebSocket (Gorilla) / PTY 终端 (creack/pty)
|
- WebSocket (Gorilla) / PTY 终端 (creack/pty)
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
- Agent CLI 引擎(`pkg/agentcli/`):ReAct + Evolver + 双协议 LLM(OpenAI / Anthropic)
|
- Agent CLI 引擎(`pkg/agentcli/`):ReAct + Evolver + 双协议 LLM(OpenAI / Anthropic)
|
||||||
|
|
||||||
### Agent
|
### Agent
|
||||||
- Go 1.24 静态编译 (CGO_ENABLED=0)
|
- Go 1.22 模块(Go 1.25 工具链)静态编译 (CGO_ENABLED=0)
|
||||||
- /proc 指标采集 + 远程任务双通道
|
- /proc 指标采集 + 远程任务双通道
|
||||||
- 支持 linux-amd64 / linux-arm64 / darwin-arm64
|
- 支持 linux-amd64 / linux-arm64 / darwin-arm64
|
||||||
|
|
||||||
|
|||||||
-1092
File diff suppressed because it is too large.
Load diff
-446
@@ -1088,449 +1088,3 @@ python3 ../tests/integration_test.py --host 127.0.0.1 --port 8080
|
|||||||
- [x] U4: KubernetesClusterDetail 非注释 CJK = 0
|
- [x] U4: KubernetesClusterDetail 非注释 CJK = 0
|
||||||
- [x] U5: TopologyView 非注释 CJK = 0
|
- [x] U5: TopologyView 非注释 CJK = 0
|
||||||
- [x] U6: 17/17 集成回归测试通过
|
- [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('>名称:</span>')` 等 → 语义化键如 `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
|
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -161,7 +162,8 @@ func (h *HealthProbeCollector) execTCP(cfg model.ProbeConfig, start time.Time) m
|
|||||||
timeout = 10 * time.Second
|
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)
|
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||||
latencyMs := int(time.Since(start).Milliseconds())
|
latencyMs := int(time.Since(start).Milliseconds())
|
||||||
result.LatencyMs = latencyMs
|
result.LatencyMs = latencyMs
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ set -e
|
|||||||
# 默认参数
|
# 默认参数
|
||||||
AIOPS_SERVER=""
|
AIOPS_SERVER=""
|
||||||
AGENT_TOKEN=""
|
AGENT_TOKEN=""
|
||||||
|
AGENT_HOSTNAME=""
|
||||||
INSTALL_DIR="/usr/local/bin"
|
INSTALL_DIR="/usr/local/bin"
|
||||||
SERVICE_NAME="aiops-agent"
|
SERVICE_NAME="aiops-agent"
|
||||||
PLIST_LABEL="com.ezops.aiops-agent"
|
PLIST_LABEL="com.ezops.aiops-agent"
|
||||||
@@ -34,6 +35,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
case $1 in
|
case $1 in
|
||||||
--server) AIOPS_SERVER="$2"; shift 2;;
|
--server) AIOPS_SERVER="$2"; shift 2;;
|
||||||
--token) AGENT_TOKEN="$2"; shift 2;;
|
--token) AGENT_TOKEN="$2"; shift 2;;
|
||||||
|
--hostname) AGENT_HOSTNAME="$2"; shift 2;;
|
||||||
--version) AGENT_VERSION="$2"; shift 2;;
|
--version) AGENT_VERSION="$2"; shift 2;;
|
||||||
*) echo "未知参数: $1"; exit 1;;
|
*) echo "未知参数: $1"; exit 1;;
|
||||||
esac
|
esac
|
||||||
@@ -142,6 +144,8 @@ if [ "$OS_LOWER" = "darwin" ]; then
|
|||||||
<string>10</string>
|
<string>10</string>
|
||||||
<string>-token</string>
|
<string>-token</string>
|
||||||
<string>${AGENT_TOKEN}</string>
|
<string>${AGENT_TOKEN}</string>
|
||||||
|
<string>-hostname</string>
|
||||||
|
<string>${AGENT_HOSTNAME}</string>
|
||||||
</array>
|
</array>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
@@ -193,7 +197,7 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
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
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
LimitNOFILE=65536
|
LimitNOFILE=65536
|
||||||
|
|||||||
+6
-10
@@ -116,12 +116,11 @@ func main() {
|
|||||||
|
|
||||||
sysInfo := collector.CollectSystem()
|
sysInfo := collector.CollectSystem()
|
||||||
logger.Printf("系统信息: OS=%q Kernel=%q", sysInfo.OSVersion, sysInfo.Kernel)
|
logger.Printf("系统信息: OS=%q Kernel=%q", sysInfo.OSVersion, sysInfo.Kernel)
|
||||||
|
agentStartedAt := time.Now()
|
||||||
|
|
||||||
localIP := detectLocalIP()
|
localIP := detectLocalIP()
|
||||||
logger.Printf("探测本机 IP: %s", localIP)
|
logger.Printf("探测本机 IP: %s", localIP)
|
||||||
|
|
||||||
sysReportCount := 0
|
|
||||||
|
|
||||||
taskExecutor := task.New(logger)
|
taskExecutor := task.New(logger)
|
||||||
taskClient := task.NewClient(httpCli, cfg.Hostname, logger)
|
taskClient := task.NewClient(httpCli, cfg.Hostname, logger)
|
||||||
|
|
||||||
@@ -135,20 +134,17 @@ func main() {
|
|||||||
var probeConfigs []model.ProbeConfig
|
var probeConfigs []model.ProbeConfig
|
||||||
|
|
||||||
collectAndReport := func() {
|
collectAndReport := func() {
|
||||||
|
currentUptime := sysInfo.UptimeSeconds + int64(time.Since(agentStartedAt).Seconds())
|
||||||
report := &model.Report{
|
report := &model.Report{
|
||||||
Hostname: cfg.Hostname,
|
Hostname: cfg.Hostname,
|
||||||
IP: localIP,
|
IP: localIP,
|
||||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
AgentVersion: version,
|
AgentVersion: version,
|
||||||
}
|
System: &model.SystemStat{
|
||||||
|
|
||||||
if sysReportCount < 3 && (sysInfo.OSVersion != "" || sysInfo.Kernel != "") {
|
|
||||||
report.System = &model.SystemStat{
|
|
||||||
OSVersion: sysInfo.OSVersion,
|
OSVersion: sysInfo.OSVersion,
|
||||||
Kernel: sysInfo.Kernel,
|
Kernel: sysInfo.Kernel,
|
||||||
Uptime: sysInfo.UptimeSeconds,
|
Uptime: currentUptime,
|
||||||
}
|
},
|
||||||
sysReportCount++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if stat, err := cpuCollector.Collect(); err != nil {
|
if stat, err := cpuCollector.Collect(); err != nil {
|
||||||
@@ -211,7 +207,7 @@ func main() {
|
|||||||
runtime.ReadMemStats(&memStats)
|
runtime.ReadMemStats(&memStats)
|
||||||
report.Heartbeat = model.AgentHeartbeat{
|
report.Heartbeat = model.AgentHeartbeat{
|
||||||
Version: version,
|
Version: version,
|
||||||
UptimeSec: sysInfo.UptimeSeconds,
|
UptimeSec: currentUptime,
|
||||||
SelfCPU: 0,
|
SelfCPU: 0,
|
||||||
SelfRSSMB: float64(memStats.Alloc) / 1024.0 / 1024.0,
|
SelfRSSMB: float64(memStats.Alloc) / 1024.0 / 1024.0,
|
||||||
ReportSeq: reportSeq,
|
ReportSeq: reportSeq,
|
||||||
|
|||||||
+81
-105
@@ -1,147 +1,123 @@
|
|||||||
package cloud
|
package cloud
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FetchDailyBill 通过 Cost Explorer 拉取 AWS 单日服务和区域费用。 // Logan.Li
|
// FetchDailyBill 通过 AWS Cost Explorer 按服务和区域同步指定日的未摊销成本。 // Logan.Li
|
||||||
func (p *AWSProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD")
|
return nil, fmt.Errorf("账单日期格式错误: %w", err)
|
||||||
}
|
}
|
||||||
type aggregationKey struct {
|
payload, err := json.Marshal(map[string]interface{}{
|
||||||
service string
|
"TimePeriod": map[string]string{
|
||||||
region string
|
"Start": day.Format("2006-01-02"),
|
||||||
}
|
"End": day.AddDate(0, 0, 1).Format("2006-01-02"),
|
||||||
aggregation := map[aggregationKey]float64{}
|
},
|
||||||
nextToken := ""
|
"Granularity": "DAILY",
|
||||||
currency := "USD"
|
"Metrics": []string{"UnblendedCost"},
|
||||||
for {
|
"GroupBy": []map[string]string{
|
||||||
payload := map[string]interface{}{
|
{"Type": "DIMENSION", "Key": "SERVICE"},
|
||||||
"TimePeriod": map[string]string{"Start": billingDate, "End": start.AddDate(0, 0, 1).Format("2006-01-02")},
|
{"Type": "DIMENSION", "Key": "REGION"},
|
||||||
"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
|
|
||||||
})
|
})
|
||||||
records := make([]CloudBillingRecord, 0, len(keys))
|
if err != nil {
|
||||||
for _, key := range keys {
|
return nil, fmt.Errorf("构造 AWS Cost Explorer 请求失败: %w", err)
|
||||||
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)",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
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
|
// callCostExplorer 使用 SigV4 调用 Cost Explorer;该服务固定使用 us-east-1。 // Logan.Li
|
||||||
func (p *AWSProvider) callCostExplorer(payload interface{}) ([]byte, error) {
|
func (p *AWSProvider) callCostExplorer(payload []byte) ([]byte, error) {
|
||||||
body, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("编码 AWS 账单请求失败: %w", err)
|
|
||||||
}
|
|
||||||
const host = "ce.us-east-1.amazonaws.com"
|
const host = "ce.us-east-1.amazonaws.com"
|
||||||
const target = "AWSInsightsIndexService.GetCostAndUsage"
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
amzDate, date := now.Format("20060102T150405Z"), now.Format("20060102")
|
date := now.Format("20060102")
|
||||||
canonicalHeaders := "content-type:application/x-amz-json-1.1\nhost:" + host + "\nx-amz-date:" + amzDate + "\nx-amz-target:" + target + "\n"
|
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"
|
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"
|
credentialScope := date + "/us-east-1/ce/aws4_request"
|
||||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest)
|
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest)
|
||||||
kDate := hmacSHA256([]byte("AWS4"+p.secretKey), date)
|
kDate := hmacSHA256([]byte("AWS4"+p.secretKey), date)
|
||||||
kRegion := hmacSHA256(kDate, "us-east-1")
|
kRegion := hmacSHA256(kDate, "us-east-1")
|
||||||
kService := hmacSHA256(kRegion, "ce")
|
kService := hmacSHA256(kRegion, "ce")
|
||||||
signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "aws4_request"), stringToSign))
|
signingKey := hmacSHA256(kService, "aws4_request")
|
||||||
req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", strings.NewReader(string(body)))
|
signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign))
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", bytes.NewReader(payload))
|
||||||
if err != nil {
|
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("Content-Type", "application/x-amz-json-1.1")
|
||||||
req.Header.Set("X-Amz-Date", amzDate)
|
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)
|
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)
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("请求 AWS Cost Explorer 失败: %w", err)
|
return nil, fmt.Errorf("请求 AWS Cost Explorer 失败: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
data, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("读取 AWS Cost Explorer 响应失败: %w", err)
|
return nil, fmt.Errorf("读取 AWS Cost Explorer 响应失败: %w", err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
return nil, fmt.Errorf("AWS Cost Explorer 返回 HTTP %d: %s", resp.StatusCode, string(data))
|
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
|
||||||
}
|
}
|
||||||
@@ -79,37 +79,6 @@ func (p *AzureProvider) get(path string) ([]byte, error) {
|
|||||||
return body, nil
|
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
|
// ListRegions 获取 Azure 订阅可用区域。 // Logan.Li
|
||||||
func (p *AzureProvider) ListRegions() ([]CloudRegion, error) {
|
func (p *AzureProvider) ListRegions() ([]CloudRegion, error) {
|
||||||
body, err := p.get("/subscriptions/" + p.subscriptionID + "/locations?api-version=2022-12-01")
|
body, err := p.get("/subscriptions/" + p.subscriptionID + "/locations?api-version=2022-12-01")
|
||||||
|
|||||||
@@ -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 != "<nil>" {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ type BillingFetcher interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SupportedBillingProviders 已接入账单 API 的云厂商列表。 // Logan.Li
|
// SupportedBillingProviders 已接入账单 API 的云厂商列表。 // Logan.Li
|
||||||
var SupportedBillingProviders = []string{"aliyun", "tencent", "aws", "huawei", "azure"}
|
var SupportedBillingProviders = []string{"aliyun", "tencent", "aws"}
|
||||||
|
|
||||||
// NewBillingFetcher 根据云厂商创建账单拉取器
|
// NewBillingFetcher 根据云厂商创建账单拉取器
|
||||||
func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
|
func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
|
||||||
@@ -28,10 +28,6 @@ func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
|
|||||||
return NewTencentProvider(accessKey, secretKey)
|
return NewTencentProvider(accessKey, secretKey)
|
||||||
case "aws":
|
case "aws":
|
||||||
return NewAWSProvider(accessKey, secretKey)
|
return NewAWSProvider(accessKey, secretKey)
|
||||||
case "huawei":
|
|
||||||
return NewHuaweiProvider(accessKey, secretKey)
|
|
||||||
case "azure":
|
|
||||||
return NewAzureProvider(accessKey, secretKey)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
+19
-29
@@ -8,42 +8,37 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HuaweiProvider 通过华为云 AK/SK 签名 API 同步项目区域与 ECS 实例。 // Logan.Li
|
// HuaweiProvider 通过华为云 AK/SK 签名 API 同步项目区域与 ECS 实例。 // Logan.Li
|
||||||
type HuaweiProvider struct {
|
type HuaweiProvider struct{ accessKey, secretKey string }
|
||||||
accessKey, secretKey string
|
|
||||||
regionMu sync.RWMutex
|
|
||||||
regionByProject map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHuaweiProvider 创建华为云 Provider。 // Logan.Li
|
// NewHuaweiProvider 创建华为云 Provider。 // Logan.Li
|
||||||
func NewHuaweiProvider(accessKey, secretKey string) *HuaweiProvider {
|
func NewHuaweiProvider(accessKey, secretKey string) *HuaweiProvider {
|
||||||
return &HuaweiProvider{accessKey: accessKey, secretKey: secretKey, regionByProject: make(map[string]string)}
|
return &HuaweiProvider{accessKey, secretKey}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name 返回华为云厂商标识。 // Logan.Li
|
// Name 返回华为云厂商标识。 // Logan.Li
|
||||||
func (p *HuaweiProvider) Name() string { return "huawei" }
|
func (p *HuaweiProvider) Name() string { return "huawei" }
|
||||||
|
|
||||||
// signedGet 使用 SDK-HMAC-SHA256 调用华为云 GET API。 // Logan.Li
|
// 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)
|
u, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建华为云请求失败: %w", err)
|
return nil, fmt.Errorf("创建华为云请求失败: %w", err)
|
||||||
}
|
}
|
||||||
now := time.Now().UTC().Format("20060102T150405Z")
|
now := time.Now().UTC().Format("20060102T150405Z")
|
||||||
|
date := now[:8]
|
||||||
host := u.URL.Host
|
host := u.URL.Host
|
||||||
canonicalHeaders := "host:" + host + "\n" + "x-sdk-date:" + now + "\n"
|
canonicalHeaders := "host:" + host + "\n" + "x-sdk-date:" + now + "\n"
|
||||||
signedHeaders := "host;x-sdk-date"
|
signedHeaders := "host;x-sdk-date"
|
||||||
canonicalURI := u.URL.EscapedPath()
|
canonicalRequest := "GET\n" + u.URL.EscapedPath() + "\n" + u.URL.Query().Encode() + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex("")
|
||||||
if !strings.HasSuffix(canonicalURI, "/") {
|
credentialScope := date + "/" + service + "/sdk_request"
|
||||||
canonicalURI += "/"
|
stringToSign := "SDK-HMAC-SHA256\n" + now + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest)
|
||||||
}
|
kDate := hmacSHA256([]byte(p.secretKey), date)
|
||||||
canonicalRequest := "GET\n" + canonicalURI + "\n" + u.URL.Query().Encode() + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex("")
|
kService := hmacSHA256(kDate, service)
|
||||||
stringToSign := "SDK-HMAC-SHA256\n" + now + "\n" + sha256Hex(canonicalRequest)
|
signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "sdk_request"), stringToSign))
|
||||||
signature := hex.EncodeToString(hmacSHA256([]byte(p.secretKey), stringToSign))
|
|
||||||
u.Header.Set("X-Sdk-Date", now)
|
u.Header.Set("X-Sdk-Date", now)
|
||||||
u.Header.Set("Authorization", "SDK-HMAC-SHA256 Access="+p.accessKey+", SignedHeaders="+signedHeaders+", Signature="+signature)
|
u.Header.Set("Authorization", "SDK-HMAC-SHA256 Access="+p.accessKey+", SignedHeaders="+signedHeaders+", Signature="+signature)
|
||||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(u)
|
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)
|
return nil, fmt.Errorf("解析华为云区域响应失败: %w", err)
|
||||||
}
|
}
|
||||||
regions := []CloudRegion{}
|
regions := []CloudRegion{}
|
||||||
regionByProject := make(map[string]string, len(result.Projects))
|
|
||||||
for _, project := range result.Projects {
|
for _, project := range result.Projects {
|
||||||
if project.ID == "" || project.Name == "" {
|
if project.ID == "" || project.Name == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
regions = append(regions, CloudRegion{RegionID: project.ID, LocalName: project.Name, Endpoint: "https://ecs." + project.Name + ".myhuaweicloud.com", Status: "active"})
|
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 })
|
sort.Slice(regions, func(i, j int) bool { return regions[i].LocalName < regions[j].LocalName })
|
||||||
return regions, nil
|
return regions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListInstances 获取指定华为云项目中的 ECS 实例。 // Logan.Li
|
// ListInstances 获取指定华为云项目中的 ECS 实例。 // Logan.Li
|
||||||
func (p *HuaweiProvider) ListInstances(projectID string) ([]CloudInstance, error) {
|
func (p *HuaweiProvider) ListInstances(projectID string) ([]CloudInstance, error) {
|
||||||
p.regionMu.RLock()
|
regions, err := p.ListRegions()
|
||||||
regionName := p.regionByProject[projectID]
|
if err != nil {
|
||||||
p.regionMu.RUnlock()
|
return nil, err
|
||||||
if regionName == "" {
|
}
|
||||||
if _, err := p.ListRegions(); err != nil {
|
regionName := ""
|
||||||
return nil, err
|
for _, region := range regions {
|
||||||
|
if region.RegionID == projectID {
|
||||||
|
regionName = region.LocalName
|
||||||
|
break
|
||||||
}
|
}
|
||||||
p.regionMu.RLock()
|
|
||||||
regionName = p.regionByProject[projectID]
|
|
||||||
p.regionMu.RUnlock()
|
|
||||||
}
|
}
|
||||||
if regionName == "" {
|
if regionName == "" {
|
||||||
return nil, fmt.Errorf("未找到华为云项目 %s", projectID)
|
return nil, fmt.Errorf("未找到华为云项目 %s", projectID)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,13 +43,13 @@ func NewProvider(provider, accessKey, secretKey string) Provider {
|
|||||||
case "aliyun":
|
case "aliyun":
|
||||||
return NewAliyunProvider(accessKey, secretKey)
|
return NewAliyunProvider(accessKey, secretKey)
|
||||||
case "aws":
|
case "aws":
|
||||||
return NewAWSProvider(accessKey, secretKey)
|
return NewAWSProvider(accessKey, secretKey) // 启用 AWS EC2 区域与实例同步。 // Logan.Li
|
||||||
case "tencent":
|
case "tencent":
|
||||||
return NewTencentProvider(accessKey, secretKey) // 启用腾讯云 CVM 区域与实例同步。 // Logan.Li
|
return NewTencentProvider(accessKey, secretKey) // 启用腾讯云 CVM 区域与实例同步。 // Logan.Li
|
||||||
case "huawei":
|
case "huawei":
|
||||||
return NewHuaweiProvider(accessKey, secretKey)
|
return NewHuaweiProvider(accessKey, secretKey) // 启用华为云 ECS 区域与实例同步。 // Logan.Li
|
||||||
case "azure":
|
case "azure":
|
||||||
return NewAzureProvider(accessKey, secretKey)
|
return NewAzureProvider(accessKey, secretKey) // 启用 Azure VM 区域与实例同步。 // Logan.Li
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package cloud
|
package cloud
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -126,12 +124,12 @@ func (p *TencentProvider) callServiceOnce(service, host, version, action, region
|
|||||||
timestamp, date := now.Unix(), now.UTC().Format("2006-01-02")
|
timestamp, date := now.Unix(), now.UTC().Format("2006-01-02")
|
||||||
canonicalHeaders := "content-type:application/json; charset=utf-8\nhost:" + host + "\n"
|
canonicalHeaders := "content-type:application/json; charset=utf-8\nhost:" + host + "\n"
|
||||||
signedHeaders := "content-type;host"
|
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"
|
credentialScope := date + "/" + service + "/tc3_request"
|
||||||
stringToSign := "TC3-HMAC-SHA256\n" + fmt.Sprint(timestamp) + "\n" + credentialScope + "\n" + tencentSHA256Hex(canonicalRequest)
|
stringToSign := "TC3-HMAC-SHA256\n" + fmt.Sprint(timestamp) + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest)
|
||||||
secretDate := tencentHMACSHA256([]byte("TC3"+p.secretKey), date)
|
secretDate := hmacSHA256([]byte("TC3"+p.secretKey), date)
|
||||||
secretService := tencentHMACSHA256(secretDate, service)
|
secretService := hmacSHA256(secretDate, service)
|
||||||
signature := hex.EncodeToString(tencentHMACSHA256(tencentHMACSHA256(secretService, "tc3_request"), stringToSign))
|
signature := hex.EncodeToString(hmacSHA256(hmacSHA256(secretService, "tc3_request"), stringToSign))
|
||||||
req, err := http.NewRequest(http.MethodPost, "https://"+host, strings.NewReader(string(body)))
|
req, err := http.NewRequest(http.MethodPost, "https://"+host, strings.NewReader(string(body)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建腾讯云请求失败: %w", err)
|
return nil, fmt.Errorf("创建腾讯云请求失败: %w", err)
|
||||||
@@ -217,7 +215,8 @@ func (p *TencentProvider) ListInstances(regionID string) ([]CloudInstance, error
|
|||||||
return nil, fmt.Errorf("解析腾讯云实例响应失败: %w", err)
|
return nil, fmt.Errorf("解析腾讯云实例响应失败: %w", err)
|
||||||
}
|
}
|
||||||
for _, item := range result.Response.InstanceSet {
|
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 {
|
if len(result.Response.InstanceSet) == 0 || len(instances) >= result.Response.TotalCount {
|
||||||
break
|
break
|
||||||
@@ -226,8 +225,8 @@ func (p *TencentProvider) ListInstances(regionID string) ([]CloudInstance, error
|
|||||||
return instances, nil
|
return instances, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// tencentFirstString 获取 API 列表中的首个 IP。 // Logan.Li
|
// firstString 获取 API 列表中的首个 IP。 // Logan.Li
|
||||||
func tencentFirstString(values []string) string {
|
func firstString(values []string) string {
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -241,16 +240,3 @@ func tencentState(state string) string {
|
|||||||
}
|
}
|
||||||
return state
|
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)
|
|
||||||
}
|
|
||||||
@@ -23,11 +23,9 @@ func (p *TencentProvider) FetchDailyBill(billingDate string) ([]CloudBillingReco
|
|||||||
const pageSize = 100
|
const pageSize = 100
|
||||||
|
|
||||||
for offset := 0; ; offset += pageSize {
|
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{}{
|
body, err := p.callService("billing", "billing.tencentcloudapi.com", "2018-07-09", "DescribeBillDetail", "", map[string]interface{}{
|
||||||
"Month": billingDate[:7],
|
"Month": billingDate[:7],
|
||||||
"BeginTime": billingDate + " 00:00:00",
|
|
||||||
"EndTime": billingDate + " 23:59:59",
|
|
||||||
"Offset": offset,
|
"Offset": offset,
|
||||||
"Limit": pageSize,
|
"Limit": pageSize,
|
||||||
"NeedRecordNum": 1,
|
"NeedRecordNum": 1,
|
||||||
|
|||||||
@@ -26,12 +26,3 @@ func TestTencentRetryDelay(t *testing.T) {
|
|||||||
t.Fatal("非限频错误不应重试")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,15 +9,24 @@ import (
|
|||||||
|
|
||||||
// Config 应用配置
|
// Config 应用配置
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Mode string `mapstructure:"mode"`
|
Mode string `mapstructure:"mode"`
|
||||||
Port string `mapstructure:"port"`
|
Port string `mapstructure:"port"`
|
||||||
LogLevel string `mapstructure:"log_level"`
|
LogLevel string `mapstructure:"log_level"`
|
||||||
Database DatabaseConfig `mapstructure:"database"`
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
Redis RedisConfig `mapstructure:"redis"`
|
Agent AgentConfig `mapstructure:"agent"`
|
||||||
|
Retention RetentionConfig `mapstructure:"retention"`
|
||||||
|
Billing BillingConfig `mapstructure:"billing"`
|
||||||
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
Websocket WebsocketConfig `mapstructure:"websocket"`
|
Websocket WebsocketConfig `mapstructure:"websocket"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BillingConfig 定义云账单报告本位币及各原币种折算到 CNY 的汇率。 // Logan.Li
|
||||||
|
type BillingConfig struct {
|
||||||
|
BaseCurrency string `mapstructure:"base_currency"`
|
||||||
|
ExchangeRates map[string]float64 `mapstructure:"exchange_rates"`
|
||||||
|
}
|
||||||
|
|
||||||
// DatabaseConfig 数据库配置
|
// DatabaseConfig 数据库配置
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
Driver string `mapstructure:"driver"`
|
Driver string `mapstructure:"driver"`
|
||||||
@@ -37,9 +46,25 @@ type JWTConfig struct {
|
|||||||
ExpireHour int `mapstructure:"expire_hour"`
|
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配置
|
// RedisConfig Redis配置
|
||||||
type RedisConfig struct {
|
type RedisConfig struct {
|
||||||
Driver string `mapstructure:"driver"` // redka (内嵌) / redis (外部)
|
Driver string `mapstructure:"driver"` // redka (内嵌) / redis (外部)
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
Password string `mapstructure:"password"`
|
Password string `mapstructure:"password"`
|
||||||
@@ -71,6 +96,17 @@ func Load() *Config {
|
|||||||
viper.SetDefault("database.max_conn", 100)
|
viper.SetDefault("database.max_conn", 100)
|
||||||
viper.SetDefault("database.max_idle", 10)
|
viper.SetDefault("database.max_idle", 10)
|
||||||
viper.SetDefault("jwt.expire_hour", 24)
|
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.host", "localhost")
|
||||||
viper.SetDefault("redis.port", 6379)
|
viper.SetDefault("redis.port", 6379)
|
||||||
viper.SetDefault("redis.driver", "redka")
|
viper.SetDefault("redis.driver", "redka")
|
||||||
|
|||||||
+98
-92
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ezops/middleware"
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
ezredis "ezops/pkg/redis"
|
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}
|
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 上报的指标数据
|
// ReceiveReport 接收 Agent 上报的指标数据
|
||||||
// POST /api/v1/agent/report
|
// POST /api/v1/agent/report
|
||||||
func (h *AgentHandler) ReceiveReport(c *gin.Context) {
|
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()})
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求格式错误: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !middleware.RequireAgentHostname(c, req.Hostname) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 解析上报时间
|
// 解析上报时间
|
||||||
reportTime := time.Now()
|
reportTime := time.Now()
|
||||||
@@ -46,6 +68,14 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
|
|||||||
reportTime = t
|
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)
|
diskJSON, _ := json.Marshal(req.Disks)
|
||||||
@@ -61,8 +91,7 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
|
|||||||
|
|
||||||
// 存储到数据库
|
// 存储到数据库
|
||||||
report := model.AgentReport{
|
report := model.AgentReport{
|
||||||
Hostname: req.Hostname,
|
Hostname: canonicalHostname,
|
||||||
IP: strings.TrimSpace(req.IP), // 使用 Agent 自报 IP,避免反向代理地址误关联。 // Logan.Li
|
|
||||||
CPUUsage: req.CPU.UsagePercent,
|
CPUUsage: req.CPU.UsagePercent,
|
||||||
CPUIOwait: req.CPU.IOWait,
|
CPUIOwait: req.CPU.IOWait,
|
||||||
CPUCores: req.CPU.CoreCount,
|
CPUCores: req.CPU.CoreCount,
|
||||||
@@ -104,86 +133,71 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
|
|||||||
memMB := int64(memGB * 1024) // GB → MB
|
memMB := int64(memGB * 1024) // GB → MB
|
||||||
diskGB := int64(totalDiskGB + 0.5) // 39.99 → 40
|
diskGB := int64(totalDiskGB + 0.5) // 39.99 → 40
|
||||||
|
|
||||||
clientIP := c.ClientIP()
|
// 使用 map 按主键更新,避免可选系统字段导致原始 SQL 占位参数错位。 // Logan.Li
|
||||||
|
updates := map[string]interface{}{
|
||||||
// 三级匹配:优先用 Agent 上报的 IP,其次用请求源 IP,最后用报文的 hostname
|
"cpu_usage": req.CPU.UsagePercent, "memory_usage": req.Memory.UsagePercent,
|
||||||
matchIP := req.IP
|
"disk_usage": diskAvg,
|
||||||
if matchIP == "" {
|
"cpu": req.CPU.CoreCount, "memory": memMB, "disk": diskGB,
|
||||||
matchIP = clientIP
|
"status": "running", "agent_status": "online", "updated_at": time.Now(),
|
||||||
}
|
|
||||||
|
|
||||||
// 直接 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",
|
|
||||||
}
|
}
|
||||||
if req.AgentVersion != "" {
|
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 != nil {
|
||||||
if req.System.OSVersion != "" {
|
if req.System.OSVersion != "" {
|
||||||
updateSQL += ", os = ?"
|
updates["os"] = req.System.OSVersion
|
||||||
args = append(args, req.System.OSVersion)
|
|
||||||
}
|
}
|
||||||
if req.System.Kernel != "" {
|
if req.System.Kernel != "" {
|
||||||
updateSQL += ", kernel = ?"
|
updates["kernel"] = req.System.Kernel
|
||||||
args = append(args, req.System.Kernel)
|
|
||||||
}
|
}
|
||||||
if req.System.Uptime > 0 {
|
if req.System.Uptime > 0 {
|
||||||
updateSQL += ", uptime = ?"
|
updates["uptime"] = req.System.Uptime
|
||||||
args = append(args, req.System.Uptime)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
args = append(args, time.Now())
|
// 兼容旧版 Agent:系统信息缺席时使用心跳中的运行时长补齐资产元数据。 // Logan.Li
|
||||||
updateSQL += " WHERE hostname = ? OR ip = ?"
|
if _, ok := updates["uptime"]; !ok && req.Heartbeat.UptimeSec > 0 {
|
||||||
args = append(args, req.Hostname, matchIP)
|
updates["uptime"] = req.Heartbeat.UptimeSec
|
||||||
|
}
|
||||||
result := h.db.Exec(updateSQL, args...)
|
var result *gorm.DB
|
||||||
|
if assetMatched {
|
||||||
// 如果 hostname 和 IP 都没匹配到,尝试用 hostname 反向匹配 IP 字段
|
result = h.db.Model(&model.Server{}).Where("id = ?", matchedServer.ID).Updates(updates)
|
||||||
if result.RowsAffected == 0 && req.Hostname != matchIP {
|
} else {
|
||||||
updateSQL2 := updateSQL + " OR ip = ?"
|
result = h.db.Model(&model.Server{}).Where("hostname = ? OR ip = ?", req.Hostname, matchIP).Updates(updates)
|
||||||
args2 := append(args, req.Hostname)
|
|
||||||
result = h.db.Exec(updateSQL2, args2...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果还是没匹配到 —— 按 hostname 显式查重后再注册 // Logan.Li
|
// 如果还是没匹配到 —— 按 hostname 显式查重后再注册 // Logan.Li
|
||||||
// asset_servers.hostname 无 UNIQUE 索引,OnConflict 不可靠,故先 First 查重,
|
// asset_servers.hostname 无 UNIQUE 索引,OnConflict 不可靠,故先 First 查重,
|
||||||
// 避免 Agent 每次上报(10s)都新建记录导致资产越来越多。
|
// 避免 Agent 每次上报(10s)都新建记录导致资产越来越多。
|
||||||
if result.RowsAffected == 0 {
|
if result.Error == nil && result.RowsAffected == 0 {
|
||||||
osName := ""
|
osName := ""
|
||||||
|
kernel := ""
|
||||||
|
uptime := req.Heartbeat.UptimeSec
|
||||||
if req.System != nil {
|
if req.System != nil {
|
||||||
osName = req.System.OSVersion
|
osName = req.System.OSVersion
|
||||||
|
kernel = req.System.Kernel
|
||||||
|
if req.System.Uptime > 0 {
|
||||||
|
uptime = req.System.Uptime
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var existing model.Server
|
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,
|
"ip": matchIP, "os": osName, "cpu": req.CPU.CoreCount,
|
||||||
"memory": memMB, "disk": diskGB, "status": "running",
|
"memory": memMB, "disk": diskGB, "status": "running",
|
||||||
"agent_status": "online", "agent_version": req.AgentVersion,
|
"agent_status": "online", "agent_version": req.AgentVersion,
|
||||||
"cpu_usage": req.CPU.UsagePercent, "memory_usage": req.Memory.UsagePercent,
|
"cpu_usage": req.CPU.UsagePercent, "memory_usage": req.Memory.UsagePercent,
|
||||||
"disk_usage": diskAvg,
|
"disk_usage": diskAvg,
|
||||||
})
|
}
|
||||||
|
if kernel != "" {
|
||||||
|
existingUpdates["kernel"] = kernel
|
||||||
|
}
|
||||||
|
if uptime > 0 {
|
||||||
|
existingUpdates["uptime"] = uptime
|
||||||
|
}
|
||||||
|
h.db.Model(&existing).Updates(existingUpdates)
|
||||||
} else {
|
} else {
|
||||||
// 真正的新主机 → 注册(OnConflict 作为并发双保险)
|
// 真正的新主机 → 注册,hostname 无唯一索引时不使用 OnConflict。 // Logan.Li
|
||||||
newServer := model.Server{
|
newServer := model.Server{
|
||||||
Hostname: req.Hostname,
|
Hostname: req.Hostname,
|
||||||
IP: matchIP,
|
IP: matchIP,
|
||||||
@@ -197,19 +211,13 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
|
|||||||
Status: "running",
|
Status: "running",
|
||||||
AgentStatus: "online",
|
AgentStatus: "online",
|
||||||
AgentVersion: req.AgentVersion,
|
AgentVersion: req.AgentVersion,
|
||||||
|
Kernel: kernel,
|
||||||
|
Uptime: uptime,
|
||||||
CPUUsage: req.CPU.UsagePercent,
|
CPUUsage: req.CPU.UsagePercent,
|
||||||
MemoryUsage: req.Memory.UsagePercent,
|
MemoryUsage: req.Memory.UsagePercent,
|
||||||
DiskUsage: diskAvg,
|
DiskUsage: diskAvg,
|
||||||
}
|
}
|
||||||
if err := h.db.Clauses(clause.OnConflict{
|
if err := h.db.Create(&newServer).Error; err != nil {
|
||||||
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 {
|
|
||||||
fmt.Printf("[agent] 自动注册主机 %q (IP: %s) 失败: %v\n", req.Hostname, matchIP, err)
|
fmt.Printf("[agent] 自动注册主机 %q (IP: %s) 失败: %v\n", req.Hostname, matchIP, err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[agent] 自动注册新主机: %q (IP: %s, CPU=%d核, 内存=%dMB, 磁盘=%dGB, OS=%q)\n",
|
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 {
|
if h.hubMetrics != nil && result.RowsAffected > 0 {
|
||||||
h.hubMetrics.Broadcast(MetricsUpdate{
|
h.hubMetrics.Broadcast(MetricsUpdate{
|
||||||
Type: "metrics_update",
|
Type: "metrics_update",
|
||||||
Hostname: req.Hostname,
|
Hostname: canonicalHostname,
|
||||||
CPU: req.CPU.UsagePercent,
|
CPU: req.CPU.UsagePercent,
|
||||||
Memory: req.Memory.UsagePercent,
|
Memory: req.Memory.UsagePercent,
|
||||||
Disk: diskAvg,
|
Disk: diskAvg,
|
||||||
@@ -240,13 +248,13 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
|
|||||||
if h.cache != nil && h.cache.Capabilities().SortedSet {
|
if h.cache != nil && h.cache.Capabilities().SortedSet {
|
||||||
timestamp := float64(reportTime.Unix())
|
timestamp := float64(reportTime.Unix())
|
||||||
ttl := 7 * 24 * time.Hour
|
ttl := 7 * 24 * time.Hour
|
||||||
h.writeHotMetric(c, "metrics:cpu:"+req.Hostname, req.CPU.UsagePercent, timestamp, ttl, reportTime)
|
h.writeHotMetric(c, "metrics:cpu:"+canonicalHostname, req.CPU.UsagePercent, timestamp, ttl, reportTime)
|
||||||
h.writeHotMetric(c, "metrics:mem:"+req.Hostname, req.Memory.UsagePercent, timestamp, ttl, reportTime)
|
h.writeHotMetric(c, "metrics:mem:"+canonicalHostname, req.Memory.UsagePercent, timestamp, ttl, reportTime)
|
||||||
h.writeHotMetric(c, "metrics:load1:"+req.Hostname, req.CPU.Load1, timestamp, ttl, reportTime)
|
h.writeHotMetric(c, "metrics:load1:"+canonicalHostname, req.CPU.Load1, timestamp, ttl, reportTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存成功 — 响应中携带探针配置给 Agent
|
// 保存成功 — 响应中携带探针配置给 Agent
|
||||||
resp := h.makeProbeConfigResponse(req.Hostname, 200, "ok", req.ProbeResults)
|
resp := h.makeProbeConfigResponse(canonicalHostname, 200, "ok", req.ProbeResults)
|
||||||
c.JSON(http.StatusOK, resp)
|
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
|
// GET /api/v1/agent/latest
|
||||||
func (h *AgentHandler) GetLatestReports(c *gin.Context) {
|
func (h *AgentHandler) GetLatestReports(c *gin.Context) {
|
||||||
var results []model.AgentLatestResponse
|
var results []model.AgentLatestResponse
|
||||||
threeMinAgo := time.Now().UTC().Add(-3 * time.Minute) // SQLite 上报时间统一以 UTC 持久化。 // Logan.Li
|
// 查询边界与 Agent 上报的 UTC 时间保持一致。 // Logan.Li
|
||||||
// 以 servers 表为主,按资产名或 Agent 自报 IP 关联最近 3 分钟内的上报。 // Logan.Li
|
threeMinAgo := time.Now().UTC().Add(-3 * time.Minute)
|
||||||
|
// 以 servers 表为主,LEFT JOIN 最近 3 分钟内的 Agent 上报
|
||||||
// 未装 Agent 的主机也会显示(agent_status='not_installed'),打破"不显示→无法安装"的循环
|
// 未装 Agent 的主机也会显示(agent_status='not_installed'),打破"不显示→无法安装"的循环
|
||||||
h.db.Raw(`
|
h.db.Raw(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -303,8 +312,7 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) {
|
|||||||
a.report_time,
|
a.report_time,
|
||||||
COALESCE(s.agent_version, '') as agent_version,
|
COALESCE(s.agent_version, '') as agent_version,
|
||||||
CASE
|
CASE
|
||||||
WHEN a.id IS NOT NULL THEN 'online'
|
WHEN a.hostname IS NOT NULL THEN COALESCE(s.agent_status, 'online')
|
||||||
WHEN COALESCE(s.agent_version, '') <> '' OR COALESCE(s.agent_status, '') = 'online' THEN 'offline'
|
|
||||||
ELSE 'not_installed'
|
ELSE 'not_installed'
|
||||||
END as agent_status,
|
END as agent_status,
|
||||||
COALESCE(s.os, '') as os,
|
COALESCE(s.os, '') as os,
|
||||||
@@ -314,13 +322,14 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) {
|
|||||||
COALESCE(r.name, '') as region,
|
COALESCE(r.name, '') as region,
|
||||||
CASE WHEN s.password != '' OR s.ssh_key != '' THEN true ELSE false END as has_auth
|
CASE WHEN s.password != '' OR s.ssh_key != '' THEN true ELSE false END as has_auth
|
||||||
FROM asset_servers s
|
FROM asset_servers s
|
||||||
LEFT JOIN monitor_agent_reports a ON a.id = (
|
LEFT JOIN (
|
||||||
SELECT ar.id FROM monitor_agent_reports ar
|
SELECT * FROM monitor_agent_reports
|
||||||
WHERE ar.report_time >= ?
|
WHERE id IN (
|
||||||
AND (ar.hostname = s.hostname OR (ar.ip <> '' AND ar.ip = s.ip))
|
SELECT MAX(id) FROM monitor_agent_reports
|
||||||
ORDER BY ar.report_time DESC, ar.id DESC
|
WHERE report_time >= ?
|
||||||
LIMIT 1
|
GROUP BY hostname
|
||||||
)
|
)
|
||||||
|
) a ON a.hostname = s.hostname
|
||||||
LEFT JOIN asset_regions r ON r.id = s.region_id
|
LEFT JOIN asset_regions r ON r.id = s.region_id
|
||||||
ORDER BY a.report_time DESC, s.id ASC
|
ORDER BY a.report_time DESC, s.id ASC
|
||||||
`, threeMinAgo).Scan(&results)
|
`, threeMinAgo).Scan(&results)
|
||||||
@@ -348,7 +357,7 @@ func parseFlexibleTime(s string) (time.Time, error) {
|
|||||||
}
|
}
|
||||||
for _, layout := range layouts {
|
for _, layout := range layouts {
|
||||||
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
|
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)
|
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
|
// resolveHostHistoryRange 解析历史查询时间范围(minutes / hours / start+end) // Logan.Li
|
||||||
func resolveHostHistoryRange(c *gin.Context) (since, until time.Time, err error) {
|
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 != "" {
|
if startStr := strings.TrimSpace(c.Query("start")); startStr != "" {
|
||||||
endStr := strings.TrimSpace(c.Query("end"))
|
endStr := strings.TrimSpace(c.Query("end"))
|
||||||
if endStr == "" {
|
if endStr == "" {
|
||||||
@@ -410,16 +419,9 @@ func (h *AgentHandler) GetHostHistory(c *gin.Context) {
|
|||||||
return
|
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
|
var reports []model.AgentReport
|
||||||
// 资产名可能与操作系统主机名不同,优先兼容 Agent 自报 IP。 // Logan.Li
|
// SQLite 会按时间文本比较,查询边界需统一转换为 UTC。 // Logan.Li
|
||||||
h.db.Where("hostname = ? OR (ip <> '' AND ip = ?)", hostname, serverIP).
|
h.db.Where("hostname = ? AND report_time >= ? AND report_time <= ?", hostname, since.UTC(), until.UTC()).
|
||||||
Where("report_time >= ? AND report_time <= ?", since, until).
|
|
||||||
Order("report_time ASC").
|
Order("report_time ASC").
|
||||||
Find(&reports)
|
Find(&reports)
|
||||||
|
|
||||||
@@ -431,7 +433,7 @@ func (h *AgentHandler) GetHostHistory(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AggregateMetrics 聚合指标到 monitor_metrics_aggregate(Redis ZSet 或 DB 直读)// Logan.Li
|
// 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) {
|
func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
|
||||||
var req model.AggregateReq
|
var req model.AggregateReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -504,7 +506,8 @@ func (h *AgentHandler) aggregateFromReports(req model.AggregateReq) (int64, erro
|
|||||||
|
|
||||||
for _, iv := range intervals {
|
for _, iv := range intervals {
|
||||||
lookback := iv.duration * 200
|
lookback := iv.duration * 200
|
||||||
since := time.Now().Add(-lookback)
|
// 本地 SQLite 模式下使用 UTC 边界查询 Agent 报告。 // Logan.Li
|
||||||
|
since := time.Now().UTC().Add(-lookback)
|
||||||
var reports []model.AgentReport
|
var reports []model.AgentReport
|
||||||
if err := h.db.Where("hostname = ? AND report_time >= ?", req.Hostname, since).
|
if err := h.db.Where("hostname = ? AND report_time >= ?", req.Hostname, since).
|
||||||
Order("report_time ASC").Find(&reports).Error; err != nil {
|
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
|
// GET /api/v1/agent/port-policy?hostname=xxx
|
||||||
func (h *AgentHandler) GetPortPolicyForAgent(c *gin.Context) {
|
func (h *AgentHandler) GetPortPolicyForAgent(c *gin.Context) {
|
||||||
hostname := c.Query("hostname")
|
hostname := c.Query("hostname")
|
||||||
|
if !middleware.RequireAgentHostname(c, hostname) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: model.AgentPortPolicyDTO{
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: model.AgentPortPolicyDTO{
|
||||||
PortFilterMode: "whitelist",
|
PortFilterMode: "whitelist",
|
||||||
|
|||||||
+233
-54
@@ -19,6 +19,7 @@ import (
|
|||||||
"ezops/model"
|
"ezops/model"
|
||||||
"ezops/pkg/agentcli"
|
"ezops/pkg/agentcli"
|
||||||
"ezops/pkg/agentcli/core"
|
"ezops/pkg/agentcli/core"
|
||||||
|
"ezops/pkg/agentcli/guardrail"
|
||||||
"ezops/pkg/agentcli/llm"
|
"ezops/pkg/agentcli/llm"
|
||||||
"ezops/pkg/agentcli/safety"
|
"ezops/pkg/agentcli/safety"
|
||||||
"ezops/pkg/agentcli/tools"
|
"ezops/pkg/agentcli/tools"
|
||||||
@@ -36,21 +37,24 @@ func NewAgentCLIHandler(db *gorm.DB) *AgentCLIHandler { return &AgentCLIHandler{
|
|||||||
|
|
||||||
// wsEvent WebSocket 事件信封(后端→前端) // Logan.Li
|
// wsEvent WebSocket 事件信封(后端→前端) // Logan.Li
|
||||||
type wsEvent struct {
|
type wsEvent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Round int `json:"round,omitempty"`
|
Phase string `json:"phase,omitempty"`
|
||||||
SessionID uint `json:"session_id,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
Thought string `json:"thought,omitempty"`
|
Round int `json:"round,omitempty"`
|
||||||
ToolName string `json:"tool_name,omitempty"`
|
SessionID uint `json:"session_id,omitempty"`
|
||||||
ToolArgs map[string]interface{} `json:"tool_args,omitempty"`
|
Thought string `json:"thought,omitempty"`
|
||||||
Risk string `json:"risk,omitempty"`
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
Output string `json:"output,omitempty"`
|
ToolArgs map[string]interface{} `json:"tool_args,omitempty"`
|
||||||
Success bool `json:"success,omitempty"`
|
Risk string `json:"risk,omitempty"`
|
||||||
Duration string `json:"duration,omitempty"`
|
Output string `json:"output,omitempty"`
|
||||||
Tokens int `json:"tokens,omitempty"`
|
Success bool `json:"success,omitempty"`
|
||||||
Answer string `json:"answer,omitempty"`
|
Duration string `json:"duration,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
OutputSize int `json:"output_size,omitempty"`
|
||||||
ConfirmID string `json:"confirm_id,omitempty"`
|
Tokens int `json:"tokens,omitempty"`
|
||||||
Command string `json:"command,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
|
// wsControl 前端→后端控制消息 // Logan.Li
|
||||||
@@ -83,7 +87,7 @@ type agentConn struct {
|
|||||||
server model.Server
|
server model.Server
|
||||||
sshCfg remoteexec.SSHConfig
|
sshCfg remoteexec.SSHConfig
|
||||||
kubernetesCtx *kubernetesAgentContext
|
kubernetesCtx *kubernetesAgentContext
|
||||||
session *model.AgentCLISession
|
session *model.AIRun
|
||||||
taskCtx context.Context
|
taskCtx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
totalTokens int
|
totalTokens int
|
||||||
@@ -94,6 +98,9 @@ type agentConn struct {
|
|||||||
convHistory []llm.Message
|
convHistory []llm.Message
|
||||||
running bool
|
running bool
|
||||||
runningMu sync.Mutex
|
runningMu sync.Mutex
|
||||||
|
pendingSpans map[string]string
|
||||||
|
seq int
|
||||||
|
seqMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run WebSocket 入口 // Logan.Li
|
// Run WebSocket 入口 // Logan.Li
|
||||||
@@ -141,7 +148,7 @@ func (h *AgentCLIHandler) runHostWS(c *gin.Context) {
|
|||||||
ac := &agentConn{
|
ac := &agentConn{
|
||||||
conn: conn, db: h.db, mode: agentcli.RunModeHost,
|
conn: conn, db: h.db, mode: agentcli.RunModeHost,
|
||||||
userID: middleware.GetUserID(c), server: server, sshCfg: sshCfg,
|
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,
|
ac.write(wsEvent{Type: "session", SessionID: 0,
|
||||||
Message: fmt.Sprintf("已连接主机 %s(%s),LLM: %s。请描述要执行的任务。", server.Hostname, server.IP, llmCfg.Model)})
|
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{
|
ac := &agentConn{
|
||||||
conn: conn, db: h.db, mode: agentcli.RunModeKubernetes,
|
conn: conn, db: h.db, mode: agentcli.RunModeKubernetes,
|
||||||
userID: middleware.GetUserID(c), kubernetesCtx: kCtx,
|
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)
|
target := buildKubernetesTargetInfo(kCtx)
|
||||||
ac.write(wsEvent{Type: "session", SessionID: 0,
|
ac.write(wsEvent{Type: "session", SessionID: 0,
|
||||||
@@ -191,7 +198,9 @@ func (ac *agentConn) wsReadLoop() {
|
|||||||
for {
|
for {
|
||||||
_, data, err := ac.conn.ReadMessage()
|
_, data, err := ac.conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ac.abort()
|
if ac.isRunning() {
|
||||||
|
ac.abort()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var ctrl wsControl
|
var ctrl wsControl
|
||||||
@@ -253,20 +262,27 @@ func (ac *agentConn) startTask(query, safetyMode string) {
|
|||||||
if r := []rune(title); len(r) > 60 {
|
if r := []rune(title); len(r) > 60 {
|
||||||
title = string(r[:60]) + "..."
|
title = string(r[:60]) + "..."
|
||||||
}
|
}
|
||||||
sess := &model.AgentCLISession{
|
sess := &model.AIRun{
|
||||||
UserID: ac.userID, Mode: ac.mode, Title: title, Status: "running",
|
RunID: guardrail.NewTraceID(), UserID: ac.userID, Feature: "agent_cli",
|
||||||
|
Mode: ac.mode, Title: title, Status: "running",
|
||||||
Provider: llmCfg.Provider, Model: llmCfg.Model,
|
Provider: llmCfg.Provider, Model: llmCfg.Model,
|
||||||
|
SafetyMode: safetyMode, StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil {
|
if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil {
|
||||||
sess.ClusterID = ac.kubernetesCtx.cluster.ID
|
sess.SubjectType = "k8s_cluster"
|
||||||
sess.ClusterName = ac.kubernetesCtx.cluster.Name
|
if ac.kubernetesCtx.podName != "" {
|
||||||
sess.Namespace = ac.kubernetesCtx.namespace
|
sess.SubjectType = "pod"
|
||||||
sess.PodName = ac.kubernetesCtx.podName
|
} else if ac.kubernetesCtx.namespace != "" {
|
||||||
sess.Container = ac.kubernetesCtx.container
|
sess.SubjectType = "namespace"
|
||||||
|
}
|
||||||
|
sess.SubjectID = fmt.Sprintf("%d", ac.kubernetesCtx.cluster.ID)
|
||||||
|
sess.SubjectName = ac.kubernetesCtx.cluster.Name
|
||||||
|
sess.SubjectScope = ac.kubernetesCtx.namespace
|
||||||
} else {
|
} else {
|
||||||
sess.ServerID = ac.server.ID
|
sess.SubjectType = "host"
|
||||||
sess.ServerName = ac.server.Hostname
|
sess.SubjectID = fmt.Sprintf("%d", ac.server.ID)
|
||||||
sess.ServerHost = ac.server.IP
|
sess.SubjectName = ac.server.Hostname
|
||||||
|
sess.SubjectScope = ac.server.IP
|
||||||
}
|
}
|
||||||
if err := ac.db.Create(sess).Error; err != nil {
|
if err := ac.db.Create(sess).Error; err != nil {
|
||||||
ac.write(wsEvent{Type: "error", Message: "创建会话失败: " + err.Error()})
|
ac.write(wsEvent{Type: "error", Message: "创建会话失败: " + err.Error()})
|
||||||
@@ -284,15 +300,15 @@ func (ac *agentConn) resumeSession(sessionID uint) {
|
|||||||
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var sess model.AgentCLISession
|
var sess model.AIRun
|
||||||
q := ac.db.Where("user_id = ?", ac.userID)
|
q := ac.db.Where("user_id = ?", ac.userID)
|
||||||
if ac.mode == agentcli.RunModeKubernetes {
|
if ac.mode == agentcli.RunModeKubernetes {
|
||||||
q = q.Where("mode IN ?", []string{agentcli.RunModeKubernetes, "k8s"})
|
q = q.Where("mode IN ?", []string{agentcli.RunModeKubernetes, "k8s"})
|
||||||
if ac.kubernetesCtx != nil {
|
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 {
|
} 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 {
|
if err := q.First(&sess, sessionID).Error; err != nil {
|
||||||
ac.write(wsEvent{Type: "error", Message: "会话不存在或无权访问"})
|
ac.write(wsEvent{Type: "error", Message: "会话不存在或无权访问"})
|
||||||
@@ -302,8 +318,8 @@ func (ac *agentConn) resumeSession(sessionID uint) {
|
|||||||
ac.write(wsEvent{Type: "error", Message: "该会话仍在执行中,无法恢复"})
|
ac.write(wsEvent{Type: "error", Message: "该会话仍在执行中,无法恢复"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var msgs []model.AgentCLIMessage
|
var msgs []model.AIRunMessage
|
||||||
ac.db.Where("session_id = ?", sessionID).Order("id ASC").Find(&msgs)
|
ac.db.Where("run_id = ?", sess.RunID).Order("seq ASC, id ASC").Find(&msgs)
|
||||||
ac.session = &sess
|
ac.session = &sess
|
||||||
ac.convHistory = buildConvHistoryFromMessages(msgs)
|
ac.convHistory = buildConvHistoryFromMessages(msgs)
|
||||||
ac.setTotalTokens(sess.TotalTokens)
|
ac.setTotalTokens(sess.TotalTokens)
|
||||||
@@ -337,7 +353,7 @@ func (ac *agentConn) continueTask(query, safetyMode string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runTask 执行一轮 ReAct(新建或追问共用) // Logan.Li
|
// 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() {
|
if ac.isRunning() {
|
||||||
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
||||||
return
|
return
|
||||||
@@ -390,9 +406,9 @@ func (ac *agentConn) runTask(sess *model.AgentCLISession, query, safetyMode stri
|
|||||||
ac.taskCtx = ctx
|
ac.taskCtx = ctx
|
||||||
ac.cancel = cancel
|
ac.cancel = cancel
|
||||||
ac.setRunning(true)
|
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()
|
targetInfo := ac.buildTargetInfo()
|
||||||
|
|
||||||
@@ -454,11 +470,15 @@ func (ac *agentConn) onEvent(e core.Event) {
|
|||||||
if e.Type == core.EventProgress && e.Tokens > 0 {
|
if e.Type == core.EventProgress && e.Tokens > 0 {
|
||||||
ac.setTotalTokens(e.Tokens)
|
ac.setTotalTokens(e.Tokens)
|
||||||
}
|
}
|
||||||
|
if e.Type == core.EventDone || e.Type == core.EventError {
|
||||||
|
ac.setRunning(false)
|
||||||
|
}
|
||||||
|
|
||||||
ev := wsEvent{
|
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,
|
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,
|
Tokens: e.Tokens, Answer: e.Answer, Message: e.Message,
|
||||||
}
|
}
|
||||||
if ac.session != nil {
|
if ac.session != nil {
|
||||||
@@ -470,20 +490,25 @@ func (ac *agentConn) onEvent(e core.Event) {
|
|||||||
if ac.session == nil {
|
if ac.session == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sid := ac.session.ID
|
runID := ac.session.RunID
|
||||||
switch e.Type {
|
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:
|
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:
|
case core.EventToolCall:
|
||||||
argsJSON, _ := json.Marshal(e.ToolArgs)
|
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:
|
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:
|
case core.EventDone:
|
||||||
ac.setTotalTokens(e.Tokens)
|
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:
|
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") {
|
if cmd == "" && (toolName == "kubernetes_action" || toolName == "k8s_action") {
|
||||||
cmd = fmt.Sprintf("%v %v", args["action"], args["name"])
|
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)
|
ch := make(chan bool, 1)
|
||||||
ac.cfMu.Lock()
|
ac.cfMu.Lock()
|
||||||
@@ -512,6 +537,7 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case approved := <-ch:
|
case approved := <-ch:
|
||||||
|
ac.recordConfirmSpan(toolName, args, risk, approved)
|
||||||
return approved, nil
|
return approved, nil
|
||||||
case <-ac.taskCtx.Done():
|
case <-ac.taskCtx.Done():
|
||||||
ac.delConfirm(confirmID)
|
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
|
// resolveConfirm 前端回传确认结果 // Logan.Li
|
||||||
func (ac *agentConn) resolveConfirm(confirmID string, approved bool) {
|
func (ac *agentConn) resolveConfirm(confirmID string, approved bool) {
|
||||||
ac.cfMu.Lock()
|
ac.cfMu.Lock()
|
||||||
@@ -564,13 +609,13 @@ func (ac *agentConn) lastRound() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
var maxRound int
|
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)
|
Select("COALESCE(MAX(round),0)").Scan(&maxRound)
|
||||||
return maxRound
|
return maxRound
|
||||||
}
|
}
|
||||||
|
|
||||||
// finish 结束本轮执行并更新会话 DB // Logan.Li
|
// finish 结束本轮执行并更新会话 DB // Logan.Li
|
||||||
func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) {
|
func (ac *agentConn) finish(sess *model.AIRun, status, errMsg string, rounds int, dur time.Duration) {
|
||||||
ac.setRunning(false)
|
ac.setRunning(false)
|
||||||
tokens := ac.getTotalTokens()
|
tokens := ac.getTotalTokens()
|
||||||
// done/error 已由引擎 onEvent 推送;此处仅 aborted 需补推 WS(引擎初始化失败在 runTask 内单独推送) // Logan.Li
|
// 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(),
|
"status": status, "rounds": rounds, "duration": dur.Milliseconds(),
|
||||||
"total_tokens": tokens,
|
"total_tokens": tokens,
|
||||||
}
|
}
|
||||||
|
if status == "done" || status == "error" || status == "aborted" {
|
||||||
|
now := time.Now()
|
||||||
|
updates["finished_at"] = &now
|
||||||
|
}
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
updates["error"] = errMsg
|
updates["error"] = errMsg
|
||||||
}
|
}
|
||||||
@@ -595,15 +644,145 @@ func (ac *agentConn) write(ev wsEvent) {
|
|||||||
_ = ac.conn.WriteMessage(websocket.TextMessage, b)
|
_ = ac.conn.WriteMessage(websocket.TextMessage, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistMsg 持久化一条消息 // Logan.Li
|
// persistMsg 持久化一条 AI Run 回放消息。 // Logan.Li
|
||||||
func (ac *agentConn) persistMsg(sid uint, round int, typ, role, content, toolName, args, output, risk string, success bool, dur string, tokens int) {
|
func (ac *agentConn) persistMsg(runID string, round int, typ, role, content, toolName, args, output, risk string, success bool, dur string, tokens int) {
|
||||||
msg := &model.AgentCLIMessage{
|
content = guardrail.SanitizeText(content)
|
||||||
SessionID: sid, Round: round, Type: typ, Role: role, Content: content,
|
if typ == "tool_call" {
|
||||||
ToolName: toolName, ToolArgs: args, ToolOutput: output, Risk: risk,
|
content = guardrail.SanitizeJSON(content)
|
||||||
Success: success, Duration: dur, Tokens: tokens,
|
}
|
||||||
|
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 {
|
if err := ac.db.Create(msg).Error; err != nil {
|
||||||
// 持久化失败不阻断主流程
|
// 持久化失败不阻断主流程
|
||||||
_ = err
|
_ = 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()
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
|
|||||||
pageSize = 20
|
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 != "" {
|
if mode := c.Query("mode"); mode != "" {
|
||||||
mode = agentcli.NormalizeRunMode(mode)
|
mode = agentcli.NormalizeRunMode(mode)
|
||||||
if mode == agentcli.RunModeKubernetes {
|
if mode == agentcli.RunModeKubernetes {
|
||||||
@@ -36,18 +36,18 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sid := c.Query("server_id"); sid != "" {
|
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 != "" {
|
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 != "" {
|
if ns := c.Query("namespace"); ns != "" {
|
||||||
q = q.Where("namespace = ?", ns)
|
q = q.Where("subject_scope = ?", ns)
|
||||||
}
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
q.Count(&total)
|
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 {
|
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()})
|
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "查询失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -60,13 +60,13 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
|
|||||||
func (h *AgentCLIHandler) GetSession(c *gin.Context) {
|
func (h *AgentCLIHandler) GetSession(c *gin.Context) {
|
||||||
uid := middleware.GetUserID(c)
|
uid := middleware.GetUserID(c)
|
||||||
id := c.Param("id")
|
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 {
|
if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var msgs []model.AgentCLIMessage
|
var msgs []model.AIRunMessage
|
||||||
h.db.Where("session_id = ?", id).Order("id ASC").Find(&msgs)
|
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}})
|
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) {
|
func (h *AgentCLIHandler) DeleteSession(c *gin.Context) {
|
||||||
uid := middleware.GetUserID(c)
|
uid := middleware.GetUserID(c)
|
||||||
id := c.Param("id")
|
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 {
|
if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil {
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
||||||
return
|
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()})
|
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除消息失败: " + err.Error()})
|
||||||
return
|
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()})
|
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除会话失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// buildConvHistoryFromMessages 将 DB 消息重建为追问用的 prior 历史(不含 system) // Logan.Li
|
// 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 out []llm.Message
|
||||||
var pendingThought string
|
var pendingThought string
|
||||||
var pendingToolCalls []llm.ToolCall
|
var pendingToolCalls []llm.ToolCall
|
||||||
@@ -40,7 +40,7 @@ func buildConvHistoryFromMessages(msgs []model.AgentCLIMessage) []llm.Message {
|
|||||||
case "thought":
|
case "thought":
|
||||||
pendingThought = m.Content
|
pendingThought = m.Content
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
args := validateToolArgsJSON(m.ToolArgs)
|
args := validateToolArgsJSON(m.Content)
|
||||||
tcID := fmt.Sprintf("hist-%d", m.ID)
|
tcID := fmt.Sprintf("hist-%d", m.ID)
|
||||||
pendingToolCalls = append(pendingToolCalls, llm.ToolCall{
|
pendingToolCalls = append(pendingToolCalls, llm.ToolCall{
|
||||||
ID: tcID, Type: "function",
|
ID: tcID, Type: "function",
|
||||||
@@ -56,9 +56,6 @@ func buildConvHistoryFromMessages(msgs []model.AgentCLIMessage) []llm.Message {
|
|||||||
pendingToolCallIDs = pendingToolCallIDs[1:]
|
pendingToolCallIDs = pendingToolCallIDs[1:]
|
||||||
}
|
}
|
||||||
content := m.Content
|
content := m.Content
|
||||||
if content == "" {
|
|
||||||
content = m.ToolOutput
|
|
||||||
}
|
|
||||||
out = append(out, llm.Message{
|
out = append(out, llm.Message{
|
||||||
Role: "tool", Name: m.ToolName, Content: content, ToolCallID: toolID,
|
Role: "tool", Name: m.ToolName, Content: content, ToolCallID: toolID,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
|
"ezops/pkg/agentauth"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -142,28 +144,70 @@ func runSSHCommandWithTimeout(client *ssh.Client, cmd string, timeout time.Durat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// installServerURL 根据请求构造后端可访问地址,兼容 Nginx 等反向代理 // Logan.Li
|
// installServerURL 解析 Agent 对外服务地址,优先使用前端提交的浏览器 origin。 // Logan.Li
|
||||||
func installServerURL(c *gin.Context) string {
|
func installServerURL(c *gin.Context, preferred string) (string, error) {
|
||||||
|
if strings.TrimSpace(preferred) != "" {
|
||||||
|
return validateInstallServerURL(preferred)
|
||||||
|
}
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if c.Request.TLS != nil {
|
if c.Request.TLS != nil {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
|
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
|
||||||
scheme = proto
|
scheme = strings.TrimSpace(strings.Split(proto, ",")[0])
|
||||||
}
|
}
|
||||||
// 优先使用 X-Forwarded-Host(Nginx 暴露给客户端的真实域名),其次用 Host // Logan.Li
|
// 优先使用 X-Forwarded-Host(Nginx 暴露给客户端的真实域名),其次用 Host // Logan.Li
|
||||||
host := c.Request.Host
|
host := c.Request.Host
|
||||||
if fwd := c.GetHeader("X-Forwarded-Host"); fwd != "" {
|
if fwd := c.GetHeader("X-Forwarded-Host"); fwd != "" {
|
||||||
host = fwd
|
host = strings.TrimSpace(strings.Split(fwd, ",")[0])
|
||||||
}
|
}
|
||||||
// 去掉 scheme 的默认端口,避免生成 http://example.com:80 这类无效地址 // Logan.Li
|
return validateInstallServerURL(fmt.Sprintf("%s://%s", scheme, host))
|
||||||
if scheme == "http" && strings.HasSuffix(host, ":80") {
|
}
|
||||||
host = strings.TrimSuffix(host, ":80")
|
|
||||||
|
// 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") {
|
if parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
host = strings.TrimSuffix(host, ":443")
|
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
|
// detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li
|
||||||
@@ -187,11 +231,12 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
|||||||
id, _ := strconv.Atoi(c.Param("id"))
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
SSHKey string `json:"ssh_key"`
|
SSHKey string `json:"ssh_key"`
|
||||||
AuthType string `json:"auth_type"`
|
AuthType string `json:"auth_type"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
}
|
}
|
||||||
// 兼容旧调用方式:token 在 query,新方式在 body
|
// 兼容旧调用方式:token 在 query,新方式在 body
|
||||||
c.ShouldBindJSON(&req)
|
c.ShouldBindJSON(&req)
|
||||||
@@ -273,31 +318,24 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 构造安装命令(使用 Agent API 服务地址)
|
// 3. 构造安装命令(使用 Agent API 服务地址)
|
||||||
serverURL := installServerURL(c)
|
serverURL, urlErr := installServerURL(c, req.ServerURL)
|
||||||
token := req.Token
|
if urlErr != nil {
|
||||||
if token == "" {
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()})
|
||||||
token = c.Query("token")
|
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
|
// 4. 先下载脚本再执行,curl/WAF 失败时不会被管道末端 bash 的退出码掩盖。 // Logan.Li
|
||||||
installCmd := fmt.Sprintf(
|
installCmd := agentInstallCommand(serverURL, token, server.Hostname)
|
||||||
`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,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 5. 执行安装
|
// 5. 执行安装
|
||||||
installOut, err := runSSHCommand(client, installCmd)
|
installOut, err := runSSHCommand(client, installCmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := "安装命令执行失败"
|
msg := agentInstallErrorMessage(serverURL, installOut)
|
||||||
lowerOut := strings.ToLower(installOut)
|
|
||||||
if strings.Contains(lowerOut, "sudo: a password is required") || strings.Contains(lowerOut, "需要密码") {
|
|
||||||
msg = "目标主机 SSH 用户没有免密 sudo 权限,请使用 root 账号或配置 passwordless sudo"
|
|
||||||
} else if strings.Contains(lowerOut, "curl") && (strings.Contains(lowerOut, "not found") || strings.Contains(lowerOut, "未找到")) {
|
|
||||||
msg = "目标主机未安装 curl"
|
|
||||||
} else if strings.Contains(lowerOut, "下载失败") || strings.Contains(lowerOut, "failed to connect") || strings.Contains(lowerOut, "connection refused") {
|
|
||||||
msg = "目标主机无法下载安装脚本,请检查后端地址 " + serverURL + " 是否可达"
|
|
||||||
}
|
|
||||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0,
|
h.recordAgentAudit(ac, "install", resource, resourceID, 0,
|
||||||
fmt.Sprintf("server=%s arch=%s", serverURL, arch), installOut, msg)
|
fmt.Sprintf("server=%s arch=%s", serverURL, arch), installOut, msg)
|
||||||
c.JSON(http.StatusOK, model.Response{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
@@ -311,6 +349,13 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
|||||||
// 5. 验证安装是否成功
|
// 5. 验证安装是否成功
|
||||||
verifyOut, _ := runSSHCommand(client, "systemctl is-active aiops-agent 2>/dev/null || echo 'not_found'")
|
verifyOut, _ := runSSHCommand(client, "systemctl is-active aiops-agent 2>/dev/null || echo 'not_found'")
|
||||||
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
|
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
|
var agentVer string
|
||||||
if installed {
|
if installed {
|
||||||
@@ -339,7 +384,7 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.Response{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: func() string {
|
Message: func() string {
|
||||||
if installed {
|
if installed {
|
||||||
return "Agent 安装成功,已自动启动并设置开机自启"
|
return "Agent 安装成功,已自动启动并设置开机自启"
|
||||||
@@ -365,11 +410,7 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
|||||||
|
|
||||||
// 不强制 server.Status==running:只要 SSH 可达即可安装(DB 状态可能滞后于实际开机状态) // Logan.Li
|
// 不强制 server.Status==running:只要 SSH 可达即可安装(DB 状态可能滞后于实际开机状态) // Logan.Li
|
||||||
|
|
||||||
installCmd := fmt.Sprintf(
|
installCmd := agentInstallCommand(serverURL, token, server.Hostname)
|
||||||
`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,
|
|
||||||
)
|
|
||||||
|
|
||||||
installOut, err := runSSHCommand(client, installCmd)
|
installOut, err := runSSHCommand(client, installCmd)
|
||||||
if err != nil {
|
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'")
|
verifyOut, _ := runSSHCommand(client, "systemctl is-active aiops-agent 2>/dev/null || echo 'not_found'")
|
||||||
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
|
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
|
||||||
|
if !installed {
|
||||||
|
return false, installOut, verifyOut, fmt.Errorf("安装脚本已执行,但 Agent 服务未运行")
|
||||||
|
}
|
||||||
|
|
||||||
if installed {
|
if installed {
|
||||||
// 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本 // Logan.Li
|
// 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本 // Logan.Li
|
||||||
@@ -400,6 +444,7 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
|||||||
func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
ServerIDs []uint `json:"server_ids"`
|
ServerIDs []uint `json:"server_ids"`
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
Credentials map[string]struct {
|
Credentials map[string]struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -412,8 +457,11 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
serverURL := installServerURL(c)
|
serverURL, urlErr := installServerURL(c, req.ServerURL)
|
||||||
token := c.Query("token")
|
if urlErr != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: urlErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
|
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
|
||||||
|
|
||||||
// 异步执行:立即返回,后台 goroutine 逐台安装
|
// 异步执行:立即返回,后台 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)
|
installed, installOut, verifyOut, err := h.installOne(effectiveServer, serverURL, token)
|
||||||
res := server.Hostname + " (" + server.IP + ")"
|
res := server.Hostname + " (" + server.IP + ")"
|
||||||
rid := strconv.Itoa(int(server.ID))
|
rid := strconv.Itoa(int(server.ID))
|
||||||
reqDetail := fmt.Sprintf("server=%s host=%s", serverURL, res)
|
reqDetail := fmt.Sprintf("server=%s host=%s", serverURL, res)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[BatchInstall] %s (%s) 安装失败: %v", server.Hostname, server.IP, err)
|
errMessage := agentInstallErrorMessage(serverURL, installOut)
|
||||||
h.recordAgentAudit(ac, "install", res, rid, 0, reqDetail, installOut, truncateAuditText(err.Error(), 500))
|
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 {
|
} else {
|
||||||
successCount++
|
successCount++
|
||||||
respDetail := fmt.Sprintf("=== 安装输出 ===\n%s\n=== 服务状态 ===\n%s", installOut, verifyOut)
|
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) {
|
func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
ServerIDs []uint `json:"server_ids"`
|
ServerIDs []uint `json:"server_ids"`
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要升级的服务器 ID 列表"})
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要升级的服务器 ID 列表"})
|
||||||
return
|
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
|
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
|
||||||
|
|
||||||
// 异步执行:立即返回,后台逐台升级
|
// 异步执行:立即返回,后台逐台升级
|
||||||
@@ -634,8 +697,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
|||||||
|
|
||||||
// 下载新二进制 + 获取版本号 + 重启服务
|
// 下载新二进制 + 获取版本号 + 重启服务
|
||||||
upgradeScript := fmt.Sprintf(
|
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"`,
|
`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"`,
|
||||||
serverURL, binaryFile,
|
shellQuote(serverURL+"/api/agent/bin/"+binaryFile),
|
||||||
)
|
)
|
||||||
|
|
||||||
out, err := runSSHCommand(client, upgradeScript)
|
out, err := runSSHCommand(client, upgradeScript)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ezops/middleware"
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -25,7 +26,7 @@ func NewAgentTaskHandler(db *gorm.DB) *AgentTaskHandler {
|
|||||||
func (h *AgentTaskHandler) CreateTask(c *gin.Context) {
|
func (h *AgentTaskHandler) CreateTask(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
Type string `json:"type"` // command / script
|
Type string `json:"type"` // command / script
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Timeout int `json:"timeout"`
|
Timeout int `json:"timeout"`
|
||||||
}
|
}
|
||||||
@@ -47,12 +48,12 @@ func (h *AgentTaskHandler) CreateTask(c *gin.Context) {
|
|||||||
username, _ := c.Get("username")
|
username, _ := c.Get("username")
|
||||||
|
|
||||||
task := model.AgentTask{
|
task := model.AgentTask{
|
||||||
TaskID: uuid.New().String(),
|
TaskID: uuid.New().String(),
|
||||||
Hostname: req.Hostname,
|
Hostname: req.Hostname,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Command: req.Command,
|
Command: req.Command,
|
||||||
Timeout: req.Timeout,
|
Timeout: req.Timeout,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
CreatedBy: fmt.Sprintf("%v", username),
|
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 不能为空"})
|
c.JSON(400, model.Response{Code: 400, Message: "hostname 不能为空"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !middleware.RequireAgentHostname(c, hostname) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 查找该主机 pending 的任务,按时间排序,最多取 5 个
|
// 查找该主机 pending 的任务,按时间排序,最多取 5 个
|
||||||
var tasks []model.AgentTask
|
var tasks []model.AgentTask
|
||||||
@@ -94,6 +98,14 @@ func (h *AgentTaskHandler) PollTasks(c *gin.Context) {
|
|||||||
// POST /api/v1/agent/tasks/:task_id/result
|
// POST /api/v1/agent/tasks/:task_id/result
|
||||||
func (h *AgentTaskHandler) SubmitResult(c *gin.Context) {
|
func (h *AgentTaskHandler) SubmitResult(c *gin.Context) {
|
||||||
taskID := c.Param("task_id")
|
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 {
|
var req struct {
|
||||||
ExitCode int `json:"exit_code"`
|
ExitCode int `json:"exit_code"`
|
||||||
@@ -167,9 +179,9 @@ func (h *AgentTaskHandler) QuickSetupRules(c *gin.Context) {
|
|||||||
Pluck("hostname", &onlineHosts)
|
Pluck("hostname", &onlineHosts)
|
||||||
|
|
||||||
if len(onlineHosts) == 0 {
|
if len(onlineHosts) == 0 {
|
||||||
// 如果没有在线主机,尝试从 agent_reports 中获取最近 3 分钟活跃的主机名
|
// 如果没有在线主机,使用 UTC 边界从 Agent 报告中查找最近 3 分钟活跃主机。 // Logan.Li
|
||||||
h.db.Model(&model.AgentReport{}).
|
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").
|
Distinct("hostname").
|
||||||
Pluck("hostname", &onlineHosts)
|
Pluck("hostname", &onlineHosts)
|
||||||
}
|
}
|
||||||
@@ -180,11 +192,11 @@ func (h *AgentTaskHandler) QuickSetupRules(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ruleDef struct {
|
type ruleDef struct {
|
||||||
Name string
|
Name string
|
||||||
Metric string
|
Metric string
|
||||||
Operator string
|
Operator string
|
||||||
Threshold float64
|
Threshold float64
|
||||||
Duration int
|
Duration int
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultRules := []ruleDef{
|
defaultRules := []ruleDef{
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -399,9 +398,7 @@ func (h *AIOpsAlertAnalysisHandler) saveAnalysisTrace(ctx context.Context, in an
|
|||||||
Duration: in.Duration,
|
Duration: in.Duration,
|
||||||
Error: in.Error,
|
Error: in.Error,
|
||||||
}
|
}
|
||||||
if err := h.db.WithContext(ctx).Create(&row).Error; err != nil {
|
_ = h.db.WithContext(ctx).Create(&row).Error
|
||||||
log.Printf("[AIOpsAnalysis] 保存告警分析结果失败 (alert_id=%d, provider=%s): %v", in.AlertID, in.Provider, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func marshalTraceJSON(v interface{}) string {
|
func marshalTraceJSON(v interface{}) string {
|
||||||
|
|||||||
@@ -473,5 +473,3 @@ func (h *AlertHandler) NotifyRecovery(ruleName, hostname, metric, message string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -60,11 +60,21 @@ func (h *AlertHandler) UpdateContact(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
if req.Name != "" { updates["name"] = req.Name }
|
if req.Name != "" {
|
||||||
if req.Phone != "" { updates["phone"] = req.Phone }
|
updates["name"] = req.Name
|
||||||
if req.Email != "" { updates["email"] = req.Email }
|
}
|
||||||
if req.ChannelType != "" { updates["channel_type"] = req.ChannelType }
|
if req.Phone != "" {
|
||||||
if req.WebhookURL != "" { updates["webhook_url"] = req.WebhookURL }
|
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.Model(&contact).Updates(updates)
|
||||||
h.db.First(&contact, id)
|
h.db.First(&contact, id)
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "更新成功", Data: contact})
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "更新成功", Data: contact})
|
||||||
@@ -127,8 +137,12 @@ func (h *AlertHandler) UpdateContactGroup(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
if req.GroupName != "" { updates["group_name"] = req.GroupName }
|
if req.GroupName != "" {
|
||||||
if req.Notes != "" { updates["notes"] = req.Notes }
|
updates["group_name"] = req.GroupName
|
||||||
|
}
|
||||||
|
if req.Notes != "" {
|
||||||
|
updates["notes"] = req.Notes
|
||||||
|
}
|
||||||
if len(req.ContactIDs) > 0 {
|
if len(req.ContactIDs) > 0 {
|
||||||
idsJSON, _ := json.Marshal(req.ContactIDs)
|
idsJSON, _ := json.Marshal(req.ContactIDs)
|
||||||
updates["contact_ids"] = string(idsJSON)
|
updates["contact_ids"] = string(idsJSON)
|
||||||
|
|||||||
@@ -227,7 +227,8 @@ func (e *AlertEngine) checkOfflineAlerts() {
|
|||||||
e.db.Select("ip, hostname").Group("ip").Find(&servers)
|
e.db.Select("ip, hostname").Group("ip").Find(&servers)
|
||||||
|
|
||||||
// 收集所有 agent_reports 中活跃上报的主机名(最近 3 分钟内有上报)
|
// 收集所有 agent_reports 中活跃上报的主机名(最近 3 分钟内有上报)
|
||||||
threeMinAgo := time.Now().Add(-offlineThreshold)
|
// Agent 报告统一使用 UTC,避免 SQLite 时区文本比较导致误报离线。 // Logan.Li
|
||||||
|
threeMinAgo := time.Now().UTC().Add(-offlineThreshold)
|
||||||
|
|
||||||
var activeHostnames []string
|
var activeHostnames []string
|
||||||
e.db.Model(&model.AgentReport{}).
|
e.db.Model(&model.AgentReport{}).
|
||||||
|
|||||||
@@ -79,16 +79,16 @@ func notifyTemplateVars(ruleName, hostname, metric, severity, message string, va
|
|||||||
platform = defaultNotifyPlatformName
|
platform = defaultNotifyPlatformName
|
||||||
}
|
}
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"time": time.Now().Format("2006-01-02 15:04:05"),
|
"time": time.Now().Format("2006-01-02 15:04:05"),
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
"rule_name": ruleName,
|
"rule_name": ruleName,
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
"metric": metric,
|
"metric": metric,
|
||||||
"severity": severity,
|
"severity": severity,
|
||||||
"severity_label": sevLabel,
|
"severity_label": sevLabel,
|
||||||
"message": message,
|
"message": message,
|
||||||
"value": fmt.Sprintf("%.2f", value),
|
"value": fmt.Sprintf("%.2f", value),
|
||||||
"threshold": fmt.Sprintf("%.2f", threshold),
|
"threshold": fmt.Sprintf("%.2f", threshold),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,30 +15,30 @@ import (
|
|||||||
|
|
||||||
// CreateSilenceRequest 创建静默规则请求
|
// CreateSilenceRequest 创建静默规则请求
|
||||||
type CreateSilenceRequest struct {
|
type CreateSilenceRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
RuleIDs []uint `json:"rule_ids"`
|
RuleIDs []uint `json:"rule_ids"`
|
||||||
TargetIDs []uint `json:"target_ids"`
|
TargetIDs []uint `json:"target_ids"`
|
||||||
Matchers []model.AlertSilenceMatcher `json:"matchers"`
|
Matchers []model.AlertSilenceMatcher `json:"matchers"`
|
||||||
SilenceType string `json:"silence_type"`
|
SilenceType string `json:"silence_type"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
EndTime string `json:"end_time" binding:"required"`
|
EndTime string `json:"end_time" binding:"required"`
|
||||||
Weekdays []int `json:"weekdays"`
|
Weekdays []int `json:"weekdays"`
|
||||||
Status *int `json:"status"`
|
Status *int `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSilenceRequest 更新静默规则请求
|
// UpdateSilenceRequest 更新静默规则请求
|
||||||
type UpdateSilenceRequest struct {
|
type UpdateSilenceRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
RuleIDs []uint `json:"rule_ids"`
|
RuleIDs []uint `json:"rule_ids"`
|
||||||
TargetIDs []uint `json:"target_ids"`
|
TargetIDs []uint `json:"target_ids"`
|
||||||
Matchers []model.AlertSilenceMatcher `json:"matchers"`
|
Matchers []model.AlertSilenceMatcher `json:"matchers"`
|
||||||
SilenceType string `json:"silence_type"`
|
SilenceType string `json:"silence_type"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
EndTime string `json:"end_time"`
|
EndTime string `json:"end_time"`
|
||||||
Weekdays []int `json:"weekdays"`
|
Weekdays []int `json:"weekdays"`
|
||||||
Status *int `json:"status"`
|
Status *int `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSilences GET /alerts/silences
|
// ListSilences GET /alerts/silences
|
||||||
@@ -60,11 +60,11 @@ func (h *AlertHandler) ListSilences(c *gin.Context) {
|
|||||||
// 解析 JSON 字段为前端友好的格式
|
// 解析 JSON 字段为前端友好的格式
|
||||||
type SilenceWithParsed struct {
|
type SilenceWithParsed struct {
|
||||||
model.AlertSilence
|
model.AlertSilence
|
||||||
RuleIDList []uint `json:"rule_id_list"`
|
RuleIDList []uint `json:"rule_id_list"`
|
||||||
TargetIDList []uint `json:"target_id_list"`
|
TargetIDList []uint `json:"target_id_list"`
|
||||||
MatcherList []model.AlertSilenceMatcher `json:"matcher_list"`
|
MatcherList []model.AlertSilenceMatcher `json:"matcher_list"`
|
||||||
WeekdayList []int `json:"weekday_list"`
|
WeekdayList []int `json:"weekday_list"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []SilenceWithParsed
|
var result []SilenceWithParsed
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ import (
|
|||||||
|
|
||||||
// UnifiedAlertItem 统一告警事件视图 // Logan.Li
|
// UnifiedAlertItem 统一告警事件视图 // Logan.Li
|
||||||
type UnifiedAlertItem struct {
|
type UnifiedAlertItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
RuleName string `json:"rule_name"`
|
RuleName string `json:"rule_name"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Severity string `json:"severity"`
|
Severity string `json:"severity"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
Metric string `json:"metric"`
|
Metric string `json:"metric"`
|
||||||
Value float64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
Threshold float64 `json:"threshold"`
|
Threshold float64 `json:"threshold"`
|
||||||
StartedAt string `json:"started_at"`
|
StartedAt string `json:"started_at"`
|
||||||
ResolvedAt *string `json:"resolved_at,omitempty"`
|
ResolvedAt *string `json:"resolved_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUnifiedAlerts 统一告警列表(主机 + 中间件 + 站点 + 日志)
|
// ListUnifiedAlerts 统一告警列表(主机 + 中间件 + 站点 + 日志)
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ func (h *ApprovalHandler) CreateProcess(c *gin.Context) {
|
|||||||
var workflowSteps []model.ApprovalWorkflowStep
|
var workflowSteps []model.ApprovalWorkflowStep
|
||||||
stepCount := 1
|
stepCount := 1
|
||||||
var approvalSteps []struct {
|
var approvalSteps []struct {
|
||||||
name string
|
name string
|
||||||
stepType string
|
stepType string
|
||||||
approverIDs []uint
|
approverIDs []uint
|
||||||
}
|
}
|
||||||
if workflow.ID > 0 {
|
if workflow.ID > 0 {
|
||||||
@@ -126,8 +126,8 @@ func (h *ApprovalHandler) CreateProcess(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
stepCount++
|
stepCount++
|
||||||
approvalSteps = append(approvalSteps, struct {
|
approvalSteps = append(approvalSteps, struct {
|
||||||
name string
|
name string
|
||||||
stepType string
|
stepType string
|
||||||
approverIDs []uint
|
approverIDs []uint
|
||||||
}{ws.Name, ws.StepType, ParseApproverIDs(ws.ApproverIDs)})
|
}{ws.Name, ws.StepType, ParseApproverIDs(ws.ApproverIDs)})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,14 +111,14 @@ func (h *WorkflowHandler) CreateWorkflow(c *gin.Context) {
|
|||||||
|
|
||||||
for _, s := range req.Steps {
|
for _, s := range req.Steps {
|
||||||
step := model.ApprovalWorkflowStep{
|
step := model.ApprovalWorkflowStep{
|
||||||
WorkflowID: workflow.ID,
|
WorkflowID: workflow.ID,
|
||||||
StepNumber: s.StepNumber,
|
StepNumber: s.StepNumber,
|
||||||
Name: s.Name,
|
Name: s.Name,
|
||||||
StepType: s.StepType,
|
StepType: s.StepType,
|
||||||
ApproverIDs: s.ApproverIDs,
|
ApproverIDs: s.ApproverIDs,
|
||||||
IsRequired: s.IsRequired,
|
IsRequired: s.IsRequired,
|
||||||
TimeoutHours: s.TimeoutHours,
|
TimeoutHours: s.TimeoutHours,
|
||||||
AutoApprove: s.AutoApproveTimeout,
|
AutoApprove: s.AutoApproveTimeout,
|
||||||
}
|
}
|
||||||
if step.TimeoutHours == 0 {
|
if step.TimeoutHours == 0 {
|
||||||
step.TimeoutHours = 24
|
step.TimeoutHours = 24
|
||||||
@@ -195,14 +195,14 @@ func (h *WorkflowHandler) UpdateWorkflow(c *gin.Context) {
|
|||||||
tx.Where("workflow_id = ?", workflow.ID).Delete(&model.ApprovalWorkflowStep{})
|
tx.Where("workflow_id = ?", workflow.ID).Delete(&model.ApprovalWorkflowStep{})
|
||||||
for _, s := range req.Steps {
|
for _, s := range req.Steps {
|
||||||
step := model.ApprovalWorkflowStep{
|
step := model.ApprovalWorkflowStep{
|
||||||
WorkflowID: workflow.ID,
|
WorkflowID: workflow.ID,
|
||||||
StepNumber: s.StepNumber,
|
StepNumber: s.StepNumber,
|
||||||
Name: s.Name,
|
Name: s.Name,
|
||||||
StepType: s.StepType,
|
StepType: s.StepType,
|
||||||
ApproverIDs: s.ApproverIDs,
|
ApproverIDs: s.ApproverIDs,
|
||||||
IsRequired: s.IsRequired,
|
IsRequired: s.IsRequired,
|
||||||
TimeoutHours: s.TimeoutHours,
|
TimeoutHours: s.TimeoutHours,
|
||||||
AutoApprove: s.AutoApproveTimeout,
|
AutoApprove: s.AutoApproveTimeout,
|
||||||
}
|
}
|
||||||
if step.TimeoutHours == 0 {
|
if step.TimeoutHours == 0 {
|
||||||
step.TimeoutHours = 24
|
step.TimeoutHours = 24
|
||||||
|
|||||||
+192
-31
@@ -4,8 +4,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ezops/config"
|
||||||
|
"ezops/middleware"
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -14,11 +18,126 @@ import (
|
|||||||
|
|
||||||
// BillingHandler 云账单处理器
|
// BillingHandler 云账单处理器
|
||||||
type BillingHandler struct {
|
type BillingHandler struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
billing *config.BillingConfig
|
||||||
|
rateMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBillingHandler(db *gorm.DB) *BillingHandler {
|
func NewBillingHandler(db *gorm.DB, configs ...*config.BillingConfig) *BillingHandler {
|
||||||
return &BillingHandler{db: db}
|
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 获取账单列表
|
// ListBillings 获取账单列表
|
||||||
@@ -37,9 +156,6 @@ func (h *BillingHandler) ListBillings(c *gin.Context) {
|
|||||||
if accountID := c.Query("cloud_account_id"); accountID != "" {
|
if accountID := c.Query("cloud_account_id"); accountID != "" {
|
||||||
query = query.Where("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 != "" {
|
if startDate := c.Query("start_date"); startDate != "" {
|
||||||
query = query.Where("billing_date >= ?", startDate)
|
query = query.Where("billing_date >= ?", startDate)
|
||||||
}
|
}
|
||||||
@@ -102,6 +218,7 @@ func (h *BillingHandler) CreateBilling(c *gin.Context) {
|
|||||||
if billing.Currency == "" {
|
if billing.Currency == "" {
|
||||||
billing.Currency = "CNY"
|
billing.Currency = "CNY"
|
||||||
}
|
}
|
||||||
|
billing.Currency = strings.ToUpper(billing.Currency)
|
||||||
|
|
||||||
if err := h.db.Create(&billing).Error; err != nil {
|
if err := h.db.Create(&billing).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败: " + err.Error()})
|
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 == "" {
|
if billing.Currency == "" {
|
||||||
billing.Currency = "CNY"
|
billing.Currency = "CNY"
|
||||||
}
|
}
|
||||||
|
billing.Currency = strings.ToUpper(billing.Currency)
|
||||||
if err := h.db.Create(&billing).Error; err == nil {
|
if err := h.db.Create(&billing).Error; err == nil {
|
||||||
created++
|
created++
|
||||||
}
|
}
|
||||||
@@ -166,50 +284,62 @@ func (h *BillingHandler) DeleteBilling(c *gin.Context) {
|
|||||||
// GetBillingStats 获取账单统计
|
// GetBillingStats 获取账单统计
|
||||||
func (h *BillingHandler) GetBillingStats(c *gin.Context) {
|
func (h *BillingHandler) GetBillingStats(c *gin.Context) {
|
||||||
now := time.Now()
|
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")
|
today := now.Format("2006-01-02")
|
||||||
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
monthStart := now.Format("2006-01") + "-01"
|
monthStart := now.Format("2006-01") + "-01"
|
||||||
yearStart := fmt.Sprintf("%d-01-01", now.Year())
|
yearStart := fmt.Sprintf("%d-01-01", now.Year())
|
||||||
currency := c.DefaultQuery("currency", "CNY")
|
|
||||||
|
|
||||||
stats := model.BillingStats{
|
stats := model.BillingStats{
|
||||||
Currency: currency,
|
MonthTrend: []model.DailyCost{},
|
||||||
MonthTrend: []model.DailyCost{},
|
ProviderDist: []model.ProviderCost{},
|
||||||
ProviderDist: []model.ProviderCost{},
|
ProviderTrend: []model.ProviderDailyCost{},
|
||||||
Alerts: []model.BillingAlert{},
|
TopServices: []model.ServiceCost{},
|
||||||
|
Alerts: []model.BillingAlert{},
|
||||||
|
Currency: reportCurrency,
|
||||||
|
ExchangeRates: h.reportCurrencyMetadata(reportCurrency)["exchange_rates"].(map[string]float64),
|
||||||
|
RateSource: "configured",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 今日成本
|
// 所有统计统一按报告币种折算,避免不同原币种直接相加。 // Logan.Li
|
||||||
h.db.Model(&model.CloudBilling{}).Where("billing_date = ? AND currency = ?", today, currency).
|
h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", today).
|
||||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.TodayCost)
|
Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.TodayCost)
|
||||||
|
|
||||||
// 昨日成本
|
// 昨日成本
|
||||||
h.db.Model(&model.CloudBilling{}).Where("billing_date = ? AND currency = ?", yesterday, currency).
|
h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", yesterday).
|
||||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.YesterdayCost)
|
Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.YesterdayCost)
|
||||||
|
|
||||||
// 本月成本
|
// 本月成本
|
||||||
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", monthStart, currency).
|
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", monthStart).
|
||||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.MonthCost)
|
Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.MonthCost)
|
||||||
|
|
||||||
// 本年成本
|
// 本年成本
|
||||||
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", yearStart, currency).
|
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", yearStart).
|
||||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.YearCost)
|
Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.YearCost)
|
||||||
|
|
||||||
// 近30天趋势
|
// 本月日趋势与厂商日趋势来自同一聚合口径,供成本预测直接复用。 // Logan.Li
|
||||||
type dayCost struct {
|
type dayCost struct {
|
||||||
BillingDate string
|
BillingDate string
|
||||||
Total float64
|
Total float64
|
||||||
}
|
}
|
||||||
var trends []dayCost
|
var trends []dayCost
|
||||||
h.db.Model(&model.CloudBilling{}).
|
h.db.Model(&model.CloudBilling{}).
|
||||||
Select("billing_date, SUM(cost) as total").
|
Select("billing_date, SUM("+costExpression+") as total", costArgs...).
|
||||||
Where("billing_date >= ? AND currency = ?", now.AddDate(0, 0, -29).Format("2006-01-02"), currency).
|
Where("billing_date >= ?", monthStart).
|
||||||
Group("billing_date").
|
Group("billing_date").
|
||||||
Order("billing_date ASC").
|
Order("billing_date ASC").
|
||||||
Scan(&trends)
|
Scan(&trends)
|
||||||
|
|
||||||
for _, t := range trends {
|
for _, t := range trends {
|
||||||
stats.MonthTrend = append(stats.MonthTrend, model.DailyCost{Date: t.BillingDate, Cost: t.Total})
|
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
|
var provCosts []provCost
|
||||||
h.db.Model(&model.CloudBilling{}).
|
h.db.Model(&model.CloudBilling{}).
|
||||||
Select("provider, SUM(cost) as total").
|
Select("provider, SUM("+costExpression+") as total", costArgs...).
|
||||||
Where("billing_date >= ? AND currency = ?", monthStart, currency).
|
Where("billing_date >= ?", monthStart).
|
||||||
Group("provider").
|
Group("provider").
|
||||||
Scan(&provCosts)
|
Scan(&provCosts)
|
||||||
|
|
||||||
@@ -243,8 +373,39 @@ func (h *BillingHandler) GetBillingStats(c *gin.Context) {
|
|||||||
return stats.ProviderDist[i].Cost > stats.ProviderDist[j].Cost
|
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{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
@@ -254,7 +415,7 @@ func (h *BillingHandler) GetBillingStats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkBillingAlerts 检查预警规则是否触发
|
// 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
|
var rules []model.BillingAlertRule
|
||||||
h.db.Where("enabled = ?", true).Find(&rules)
|
h.db.Where("enabled = ?", true).Find(&rules)
|
||||||
|
|
||||||
@@ -263,24 +424,24 @@ func (h *BillingHandler) checkBillingAlerts(stats *model.BillingStats, now time.
|
|||||||
|
|
||||||
if rule.Metric == "daily" {
|
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 != "" {
|
if rule.Provider != "" {
|
||||||
q = q.Where("provider = ?", rule.Provider)
|
q = q.Where("provider = ?", rule.Provider)
|
||||||
}
|
}
|
||||||
if rule.CloudAccountID != nil {
|
if rule.CloudAccountID != nil {
|
||||||
q = q.Where("cloud_account_id = ?", *rule.CloudAccountID)
|
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 {
|
} else {
|
||||||
// monthly - 本月累计
|
// 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 != "" {
|
if rule.Provider != "" {
|
||||||
q = q.Where("provider = ?", rule.Provider)
|
q = q.Where("provider = ?", rule.Provider)
|
||||||
}
|
}
|
||||||
if rule.CloudAccountID != nil {
|
if rule.CloudAccountID != nil {
|
||||||
q = q.Where("cloud_account_id = ?", *rule.CloudAccountID)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查阈值
|
// 检查阈值
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
@@ -12,133 +12,208 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// billingMonthCost 表示月度费用汇总。 // Logan.Li
|
// billingAnalysisItem 表示单月一个费用分类的汇总值。 // Logan.Li
|
||||||
type billingMonthCost struct {
|
type billingAnalysisItem struct {
|
||||||
Month string `json:"month"`
|
Name string `json:"name"`
|
||||||
Cost float64 `json:"cost"`
|
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 {
|
type billingAttribution struct {
|
||||||
Dimension string `json:"dimension"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BaselineCost float64 `json:"baseline_cost"`
|
PreviousCost float64 `json:"previous_cost"`
|
||||||
CurrentCost float64 `json:"current_cost"`
|
CurrentCost float64 `json:"current_cost"`
|
||||||
Delta float64 `json:"delta"`
|
Delta float64 `json:"delta"`
|
||||||
Contribution float64 `json:"contribution"`
|
Contribution float64 `json:"contribution"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBillingCostAnalysis 返回月度趋势和同期费用归因,AI 只负责解释确定性结果。 // Logan.Li
|
// GetBillingCostAnalysis 返回多云费用堆叠趋势及同期归因。 // Logan.Li
|
||||||
func (h *BillingHandler) GetBillingCostAnalysis(c *gin.Context) {
|
func (h *BillingHandler) GetBillingCostAnalysis(c *gin.Context) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
currentStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
endMonth := c.DefaultQuery("end_month", now.Format("2006-01"))
|
||||||
baselineStart := currentStart.AddDate(0, -1, 0)
|
startMonth := c.DefaultQuery("start_month", now.AddDate(0, -5, 0).Format("2006-01"))
|
||||||
baselineEndDay := now.Day()
|
currency := strings.ToUpper(c.DefaultQuery("currency", h.billing.BaseCurrency))
|
||||||
baselineLastDay := baselineStart.AddDate(0, 1, -1).Day()
|
costExpression, costArgs, currencyOK := h.currencyExpression(currency)
|
||||||
if baselineEndDay > baselineLastDay {
|
if !currencyOK {
|
||||||
baselineEndDay = baselineLastDay
|
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 aggregateRow struct {
|
||||||
type monthRow struct {
|
|
||||||
Month string
|
Month string
|
||||||
|
Name string
|
||||||
Cost float64
|
Cost float64
|
||||||
}
|
}
|
||||||
var monthRows []monthRow
|
var rows []aggregateRow
|
||||||
query.Select("SUBSTR(billing_date, 1, 7) AS month, COALESCE(SUM(cost), 0) AS cost").
|
queryEnd := end.AddDate(0, 1, 0)
|
||||||
Group("SUBSTR(billing_date, 1, 7)").Order("month ASC").Scan(&monthRows)
|
query := h.db.Model(&model.CloudBilling{}).
|
||||||
months := make([]billingMonthCost, 0, len(monthRows))
|
Select("SUBSTR(billing_date, 1, 7) AS month, "+column+" AS name, COALESCE(SUM("+costExpression+"), 0) AS cost", costArgs...).
|
||||||
for _, row := range monthRows {
|
Where("billing_date >= ? AND billing_date < ?", start.Format("2006-01-02"), queryEnd.Format("2006-01-02"))
|
||||||
months = append(months, billingMonthCost{Month: row.Month, Cost: roundBilling(row.Cost)})
|
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")
|
monthCosts := map[string]map[string]float64{}
|
||||||
baselineEndDate := baselineEnd.Format("2006-01-02")
|
categoryTotals := map[string]float64{}
|
||||||
currentStartDate := currentStart.Format("2006-01-02")
|
for _, row := range rows {
|
||||||
currentEndDate := now.Format("2006-01-02")
|
name := row.Name
|
||||||
baselineCost := h.sumBillingPeriod(baselineStartDate, baselineEndDate, currency)
|
if name == "" {
|
||||||
currentCost := h.sumBillingPeriod(currentStartDate, currentEndDate, currency)
|
name = "未分类"
|
||||||
totalDelta := currentCost - baselineCost
|
}
|
||||||
|
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)
|
totalCost := 0.0
|
||||||
for _, dimension := range []string{"provider", "account_name", "service", "region"} {
|
peakCost := 0.0
|
||||||
attributions = append(attributions, h.billingDimensionAttribution(
|
peakMonth := ""
|
||||||
dimension, baselineStartDate, baselineEndDate, currentStartDate, currentEndDate, currency, totalDelta,
|
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 {
|
averageCost := 0.0
|
||||||
return math.Abs(attributions[i].Delta) > math.Abs(attributions[j].Delta)
|
if len(months) > 0 {
|
||||||
})
|
averageCost = totalCost / float64(len(months))
|
||||||
if len(attributions) > 20 {
|
|
||||||
attributions = attributions[:20]
|
|
||||||
}
|
}
|
||||||
|
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
|
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{
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: gin.H{
|
||||||
"analysis_start": analysisStart.Format("2006-01-02"),
|
"start_month": startMonth, "end_month": endMonth, "currency": currency, "dimension": dimension,
|
||||||
"analysis_end": currentEndDate,
|
"exchange_rates": metadata["exchange_rates"], "rate_source": metadata["rate_source"],
|
||||||
"data_through": dataThrough,
|
"data_through": dataThrough, "incomplete_period": incompletePeriod, "categories": categories, "months": months,
|
||||||
"currency": currency,
|
"summary": gin.H{"total_cost": roundBilling(totalCost), "average_cost": roundBilling(averageCost), "peak_cost": roundBilling(peakCost), "peak_month": peakMonth, "mom_percent": roundBilling(momPercent)},
|
||||||
"incomplete_period": true,
|
"attributions": attributions,
|
||||||
"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": "(当前期费用 - 基准期费用) / (当前期总费用 - 基准期总费用)",
|
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
|
||||||
// sumBillingPeriod 汇总指定币种和日期范围费用。 // Logan.Li
|
// topBillingCategories 按总费用选择 Top N 分类,其余合并为“其他”。 // Logan.Li
|
||||||
func (h *BillingHandler) sumBillingPeriod(startDate, endDate, currency string) float64 {
|
func topBillingCategories(totals map[string]float64, limit int) []string {
|
||||||
var total float64
|
names := make([]string, 0, len(totals))
|
||||||
h.db.Model(&model.CloudBilling{}).
|
for name := range totals {
|
||||||
Where("billing_date BETWEEN ? AND ? AND currency = ?", startDate, endDate, currency).
|
names = append(names, name)
|
||||||
Select("COALESCE(SUM(cost), 0)").Scan(&total)
|
}
|
||||||
return total
|
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
|
// buildBillingAnalysisMonths 补齐空月份并将非 Top 分类合并。 // Logan.Li
|
||||||
func (h *BillingHandler) billingDimensionAttribution(dimension, baselineStart, baselineEnd, currentStart, currentEnd, currency string, totalDelta float64) []billingAttribution {
|
func buildBillingAnalysisMonths(start, end time.Time, costs map[string]map[string]float64, categories []string) []billingAnalysisMonth {
|
||||||
type dimensionRow struct {
|
top := map[string]bool{}
|
||||||
Name string
|
for _, name := range categories {
|
||||||
Cost float64
|
if name != "其他" {
|
||||||
}
|
top[name] = true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result := make([]billingAttribution, 0, len(current))
|
months := make([]billingAnalysisMonth, 0)
|
||||||
for rawName, currentCost := range current {
|
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
|
name := rawName
|
||||||
if rawName == "" {
|
if name == "" {
|
||||||
name = "未分类"
|
name = "未分类"
|
||||||
}
|
}
|
||||||
baselineCost := baseline[rawName]
|
delta := current[rawName] - previous[rawName]
|
||||||
delta := currentCost - baselineCost
|
|
||||||
if math.Abs(delta) < 0.01 {
|
if math.Abs(delta) < 0.01 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -146,12 +221,13 @@ func (h *BillingHandler) billingDimensionAttribution(dimension, baselineStart, b
|
|||||||
if math.Abs(totalDelta) >= 0.01 {
|
if math.Abs(totalDelta) >= 0.01 {
|
||||||
contribution = delta / totalDelta * 100
|
contribution = delta / totalDelta * 100
|
||||||
}
|
}
|
||||||
result = append(result, billingAttribution{
|
items = append(items, billingAttribution{Name: name, PreviousCost: roundBilling(previous[rawName]), CurrentCost: roundBilling(current[rawName]), Delta: roundBilling(delta), Contribution: roundBilling(contribution)})
|
||||||
Dimension: dimension, Name: name, BaselineCost: roundBilling(baselineCost),
|
|
||||||
CurrentCost: roundBilling(currentCost), 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
|
// roundBilling 统一费用和百分比精度。 // Logan.Li
|
||||||
|
|||||||
@@ -1,45 +1,102 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ezops/config"
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestBillingDimensionAttribution 验证费用变化额和贡献度使用数据库完整数据计算。 // Logan.Li
|
// TestCurrencyExpression 验证 CNY 与 USD 报告币种使用同一组可逆汇率。 // Logan.Li
|
||||||
func TestBillingDimensionAttribution(t *testing.T) {
|
func TestCurrencyExpression(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
handler := NewBillingHandler(nil, &config.BillingConfig{
|
||||||
if err != nil {
|
BaseCurrency: "CNY",
|
||||||
t.Fatalf("打开测试数据库失败: %v", err)
|
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 {
|
metadata := handler.reportCurrencyMetadata("USD")
|
||||||
t.Fatalf("迁移账单表失败: %v", err)
|
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{
|
records := []model.CloudBilling{
|
||||||
{BillingDate: "2026-06-01", Provider: "tencent", Service: "CVM", Region: "广州", AccountName: "预发", Currency: "CNY", Cost: 100},
|
{Provider: "tencent", BillingDate: today, Cost: 100, Currency: "CNY", Service: "CVM"},
|
||||||
{BillingDate: "2026-07-01", Provider: "tencent", Service: "CVM", Region: "广州", AccountName: "预发", Currency: "CNY", Cost: 180},
|
{Provider: "aws", BillingDate: today, Cost: 10, Currency: "USD", Service: "EC2"},
|
||||||
{BillingDate: "2026-07-01", Provider: "tencent", Service: "COS", Region: "广州", AccountName: "预发", Currency: "CNY", Cost: 20},
|
|
||||||
}
|
}
|
||||||
if err = db.Create(&records).Error; err != nil {
|
if err := db.Create(&records).Error; err != nil {
|
||||||
t.Fatalf("写入测试账单失败: %v", err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
handler := NewBillingHandler(db, &config.BillingConfig{BaseCurrency: "CNY", ExchangeRates: map[string]float64{"CNY": 1, "USD": 7.2}})
|
||||||
|
|
||||||
handler := NewBillingHandler(db)
|
assertMonthCost := func(currency string, expected float64) {
|
||||||
items := handler.billingDimensionAttribution("service", "2026-06-01", "2026-06-30", "2026-07-01", "2026-07-31", "CNY", 100)
|
t.Helper()
|
||||||
if len(items) != 2 {
|
recorder := httptest.NewRecorder()
|
||||||
t.Fatalf("归因项数量错误: got=%d want=2", len(items))
|
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{}
|
assertMonthCost("CNY", 172)
|
||||||
for _, item := range items {
|
assertMonthCost("USD", 23.89)
|
||||||
result[item.Name] = item
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if months[1].Month != "2026-04" || months[1].Total != 0 {
|
||||||
t.Fatalf("CVM 归因错误: %+v", result["CVM"])
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ func (h *PipelineHandler) ListBuildNodes(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, model.Response{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"list": nodes,
|
"list": nodes,
|
||||||
"total": len(nodes),
|
"total": len(nodes),
|
||||||
"master": map[string]interface{}{
|
"master": map[string]interface{}{
|
||||||
"name": "Master",
|
"name": "Master",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -81,10 +80,6 @@ func (h *CloudAccountHandler) CreateCloudAccount(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
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)
|
status := strings.TrimSpace(req.Status)
|
||||||
if status == "" {
|
if status == "" {
|
||||||
@@ -163,10 +158,6 @@ func (h *CloudAccountHandler) UpdateCloudAccount(c *gin.Context) {
|
|||||||
account.Status = strings.TrimSpace(req.Status)
|
account.Status = strings.TrimSpace(req.Status)
|
||||||
}
|
}
|
||||||
account.MonthlyCost = req.MonthlyCost
|
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 {
|
if err := h.db.Save(&account).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, model.Response{
|
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 删除云账号
|
// DeleteCloudAccount 删除云账号
|
||||||
// @Summary 删除云账号
|
// @Summary 删除云账号
|
||||||
// @Description 删除指定云账号
|
// @Description 删除指定云账号
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
|
"ezops/pkg/siteprobe"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"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,
|
||||||
|
}})
|
||||||
|
}
|
||||||
@@ -129,4 +129,3 @@ func (h *PlatformHandler) DeletePlatform(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"})
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,7 +3,6 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ezops/cloud"
|
"ezops/cloud"
|
||||||
@@ -74,60 +73,22 @@ func (h *CloudSyncHandler) SyncRegions(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 并发统计所有可用区域实例数,卡片展示云账号真实资源量。 // Logan.Li
|
// 更新账号的 region_count 和 last_sync_at
|
||||||
serverCount, countedRegions := countCloudInstances(provider, cloudRegions)
|
h.db.Model(&account).Updates(map[string]interface{}{
|
||||||
updates := map[string]interface{}{
|
|
||||||
"region_count": len(cloudRegions),
|
"region_count": len(cloudRegions),
|
||||||
"last_sync_at": time.Now(),
|
"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{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "同步成功",
|
Message: "同步成功",
|
||||||
Data: gin.H{
|
Data: gin.H{
|
||||||
"synced": synced,
|
"synced": synced,
|
||||||
"total": len(cloudRegions),
|
"total": len(cloudRegions),
|
||||||
"server_count": serverCount,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 过滤)
|
// GetAccountRegions 获取指定云账号的区域列表(按 provider 过滤)
|
||||||
// GET /cloud-accounts/:id/regions
|
// GET /cloud-accounts/:id/regions
|
||||||
func (h *CloudSyncHandler) GetAccountRegions(c *gin.Context) {
|
func (h *CloudSyncHandler) GetAccountRegions(c *gin.Context) {
|
||||||
@@ -266,12 +227,28 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
|||||||
ip = inst.InstanceID
|
ip = inst.InstanceID
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重复资产补齐来源账号关联,保证服务器数统计准确。 // Logan.Li
|
hostname := cloudImportHostname(inst)
|
||||||
|
alias := cloudImportAlias(inst)
|
||||||
|
|
||||||
|
// 检查是否已存在(优先实例 ID,其次 IP/hostname),重复导入只补空别名,避免覆盖人工命名。 // Logan.Li
|
||||||
var existing model.Server
|
var existing model.Server
|
||||||
if err := h.db.Where("ip = ? OR hostname = ?", ip, inst.Hostname).Take(&existing).Error; err == nil {
|
existingQuery := h.db.Where("ip = ? OR hostname = ?", ip, hostname)
|
||||||
if existing.CloudAccountID == nil {
|
if inst.InstanceID != "" {
|
||||||
h.db.Model(&existing).Update("cloud_account_id", account.ID)
|
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++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -305,34 +282,28 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
|||||||
if ipAddr == "" {
|
if ipAddr == "" {
|
||||||
ipAddr = inst.InstanceID
|
ipAddr = inst.InstanceID
|
||||||
}
|
}
|
||||||
hostname := inst.Hostname
|
|
||||||
if hostname == "" {
|
|
||||||
hostname = inst.InstanceName
|
|
||||||
}
|
|
||||||
if hostname == "" {
|
|
||||||
hostname = inst.InstanceID
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注意:servers 表的 cloud_id 外键引用的是 clouds 表(非 cloud_accounts),
|
// 注意:servers 表的 cloud_id 外键引用的是 clouds 表(非 cloud_accounts),
|
||||||
// region_id 外键引用 regions 表。未找到关联时设为 NULL 避免外键约束失败。
|
// region_id 外键引用 regions 表。未找到关联时设为 NULL 避免外键约束失败。
|
||||||
server := model.Server{
|
server := model.Server{
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
IP: ipAddr,
|
Alias: alias,
|
||||||
PublicIP: inst.PublicIP,
|
CloudInstanceName: inst.InstanceName,
|
||||||
Port: defaultPort,
|
CloudInstanceID: inst.InstanceID,
|
||||||
Username: username,
|
IP: ipAddr,
|
||||||
AuthType: "password",
|
PublicIP: inst.PublicIP,
|
||||||
OSType: osType,
|
Port: defaultPort,
|
||||||
Protocol: protocol,
|
Username: username,
|
||||||
RDPPort: 3389,
|
AuthType: "password",
|
||||||
OS: osName,
|
OSType: osType,
|
||||||
CPU: inst.CPU,
|
Protocol: protocol,
|
||||||
Memory: int64(inst.Memory),
|
RDPPort: 3389,
|
||||||
Status: status,
|
OS: osName,
|
||||||
RegionID: regionID, // nil = NULL
|
CPU: inst.CPU,
|
||||||
CloudAccountID: &account.ID,
|
Memory: int64(inst.Memory),
|
||||||
Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
|
Status: status,
|
||||||
AgentStatus: "offline",
|
RegionID: regionID, // nil = NULL
|
||||||
|
Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
|
||||||
|
AgentStatus: "offline",
|
||||||
}
|
}
|
||||||
if protocol == "rdp" && hasRDPCred {
|
if protocol == "rdp" && hasRDPCred {
|
||||||
applyCredential(rdpCred, rdpPlain, &server)
|
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{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "导入完成",
|
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 ""
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}})
|
|
||||||
}
|
|
||||||
@@ -359,15 +359,15 @@ func (h *DashboardHandler) GetRecentReleases(c *gin.Context) {
|
|||||||
// @Success 200 {object} model.Response{data=[]model.MetricData}
|
// @Success 200 {object} model.Response{data=[]model.MetricData}
|
||||||
// @Router /dashboard/metrics [get]
|
// @Router /dashboard/metrics [get]
|
||||||
func (h *DashboardHandler) GetMetrics(c *gin.Context) {
|
func (h *DashboardHandler) GetMetrics(c *gin.Context) {
|
||||||
hours := 6 // 默认6小时,减少扫描行数
|
hours := 6 // 默认6小时,减少扫描行数
|
||||||
if hv := c.Query("hours"); hv != "" {
|
if hv := c.Query("hours"); hv != "" {
|
||||||
if parsed, err := strconv.Atoi(hv); err == nil && parsed > 0 && parsed <= 168 {
|
if parsed, err := strconv.Atoi(hv); err == nil && parsed > 0 && parsed <= 168 {
|
||||||
hours = parsed
|
hours = parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 agent_reports 表查最近 N 小时的真实数据(整数分组,可用索引)
|
// 从 agent_reports 表按 UTC 边界查最近 N 小时的真实数据。 // Logan.Li
|
||||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||||
type avgPoint struct {
|
type avgPoint struct {
|
||||||
BucketTS int64
|
BucketTS int64
|
||||||
AvgCPU float64
|
AvgCPU float64
|
||||||
|
|||||||
@@ -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: "已删除"})
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,9 @@ func (h *PipelineHandler) CreateEnvironment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Color == "" { req.Color = typeDefaultColor(req.Type) }
|
if req.Color == "" {
|
||||||
|
req.Color = typeDefaultColor(req.Type)
|
||||||
|
}
|
||||||
item := model.DeployEnvironment{
|
item := model.DeployEnvironment{
|
||||||
Name: req.Name, DisplayName: req.DisplayName,
|
Name: req.Name, DisplayName: req.DisplayName,
|
||||||
Type: model.EnvironmentType(req.Type), Color: req.Color,
|
Type: model.EnvironmentType(req.Type), Color: req.Color,
|
||||||
@@ -67,13 +69,27 @@ func (h *PipelineHandler) UpdateEnvironment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.ShouldBindJSON(&req)
|
c.ShouldBindJSON(&req)
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
if req.Name != nil { updates["name"] = *req.Name }
|
if req.Name != nil {
|
||||||
if req.DisplayName != nil { updates["display_name"] = *req.DisplayName }
|
updates["name"] = *req.Name
|
||||||
if req.Type != nil { updates["type"] = *req.Type }
|
}
|
||||||
if req.Color != nil { updates["color"] = *req.Color }
|
if req.DisplayName != nil {
|
||||||
if req.SortOrder != nil { updates["sort_order"] = *req.SortOrder }
|
updates["display_name"] = *req.DisplayName
|
||||||
if req.Description != nil { updates["description"] = *req.Description }
|
}
|
||||||
if req.Enabled != nil { updates["enabled"] = *req.Enabled }
|
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.Model(&item).Updates(updates)
|
||||||
h.db.First(&item, id)
|
h.db.First(&item, id)
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: item})
|
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 {
|
func typeDefaultColor(t string) string {
|
||||||
switch t {
|
switch t {
|
||||||
case "production": return "#EF4444"
|
case "production":
|
||||||
case "staging": return "#F59E0B"
|
return "#EF4444"
|
||||||
case "testing": return "#10B981"
|
case "staging":
|
||||||
default: return "#3B82F6" // development
|
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{
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||||
"run": run,
|
"run": run,
|
||||||
"logs": logs,
|
"logs": logs,
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,12 @@ func (h *PipelineHandler) UpdateGroup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.ShouldBindJSON(&req)
|
c.ShouldBindJSON(&req)
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
if req.Name != nil { updates["name"] = *req.Name }
|
if req.Name != nil {
|
||||||
if req.Description != nil { updates["description"] = *req.Description }
|
updates["name"] = *req.Name
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
updates["description"] = *req.Description
|
||||||
|
}
|
||||||
h.db.Model(&group).Updates(updates)
|
h.db.Model(&group).Updates(updates)
|
||||||
h.db.First(&group, id)
|
h.db.First(&group, id)
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: group})
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: group})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ezops/middleware"
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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 不能为空"})
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "hostname 不能为空"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !middleware.RequireAgentHostname(c, hostname) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var probes []model.HealthProbe
|
var probes []model.HealthProbe
|
||||||
h.db.Where("hostname = ? AND enabled = ?", hostname, true).
|
h.db.Where("hostname = ? AND enabled = ?", hostname, true).
|
||||||
|
|||||||
@@ -216,28 +216,28 @@ func (h *KubernetesHandler) GetClusterDetail(c *gin.Context) {
|
|||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "success",
|
Message: "success",
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"id": cluster.ID,
|
"id": cluster.ID,
|
||||||
"name": cluster.Name,
|
"name": cluster.Name,
|
||||||
"code": cluster.Code,
|
"code": cluster.Code,
|
||||||
"version": cluster.Version,
|
"version": cluster.Version,
|
||||||
"status": cluster.Status,
|
"status": cluster.Status,
|
||||||
"config_type": cluster.ConfigType,
|
"config_type": cluster.ConfigType,
|
||||||
"api_server": cluster.APIServer,
|
"api_server": cluster.APIServer,
|
||||||
"api_endpoint": cluster.APIEndpoint,
|
"api_endpoint": cluster.APIEndpoint,
|
||||||
"description": cluster.Description,
|
"description": cluster.Description,
|
||||||
"has_token": cluster.Token != "",
|
"has_token": cluster.Token != "",
|
||||||
"has_kube_config": cluster.KubeConfig != "",
|
"has_kube_config": cluster.KubeConfig != "",
|
||||||
"node_count": cluster.NodeCount,
|
"node_count": cluster.NodeCount,
|
||||||
"pod_count": cluster.PodCount,
|
"pod_count": cluster.PodCount,
|
||||||
"service_count": cluster.ServiceCount,
|
"service_count": cluster.ServiceCount,
|
||||||
"deployment_count": cluster.DeploymentCount,
|
"deployment_count": cluster.DeploymentCount,
|
||||||
"ingress_count": cluster.IngressCount,
|
"ingress_count": cluster.IngressCount,
|
||||||
"cpu_usage": cluster.CPUUsage,
|
"cpu_usage": cluster.CPUUsage,
|
||||||
"memory_usage": cluster.MemoryUsage,
|
"memory_usage": cluster.MemoryUsage,
|
||||||
"labels": cluster.Labels,
|
"labels": cluster.Labels,
|
||||||
"last_sync_at": cluster.LastSyncAt,
|
"last_sync_at": cluster.LastSyncAt,
|
||||||
"created_at": cluster.CreatedAt,
|
"created_at": cluster.CreatedAt,
|
||||||
"updated_at": cluster.UpdatedAt,
|
"updated_at": cluster.UpdatedAt,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -320,7 +320,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
|
|||||||
// Sync nodes
|
// Sync nodes
|
||||||
if nodes, err := client.ListNodes(); err == nil {
|
if nodes, err := client.ListNodes(); err == nil {
|
||||||
// Use raw SQL for reliability (avoid GORM soft-delete / auto-timestamp issues)
|
// 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 {
|
for _, n := range nodes.Items {
|
||||||
role := "worker"
|
role := "worker"
|
||||||
if _, ok := n.Metadata.Labels["node-role.kubernetes.io/master"]; ok {
|
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
|
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,
|
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"]),
|
parseIntResource(n.Status.Capacity["cpu"]), parseMemResource(n.Status.Capacity["memory"]), parseIntResource(n.Status.Capacity["pods"]),
|
||||||
cpuUsagePct, memUsagePct, string(labelsJSON), now, now).Error
|
cpuUsagePct, memUsagePct, string(labelsJSON), now, now).Error
|
||||||
@@ -508,7 +508,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, model.Response{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: msg,
|
Message: msg,
|
||||||
Data: result,
|
Data: result,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,27 +14,27 @@ func (h *KubernetesHandler) GetMultiClusterComparison(c *gin.Context) {
|
|||||||
h.db.Find(&clusters)
|
h.db.Find(&clusters)
|
||||||
|
|
||||||
type ClusterComparison struct {
|
type ClusterComparison struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
NodeCount int `json:"node_count"`
|
NodeCount int `json:"node_count"`
|
||||||
PodCount int `json:"pod_count"`
|
PodCount int `json:"pod_count"`
|
||||||
DeploymentCount int `json:"deployment_count"`
|
DeploymentCount int `json:"deployment_count"`
|
||||||
ServiceCount int `json:"service_count"`
|
ServiceCount int `json:"service_count"`
|
||||||
CPUUsage float64 `json:"cpu_usage"`
|
CPUUsage float64 `json:"cpu_usage"`
|
||||||
MemoryUsage float64 `json:"memory_usage"`
|
MemoryUsage float64 `json:"memory_usage"`
|
||||||
APIServer string `json:"api_server"`
|
APIServer string `json:"api_server"`
|
||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
|
|
||||||
// Node-level aggregated data from kubernetes_nodes table
|
// Node-level aggregated data from k8s_nodes table
|
||||||
TotalCPUCores int `json:"total_cpu_cores"`
|
TotalCPUCores int `json:"total_cpu_cores"`
|
||||||
TotalMemoryMB int64 `json:"total_memory_mb"`
|
TotalMemoryMB int64 `json:"total_memory_mb"`
|
||||||
MasterCount int `json:"master_count"`
|
MasterCount int `json:"master_count"`
|
||||||
WorkerCount int `json:"worker_count"`
|
WorkerCount int `json:"worker_count"`
|
||||||
HealthyNodes int `json:"healthy_nodes"`
|
HealthyNodes int `json:"healthy_nodes"`
|
||||||
UnhealthyNodes int `json:"unhealthy_nodes"`
|
UnhealthyNodes int `json:"unhealthy_nodes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 获取所有节点的聚合数据(按 cluster_id 分组)
|
// 2. 获取所有节点的聚合数据(按 cluster_id 分组)
|
||||||
@@ -64,8 +64,16 @@ func (h *KubernetesHandler) GetMultiClusterComparison(c *gin.Context) {
|
|||||||
agg.TotalCPU += n.CPU
|
agg.TotalCPU += n.CPU
|
||||||
agg.TotalMemory += n.Memory
|
agg.TotalMemory += n.Memory
|
||||||
if n.CPUUsage > 0 || n.MemoryUsage > 0 {
|
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.AvgCPUUsage == 0 {
|
||||||
if agg.AvgMemUsage == 0 { agg.AvgMemUsage = n.MemoryUsage } else { agg.AvgMemUsage = (agg.AvgMemUsage*float64(agg.NodeCount-1) + n.MemoryUsage) / float64(agg.NodeCount) }
|
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" {
|
if n.Role == "master" {
|
||||||
agg.MasterCount++
|
agg.MasterCount++
|
||||||
|
|||||||
@@ -14,14 +14,23 @@ import (
|
|||||||
|
|
||||||
func (h *KubernetesHandler) ListEvents(c *gin.Context) {
|
func (h *KubernetesHandler) ListEvents(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Query("namespace")
|
||||||
eventType := c.Query("type") // Normal or Warning
|
eventType := c.Query("type") // Normal or Warning
|
||||||
events, err := client.ListEvents(ns)
|
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 {
|
type EventSummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
@@ -40,7 +49,9 @@ func (h *KubernetesHandler) ListEvents(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
result := []EventSummary{}
|
result := []EventSummary{}
|
||||||
for _, e := range events.Items {
|
for _, e := range events.Items {
|
||||||
if eventType != "" && e.Type != eventType { continue }
|
if eventType != "" && e.Type != eventType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
objectKind := e.InvolvedObject.Kind
|
objectKind := e.InvolvedObject.Kind
|
||||||
objectName := e.InvolvedObject.Name
|
objectName := e.InvolvedObject.Name
|
||||||
object := fmt.Sprintf("%s/%s", objectKind, objectName)
|
object := fmt.Sprintf("%s/%s", objectKind, objectName)
|
||||||
|
|||||||
@@ -246,30 +246,45 @@ func (h *KubernetesHandler) DeleteCronJobNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
func (h *KubernetesHandler) listHPAsWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) listHPAsWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
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()
|
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 {
|
type HPASummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
TargetKind string `json:"target_kind"`
|
TargetKind string `json:"target_kind"`
|
||||||
TargetName string `json:"target_name"`
|
TargetName string `json:"target_name"`
|
||||||
MinReplicas int `json:"min_replicas"`
|
MinReplicas int `json:"min_replicas"`
|
||||||
MaxReplicas int `json:"max_replicas"`
|
MaxReplicas int `json:"max_replicas"`
|
||||||
CPUPercent int `json:"cpu_percent"`
|
CPUPercent int `json:"cpu_percent"`
|
||||||
MemPercent int `json:"mem_percent"`
|
MemPercent int `json:"mem_percent"`
|
||||||
CurrentReplicas int `json:"current_replicas"`
|
CurrentReplicas int `json:"current_replicas"`
|
||||||
}
|
}
|
||||||
result := []HPASummary{}
|
result := []HPASummary{}
|
||||||
for _, h := range hpas.Items {
|
for _, h := range hpas.Items {
|
||||||
if h.Metadata.Namespace != ns { continue }
|
if h.Metadata.Namespace != ns {
|
||||||
cpu := 0; mem := 0
|
continue
|
||||||
|
}
|
||||||
|
cpu := 0
|
||||||
|
mem := 0
|
||||||
for _, m := range h.Spec.Metrics {
|
for _, m := range h.Spec.Metrics {
|
||||||
if m.Resource != nil {
|
if m.Resource != nil {
|
||||||
if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization }
|
if m.Resource.Name == "cpu" {
|
||||||
if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization }
|
cpu = m.Resource.Target.AverageUtilization
|
||||||
|
}
|
||||||
|
if m.Resource.Name == "memory" {
|
||||||
|
mem = m.Resource.Target.AverageUtilization
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, HPASummary{
|
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) {
|
func (h *KubernetesHandler) createHPAWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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
|
var req k8sclient.CreateHPARequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
if req.Namespace == "" { req.Namespace = ns }
|
c.JSON(400, model.Response{Code: 400, Message: "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Namespace == "" {
|
||||||
|
req.Namespace = ns
|
||||||
|
}
|
||||||
client, _, err := h.getK8sClient(clusterID)
|
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)
|
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})
|
c.JSON(200, model.Response{Code: 200, Data: hpa})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) deleteHPAWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) deleteHPAWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
client, _, err := h.getK8sClient(clusterID)
|
||||||
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
if err != nil {
|
||||||
if err := client.DeleteHPA(ns, 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
|
||||||
|
}
|
||||||
|
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: "已删除"})
|
c.JSON(200, model.Response{Code: 200, Message: "已删除"})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) listCronJobsWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) listCronJobsWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
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()
|
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{}
|
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})
|
c.JSON(200, model.Response{Code: 200, Data: filtered})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) createCronJobWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) createCronJobWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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
|
var req k8sclient.CreateCronJobRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
if req.Namespace == "" { req.Namespace = ns }
|
c.JSON(400, model.Response{Code: 400, Message: "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Namespace == "" {
|
||||||
|
req.Namespace = ns
|
||||||
|
}
|
||||||
client, _, err := h.getK8sClient(clusterID)
|
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)
|
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})
|
c.JSON(200, model.Response{Code: 200, Data: cj})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) suspendCronJobWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) suspendCronJobWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||||
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
|
if clusterID == 0 {
|
||||||
var req struct { Suspend bool `json:"suspend"` }; c.ShouldBindJSON(&req)
|
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Suspend bool `json:"suspend"`
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
client, _, err := h.getK8sClient(clusterID)
|
client, _, err := h.getK8sClient(clusterID)
|
||||||
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
if err != nil {
|
||||||
if err := client.SuspendCronJob(ns, c.Param("name"), req.Suspend); err != nil { c.JSON(500, model.Response{Code: 500, Message: "操作失败: " + err.Error()}); return }
|
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
|
||||||
action := "恢复"; if req.Suspend { action = "暂停" }
|
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})
|
c.JSON(200, model.Response{Code: 200, Message: "CronJob 已" + action})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) triggerCronJobWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) triggerCronJobWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
client, _, err := h.getK8sClient(clusterID)
|
||||||
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
if err != nil {
|
||||||
if err := client.TriggerCronJob(ns, 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
|
||||||
|
}
|
||||||
|
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: "手动触发成功"})
|
c.JSON(200, model.Response{Code: 200, Message: "手动触发成功"})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) deleteCronJobWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) deleteCronJobWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
client, _, err := h.getK8sClient(clusterID)
|
||||||
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
if err != nil {
|
||||||
if err := client.DeleteCronJob(ns, 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
|
||||||
|
}
|
||||||
|
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: "已删除"})
|
c.JSON(200, model.Response{Code: 200, Message: "已删除"})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) ListHPAsAllNS(c *gin.Context) {
|
func (h *KubernetesHandler) ListHPAsAllNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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()
|
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 {
|
type HPASummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -375,11 +475,16 @@ func (h *KubernetesHandler) ListHPAsAllNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
result := []HPASummary{}
|
result := []HPASummary{}
|
||||||
for _, h := range hpas.Items {
|
for _, h := range hpas.Items {
|
||||||
cpu := 0; mem := 0
|
cpu := 0
|
||||||
|
mem := 0
|
||||||
for _, m := range h.Spec.Metrics {
|
for _, m := range h.Spec.Metrics {
|
||||||
if m.Resource != nil {
|
if m.Resource != nil {
|
||||||
if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization }
|
if m.Resource.Name == "cpu" {
|
||||||
if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization }
|
cpu = m.Resource.Target.AverageUtilization
|
||||||
|
}
|
||||||
|
if m.Resource.Name == "memory" {
|
||||||
|
mem = m.Resource.Target.AverageUtilization
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, HPASummary{
|
result = append(result, HPASummary{
|
||||||
|
|||||||
@@ -38,16 +38,16 @@ func (h *KubernetesHandler) ListNamespaces(c *gin.Context) {
|
|||||||
|
|
||||||
// NamespaceDetail is the enriched namespace info for the management page
|
// NamespaceDetail is the enriched namespace info for the management page
|
||||||
type NamespaceDetail struct {
|
type NamespaceDetail struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Age string `json:"age"`
|
Age string `json:"age"`
|
||||||
Labels map[string]string `json:"labels"`
|
Labels map[string]string `json:"labels"`
|
||||||
Annotations map[string]string `json:"annotations,omitempty"`
|
Annotations map[string]string `json:"annotations,omitempty"`
|
||||||
ResourceCounts map[string]int `json:"resource_counts"`
|
ResourceCounts map[string]int `json:"resource_counts"`
|
||||||
HasResourceQuota bool `json:"has_resource_quota"`
|
HasResourceQuota bool `json:"has_resource_quota"`
|
||||||
HasLimitRange bool `json:"has_limit_range"`
|
HasLimitRange bool `json:"has_limit_range"`
|
||||||
ResourceQuotas []k8sclient.ResourceQuotaItem `json:"resource_quotas,omitempty"`
|
ResourceQuotas []k8sclient.ResourceQuotaItem `json:"resource_quotas,omitempty"`
|
||||||
LimitRanges []k8sclient.LimitRangeItem `json:"limit_ranges,omitempty"`
|
LimitRanges []k8sclient.LimitRangeItem `json:"limit_ranges,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNamespacesDetail returns all namespaces with metadata, resource counts and quota info
|
// 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 {
|
for _, ns := range nsList.Items {
|
||||||
name := ns.Metadata.Name
|
name := ns.Metadata.Name
|
||||||
nd := NamespaceDetail{
|
nd := NamespaceDetail{
|
||||||
Name: name,
|
Name: name,
|
||||||
Status: ns.Status.Phase,
|
Status: ns.Status.Phase,
|
||||||
Age: formatAge(ns.Metadata.CreationTimestamp),
|
Age: formatAge(ns.Metadata.CreationTimestamp),
|
||||||
Labels: ns.Metadata.Labels,
|
Labels: ns.Metadata.Labels,
|
||||||
Annotations: ns.Metadata.Annotations,
|
Annotations: ns.Metadata.Annotations,
|
||||||
ResourceCounts: map[string]int{
|
ResourceCounts: map[string]int{
|
||||||
"deployments": depCounts[name],
|
"deployments": depCounts[name],
|
||||||
"pods": podCounts[name],
|
"pods": podCounts[name],
|
||||||
@@ -181,21 +181,27 @@ func (h *KubernetesHandler) GetNamespaceDetailHandler(c *gin.Context) {
|
|||||||
if deps, err := client.ListDeployments(); err == nil {
|
if deps, err := client.ListDeployments(); err == nil {
|
||||||
count := 0
|
count := 0
|
||||||
for _, d := range deps.Items {
|
for _, d := range deps.Items {
|
||||||
if d.Metadata.Namespace == nsName { count++ }
|
if d.Metadata.Namespace == nsName {
|
||||||
|
count++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
nd.ResourceCounts["deployments"] = count
|
nd.ResourceCounts["deployments"] = count
|
||||||
}
|
}
|
||||||
if pods, err := client.ListPods(); err == nil {
|
if pods, err := client.ListPods(); err == nil {
|
||||||
count := 0
|
count := 0
|
||||||
for _, p := range pods.Items {
|
for _, p := range pods.Items {
|
||||||
if p.Metadata.Namespace == nsName { count++ }
|
if p.Metadata.Namespace == nsName {
|
||||||
|
count++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
nd.ResourceCounts["pods"] = count
|
nd.ResourceCounts["pods"] = count
|
||||||
}
|
}
|
||||||
if svcs, err := client.ListServices(); err == nil {
|
if svcs, err := client.ListServices(); err == nil {
|
||||||
count := 0
|
count := 0
|
||||||
for _, s := range svcs.Items {
|
for _, s := range svcs.Items {
|
||||||
if s.Metadata.Namespace == nsName { count++ }
|
if s.Metadata.Namespace == nsName {
|
||||||
|
count++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
nd.ResourceCounts["services"] = count
|
nd.ResourceCounts["services"] = count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,20 +305,20 @@ func (h *KubernetesHandler) GetNodeDetail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
detail := map[string]interface{}{
|
detail := map[string]interface{}{
|
||||||
"name": node.Metadata.Name,
|
"name": node.Metadata.Name,
|
||||||
"role": role,
|
"role": role,
|
||||||
"status": nodeStatus,
|
"status": nodeStatus,
|
||||||
"creation_time": node.Metadata.CreationTimestamp,
|
"creation_time": node.Metadata.CreationTimestamp,
|
||||||
"internal_ip": internalIP,
|
"internal_ip": internalIP,
|
||||||
"external_ip": externalIP,
|
"external_ip": externalIP,
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
"pod_cidr": node.Spec.PodCIDR,
|
"pod_cidr": node.Spec.PodCIDR,
|
||||||
"container_runtime": node.Status.NodeInfo.ContainerRuntimeVersion,
|
"container_runtime": node.Status.NodeInfo.ContainerRuntimeVersion,
|
||||||
"kubelet_version": node.Status.NodeInfo.KubeletVersion,
|
"kubelet_version": node.Status.NodeInfo.KubeletVersion,
|
||||||
"os_image": node.Status.NodeInfo.OSImage,
|
"os_image": node.Status.NodeInfo.OSImage,
|
||||||
"kernel_version": node.Status.NodeInfo.KernelVersion,
|
"kernel_version": node.Status.NodeInfo.KernelVersion,
|
||||||
"architecture": node.Status.NodeInfo.Architecture,
|
"architecture": node.Status.NodeInfo.Architecture,
|
||||||
"operating_system": node.Status.NodeInfo.OperatingSystem,
|
"operating_system": node.Status.NodeInfo.OperatingSystem,
|
||||||
// Capacity & Allocatable
|
// Capacity & Allocatable
|
||||||
"capacity": node.Status.Capacity,
|
"capacity": node.Status.Capacity,
|
||||||
"allocatable": node.Status.Allocatable,
|
"allocatable": node.Status.Allocatable,
|
||||||
@@ -326,18 +326,18 @@ func (h *KubernetesHandler) GetNodeDetail(c *gin.Context) {
|
|||||||
"cpu_usage_m": cpuUsageM,
|
"cpu_usage_m": cpuUsageM,
|
||||||
"mem_usage_mi": memUsageMi,
|
"mem_usage_mi": memUsageMi,
|
||||||
// Allocation
|
// Allocation
|
||||||
"pod_count": podCount,
|
"pod_count": podCount,
|
||||||
"cpu_request_m": totalCPURequestM,
|
"cpu_request_m": totalCPURequestM,
|
||||||
"mem_request_mi": totalMemRequestMi,
|
"mem_request_mi": totalMemRequestMi,
|
||||||
"cpu_alloc_pct": cpuAllocPct,
|
"cpu_alloc_pct": cpuAllocPct,
|
||||||
"mem_alloc_pct": memAllocPct,
|
"mem_alloc_pct": memAllocPct,
|
||||||
"pod_alloc_pct": podAllocPct,
|
"pod_alloc_pct": podAllocPct,
|
||||||
"cpu_capacity": capCPU,
|
"cpu_capacity": capCPU,
|
||||||
"cpu_allocatable": allocCPU,
|
"cpu_allocatable": allocCPU,
|
||||||
"mem_capacity_mb": capMem,
|
"mem_capacity_mb": capMem,
|
||||||
"mem_allocatable_mb": allocMem,
|
"mem_allocatable_mb": allocMem,
|
||||||
"pod_capacity": capPods,
|
"pod_capacity": capPods,
|
||||||
"pod_allocatable": allocPods,
|
"pod_allocatable": allocPods,
|
||||||
// Lists
|
// Lists
|
||||||
"conditions": conditions,
|
"conditions": conditions,
|
||||||
"taints": taints,
|
"taints": taints,
|
||||||
@@ -397,20 +397,29 @@ func parseMemToMi(val string) int64 {
|
|||||||
// @Router /kubernetes-clusters/{id}/pods [get]
|
// @Router /kubernetes-clusters/{id}/pods [get]
|
||||||
func (h *KubernetesHandler) GetCapacityAnalysis(c *gin.Context) {
|
func (h *KubernetesHandler) GetCapacityAnalysis(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Query("namespace")
|
||||||
deps, err := client.ListDeployments()
|
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 {
|
type ContainerResource struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CPUReq string `json:"cpu_request"`
|
CPUReq string `json:"cpu_request"`
|
||||||
CPULim string `json:"cpu_limit"`
|
CPULim string `json:"cpu_limit"`
|
||||||
MemReq string `json:"mem_request"`
|
MemReq string `json:"mem_request"`
|
||||||
MemLim string `json:"mem_limit"`
|
MemLim string `json:"mem_limit"`
|
||||||
}
|
}
|
||||||
type CapacityRow struct {
|
type CapacityRow struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
@@ -420,7 +429,9 @@ func (h *KubernetesHandler) GetCapacityAnalysis(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
result := []CapacityRow{}
|
result := []CapacityRow{}
|
||||||
for _, d := range deps.Items {
|
for _, d := range deps.Items {
|
||||||
if ns != "" && d.Metadata.Namespace != ns { continue }
|
if ns != "" && d.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
containers := []ContainerResource{}
|
containers := []ContainerResource{}
|
||||||
for _, c := range d.Spec.Template.Spec.Containers {
|
for _, c := range d.Spec.Template.Spec.Containers {
|
||||||
cpuReq := c.Resources.Requests["cpu"]
|
cpuReq := c.Resources.Requests["cpu"]
|
||||||
|
|||||||
+139
-48
@@ -198,8 +198,8 @@ func (h *KubernetesHandler) ExecTerminal(c *gin.Context) {
|
|||||||
|
|
||||||
// Dial K8s API server via WebSocket with channel protocol subprotocol
|
// Dial K8s API server via WebSocket with channel protocol subprotocol
|
||||||
k8sDialer := &websocket.Dialer{
|
k8sDialer := &websocket.Dialer{
|
||||||
TLSClientConfig: client.TLSConfig,
|
TLSClientConfig: client.TLSConfig,
|
||||||
Subprotocols: []string{"v4.channel.k8s.io", "v3.channel.k8s.io", "v5.channel.k8s.io"},
|
Subprotocols: []string{"v4.channel.k8s.io", "v3.channel.k8s.io", "v5.channel.k8s.io"},
|
||||||
HandshakeTimeout: 10 * time.Second,
|
HandshakeTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
k8sHeader := http.Header{}
|
k8sHeader := http.Header{}
|
||||||
@@ -354,30 +354,46 @@ func (h *KubernetesHandler) ExecInPodNS(c *gin.Context) {
|
|||||||
// Internal helpers that resolve clusterCode and inject namespace
|
// Internal helpers that resolve clusterCode and inject namespace
|
||||||
func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
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()
|
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
|
// M2: inject pod metrics (best-effort) // Logan.Li
|
||||||
podMetrics := buildPodMetricsMap(client, ns)
|
podMetrics := buildPodMetricsMap(client, ns)
|
||||||
type PodSummary struct {
|
type PodSummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
NodeName string `json:"node_name"`
|
NodeName string `json:"node_name"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
RestartCount int `json:"restart_count"`
|
RestartCount int `json:"restart_count"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
CPUUsageM string `json:"cpu_usage_m,omitempty"`
|
CPUUsageM string `json:"cpu_usage_m,omitempty"`
|
||||||
MemUsageMi string `json:"mem_usage_mi,omitempty"`
|
MemUsageMi string `json:"mem_usage_mi,omitempty"`
|
||||||
Conditions []k8sclient.PodCondition `json:"conditions,omitempty"`
|
Conditions []k8sclient.PodCondition `json:"conditions,omitempty"`
|
||||||
}
|
}
|
||||||
result := []PodSummary{}
|
result := []PodSummary{}
|
||||||
for _, p := range pods.Items {
|
for _, p := range pods.Items {
|
||||||
if p.Metadata.Namespace != ns { continue }
|
if p.Metadata.Namespace != ns {
|
||||||
image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image }
|
continue
|
||||||
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{
|
ps := PodSummary{
|
||||||
Namespace: p.Metadata.Namespace,
|
Namespace: p.Metadata.Namespace,
|
||||||
Name: p.Metadata.Name,
|
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) {
|
func (h *KubernetesHandler) getPodLogsWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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")
|
podName := c.Param("podName")
|
||||||
stream := c.Query("stream") == "true"
|
stream := c.Query("stream") == "true"
|
||||||
// 日志增强参数:container / sinceTime / previous / tailLines(兼容 tail) // Logan.Li
|
// 日志增强参数: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",
|
Previous: c.Query("previous") == "true",
|
||||||
}
|
}
|
||||||
client, _, err := h.getK8sClient(clusterID)
|
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 {
|
if stream {
|
||||||
body, err := client.GetPodLogsStreamWithOptions(ns, podName, opts)
|
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()
|
defer body.Close()
|
||||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Stream(func(w io.Writer) bool {
|
c.Stream(func(w io.Writer) bool {
|
||||||
buf := make([]byte, 4096); n, err := body.Read(buf)
|
buf := make([]byte, 4096)
|
||||||
if n > 0 { w.Write(buf[:n]); return true }
|
n, err := body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
w.Write(buf[:n])
|
||||||
|
return true
|
||||||
|
}
|
||||||
return err == nil
|
return err == nil
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logs, err := client.GetPodLogsWithOptions(ns, podName, opts)
|
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}})
|
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) {
|
func (h *KubernetesHandler) execInPodWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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")
|
podName := c.Param("podName")
|
||||||
var req struct { Command []string `json:"command"`; Container string `json:"container"` }
|
var req struct {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
|
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)
|
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)
|
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})
|
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.
|
// ListDeploymentsAllNS lists all deployments across all namespaces using clusterCode.
|
||||||
func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) {
|
func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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()
|
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
|
// M2: inject pod metrics (best-effort) // Logan.Li
|
||||||
podMetrics := buildPodMetricsMap(client, "")
|
podMetrics := buildPodMetricsMap(client, "")
|
||||||
type PodSummary struct {
|
type PodSummary struct {
|
||||||
@@ -474,8 +528,14 @@ func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
result := []PodSummary{}
|
result := []PodSummary{}
|
||||||
for _, p := range pods.Items {
|
for _, p := range pods.Items {
|
||||||
image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image }
|
image := ""
|
||||||
restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount }
|
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{
|
ps := PodSummary{
|
||||||
Namespace: p.Metadata.Namespace,
|
Namespace: p.Metadata.Namespace,
|
||||||
Name: p.Metadata.Name,
|
Name: p.Metadata.Name,
|
||||||
@@ -559,58 +619,80 @@ func parseMemResource(val string) int64 {
|
|||||||
|
|
||||||
// formatCpuFromNano converts K8s metrics CPU (nanocores "80410173n") to millicores "80m"
|
// formatCpuFromNano converts K8s metrics CPU (nanocores "80410173n") to millicores "80m"
|
||||||
func formatCpuFromNano(raw string) string {
|
func formatCpuFromNano(raw string) string {
|
||||||
if raw == "" { return "" }
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
val := raw
|
val := raw
|
||||||
val = strings.TrimSuffix(val, "n")
|
val = strings.TrimSuffix(val, "n")
|
||||||
var nano int64
|
var nano int64
|
||||||
fmt.Sscanf(val, "%d", &nano)
|
fmt.Sscanf(val, "%d", &nano)
|
||||||
milli := nano / 1000000
|
milli := nano / 1000000
|
||||||
if milli < 1 { milli = 1 }
|
if milli < 1 {
|
||||||
|
milli = 1
|
||||||
|
}
|
||||||
return fmt.Sprintf("%dm", milli)
|
return fmt.Sprintf("%dm", milli)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCpuNanoToMilli converts K8s metrics CPU (nanocores "100354992n") to millicores (int) // Logan.Li
|
// parseCpuNanoToMilli converts K8s metrics CPU (nanocores "100354992n") to millicores (int) // Logan.Li
|
||||||
func parseCpuNanoToMilli(raw string) int {
|
func parseCpuNanoToMilli(raw string) int {
|
||||||
if raw == "" { return 0 }
|
if raw == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
val := strings.TrimSuffix(raw, "n")
|
val := strings.TrimSuffix(raw, "n")
|
||||||
var nano int64
|
var nano int64
|
||||||
fmt.Sscanf(val, "%d", &nano)
|
fmt.Sscanf(val, "%d", &nano)
|
||||||
milli := nano / 1000000
|
milli := nano / 1000000
|
||||||
if milli < 1 { milli = 1 }
|
if milli < 1 {
|
||||||
|
milli = 1
|
||||||
|
}
|
||||||
return int(milli)
|
return int(milli)
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatMemFromKi converts K8s metrics memory (Ki "2154292Ki") to Mi "2105Mi"
|
// formatMemFromKi converts K8s metrics memory (Ki "2154292Ki") to Mi "2105Mi"
|
||||||
func formatMemFromKi(raw string) string {
|
func formatMemFromKi(raw string) string {
|
||||||
if raw == "" { return "" }
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
val := strings.TrimSuffix(raw, "Ki")
|
val := strings.TrimSuffix(raw, "Ki")
|
||||||
var ki int64
|
var ki int64
|
||||||
fmt.Sscanf(val, "%d", &ki)
|
fmt.Sscanf(val, "%d", &ki)
|
||||||
mi := ki / 1024
|
mi := ki / 1024
|
||||||
if mi < 1 { mi = 1 }
|
if mi < 1 {
|
||||||
|
mi = 1
|
||||||
|
}
|
||||||
return fmt.Sprintf("%dMi", mi)
|
return fmt.Sprintf("%dMi", mi)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMemKiToMi converts K8s metrics memory (Ki "2154292Ki") to Mi (int) // Logan.Li
|
// parseMemKiToMi converts K8s metrics memory (Ki "2154292Ki") to Mi (int) // Logan.Li
|
||||||
func parseMemKiToMi(raw string) int {
|
func parseMemKiToMi(raw string) int {
|
||||||
if raw == "" { return 0 }
|
if raw == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
val := strings.TrimSuffix(raw, "Ki")
|
val := strings.TrimSuffix(raw, "Ki")
|
||||||
var ki int64
|
var ki int64
|
||||||
fmt.Sscanf(val, "%d", &ki)
|
fmt.Sscanf(val, "%d", &ki)
|
||||||
return int(ki / 1024)
|
return int(ki / 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// GetResourceYAMLNS returns the YAML of a K8s resource
|
// GetResourceYAMLNS returns the YAML of a K8s resource
|
||||||
func (h *KubernetesHandler) GetPodMetrics(c *gin.Context) {
|
func (h *KubernetesHandler) GetPodMetrics(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Query("namespace")
|
||||||
metrics, err := client.ListPodMetrics(ns)
|
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 {
|
type ContainerMetric struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -643,13 +725,22 @@ func (h *KubernetesHandler) DeletePodNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
func (h *KubernetesHandler) deletePodWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) deletePodWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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")
|
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)
|
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 {
|
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)})
|
c.JSON(200, model.Response{Code: 200, Message: fmt.Sprintf("Pod %s 已删除", podName)})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,20 +19,33 @@ func (h *KubernetesHandler) ListPodFilesNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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")
|
podName := c.Param("podName")
|
||||||
var req struct {
|
var req struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Container string `json:"container"`
|
Container string `json:"container"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误: " + err.Error()}); return }
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
if req.Path == "" { req.Path = "/" }
|
c.JSON(400, model.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Path == "" {
|
||||||
|
req.Path = "/"
|
||||||
|
}
|
||||||
client, _, err := h.getK8sClient(clusterID)
|
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
|
// ls -la — BusyBox ignores --time-style, use default "Mon DD HH:MM" format
|
||||||
cmd := []string{"ls", "-la", req.Path}
|
cmd := []string{"ls", "-la", req.Path}
|
||||||
result, err := client.ExecInPod(ns, podName, req.Container, cmd)
|
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 {
|
type FileInfo struct {
|
||||||
Name string `json:"name"`
|
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
|
output := result.Stdout // WebSocket output is already clean text
|
||||||
for _, line := range strings.Split(output, "\n") {
|
for _, line := range strings.Split(output, "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" || strings.HasPrefix(line, "total ") { continue }
|
if line == "" || strings.HasPrefix(line, "total ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
if len(fields) < 7 { continue }
|
if len(fields) < 7 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
mode := fields[0]
|
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]
|
size := fields[4]
|
||||||
// BusyBox ls date format: "Jun 23 02:17" (3 fields: month day time)
|
// BusyBox ls date format: "Jun 23 02:17" (3 fields: month day time)
|
||||||
// GNU ls --time-style: "2026-06-23T02:17:00" (1 field)
|
// 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], " ")
|
modTime = strings.Join(fields[5:8], " ")
|
||||||
name = strings.Join(fields[8:], " ")
|
name = strings.Join(fields[8:], " ")
|
||||||
}
|
}
|
||||||
if name == "" || name == "." || name == ".." { continue }
|
if name == "" || name == "." || name == ".." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
files = append(files, FileInfo{
|
files = append(files, FileInfo{
|
||||||
Name: name,
|
Name: name,
|
||||||
Size: size,
|
Size: size,
|
||||||
@@ -82,16 +103,27 @@ func (h *KubernetesHandler) DownloadPodFileNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
func (h *KubernetesHandler) downloadPodFileWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) downloadPodFileWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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")
|
podName := c.Param("podName")
|
||||||
filePath := c.Query("path")
|
filePath := c.Query("path")
|
||||||
container := c.Query("container")
|
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)
|
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}
|
cmd := []string{"cat", filePath}
|
||||||
result, err := client.ExecInPod(ns, podName, container, cmd)
|
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
|
content := result.Stdout
|
||||||
fileName := path.Base(filePath)
|
fileName := path.Base(filePath)
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
||||||
|
|||||||
@@ -223,7 +223,6 @@ func (h *KubernetesHandler) SaveTopology(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "保存成功"})
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "保存成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// SyncCluster 同步集群资源(连接真实K8s API拉取数据)
|
// SyncCluster 同步集群资源(连接真实K8s API拉取数据)
|
||||||
func (h *KubernetesHandler) ListConfigMaps(c *gin.Context) {
|
func (h *KubernetesHandler) ListConfigMaps(c *gin.Context) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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) {
|
func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
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()
|
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 {
|
type SvcPortDetail struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
@@ -326,17 +333,19 @@ func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) {
|
|||||||
Protocol string `json:"protocol,omitempty"`
|
Protocol string `json:"protocol,omitempty"`
|
||||||
}
|
}
|
||||||
type SvcSummary struct {
|
type SvcSummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ClusterIP string `json:"cluster_ip"`
|
ClusterIP string `json:"cluster_ip"`
|
||||||
Ports int `json:"ports"`
|
Ports int `json:"ports"`
|
||||||
PortList []SvcPortDetail `json:"portList,omitempty"`
|
PortList []SvcPortDetail `json:"portList,omitempty"`
|
||||||
Selector map[string]string `json:"selector,omitempty"`
|
Selector map[string]string `json:"selector,omitempty"`
|
||||||
}
|
}
|
||||||
result := []SvcSummary{}
|
result := []SvcSummary{}
|
||||||
for _, s := range svcs.Items {
|
for _, s := range svcs.Items {
|
||||||
if s.Metadata.Namespace != ns { continue }
|
if s.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
ports := []SvcPortDetail{}
|
ports := []SvcPortDetail{}
|
||||||
for _, p := range s.Spec.Ports {
|
for _, p := range s.Spec.Ports {
|
||||||
ports = append(ports, SvcPortDetail{
|
ports = append(ports, SvcPortDetail{
|
||||||
@@ -345,28 +354,36 @@ func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
result = append(result, SvcSummary{
|
result = append(result, SvcSummary{
|
||||||
Namespace: s.Metadata.Namespace,
|
Namespace: s.Metadata.Namespace,
|
||||||
Name: s.Metadata.Name,
|
Name: s.Metadata.Name,
|
||||||
Type: s.Spec.Type,
|
Type: s.Spec.Type,
|
||||||
ClusterIP: s.Spec.ClusterIP,
|
ClusterIP: s.Spec.ClusterIP,
|
||||||
Ports: len(s.Spec.Ports),
|
Ports: len(s.Spec.Ports),
|
||||||
PortList: ports,
|
PortList: ports,
|
||||||
Selector: s.Spec.Selector,
|
Selector: s.Spec.Selector,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
c.JSON(200, model.Response{Code: 200, Data: result})
|
c.JSON(200, model.Response{Code: 200, Data: result})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
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()
|
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 {
|
type PathDetail struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
PathType string `json:"pathType,omitempty"`
|
PathType string `json:"pathType,omitempty"`
|
||||||
BackendSvc string `json:"backendService,omitempty"`
|
BackendSvc string `json:"backendService,omitempty"`
|
||||||
BackendPort string `json:"backendPort,omitempty"`
|
BackendPort string `json:"backendPort,omitempty"`
|
||||||
}
|
}
|
||||||
type RuleDetail struct {
|
type RuleDetail struct {
|
||||||
@@ -374,20 +391,24 @@ func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) {
|
|||||||
Paths []PathDetail `json:"paths"`
|
Paths []PathDetail `json:"paths"`
|
||||||
}
|
}
|
||||||
type IngSummary struct {
|
type IngSummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Hosts []string `json:"hosts"`
|
Hosts []string `json:"hosts"`
|
||||||
TLS bool `json:"tls"`
|
TLS bool `json:"tls"`
|
||||||
IngressClass string `json:"ingressClass,omitempty"`
|
IngressClass string `json:"ingressClass,omitempty"`
|
||||||
Rules []RuleDetail `json:"rules,omitempty"`
|
Rules []RuleDetail `json:"rules,omitempty"`
|
||||||
}
|
}
|
||||||
result := []IngSummary{}
|
result := []IngSummary{}
|
||||||
for _, ing := range ings.Items {
|
for _, ing := range ings.Items {
|
||||||
if ing.Metadata.Namespace != ns { continue }
|
if ing.Metadata.Namespace != ns {
|
||||||
|
continue
|
||||||
|
}
|
||||||
hosts := []string{}
|
hosts := []string{}
|
||||||
rules := []RuleDetail{}
|
rules := []RuleDetail{}
|
||||||
for _, rule := range ing.Spec.Rules {
|
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{}}
|
rd := RuleDetail{Host: rule.Host, Paths: []PathDetail{}}
|
||||||
if rule.HTTP != nil {
|
if rule.HTTP != nil {
|
||||||
for _, p := range rule.HTTP.Paths {
|
for _, p := range rule.HTTP.Paths {
|
||||||
@@ -453,12 +474,21 @@ func (h *KubernetesHandler) ListIngressClasses(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) {
|
func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
pvcs, err := client.ListPVCs(ns)
|
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 {
|
type PVCSummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -470,14 +500,16 @@ func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) {
|
|||||||
for _, p := range pvcs.Items {
|
for _, p := range pvcs.Items {
|
||||||
size := ""
|
size := ""
|
||||||
if p.Status.Capacity != nil {
|
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{
|
result = append(result, PVCSummary{
|
||||||
Namespace: p.Metadata.Namespace,
|
Namespace: p.Metadata.Namespace,
|
||||||
Name: p.Metadata.Name,
|
Name: p.Metadata.Name,
|
||||||
Phase: p.Status.Phase,
|
Phase: p.Status.Phase,
|
||||||
Size: size,
|
Size: size,
|
||||||
StorageClass: p.Spec.StorageClassName,
|
StorageClass: p.Spec.StorageClassName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
c.JSON(200, model.Response{Code: 200, Data: result})
|
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)
|
// DeletePVCNS deletes a PVC (code-based)
|
||||||
func (h *KubernetesHandler) DeletePVCNS(c *gin.Context) {
|
func (h *KubernetesHandler) DeletePVCNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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 {
|
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: "已删除"})
|
c.JSON(200, model.Response{Code: 200, Message: "已删除"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,15 +208,25 @@ func (h *KubernetesHandler) GetDeploymentNS(c *gin.Context) {
|
|||||||
ns := c.Param("namespace")
|
ns := c.Param("namespace")
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
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()
|
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 {
|
for _, d := range deps.Items {
|
||||||
if d.Metadata.Namespace == ns && d.Metadata.Name == name {
|
if d.Metadata.Namespace == ns && d.Metadata.Name == name {
|
||||||
image := ""
|
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{}{
|
c.JSON(200, model.Response{Code: 200, Data: map[string]interface{}{
|
||||||
"namespace": d.Metadata.Namespace,
|
"namespace": d.Metadata.Namespace,
|
||||||
"name": d.Metadata.Name,
|
"name": d.Metadata.Name,
|
||||||
@@ -248,11 +258,19 @@ func (h *KubernetesHandler) ResumeDeploymentNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
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()
|
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 {
|
type DepSummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -265,8 +283,13 @@ func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) {
|
|||||||
}
|
}
|
||||||
result := []DepSummary{}
|
result := []DepSummary{}
|
||||||
for _, d := range deps.Items {
|
for _, d := range deps.Items {
|
||||||
if d.Metadata.Namespace != ns { continue }
|
if d.Metadata.Namespace != ns {
|
||||||
image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image }
|
continue
|
||||||
|
}
|
||||||
|
image := ""
|
||||||
|
if len(d.Spec.Template.Spec.Containers) > 0 {
|
||||||
|
image = d.Spec.Template.Spec.Containers[0].Image
|
||||||
|
}
|
||||||
result = append(result, DepSummary{
|
result = append(result, DepSummary{
|
||||||
Namespace: d.Metadata.Namespace,
|
Namespace: d.Metadata.Namespace,
|
||||||
Name: d.Metadata.Name,
|
Name: d.Metadata.Name,
|
||||||
@@ -275,63 +298,131 @@ func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) {
|
|||||||
ReadyReplicas: d.Status.ReadyReplicas,
|
ReadyReplicas: d.Status.ReadyReplicas,
|
||||||
Image: image,
|
Image: image,
|
||||||
Paused: d.Spec.Paused,
|
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})
|
c.JSON(200, model.Response{Code: 200, Data: result})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) scaleDeploymentWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) scaleDeploymentWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||||
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
|
if clusterID == 0 {
|
||||||
var req struct { Replicas int `json:"replicas"` }
|
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
|
||||||
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
|
||||||
|
}
|
||||||
client, _, err := h.getK8sClient(clusterID)
|
client, _, err := h.getK8sClient(clusterID)
|
||||||
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
if err != nil {
|
||||||
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(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)})
|
c.JSON(200, model.Response{Code: 200, Message: fmt.Sprintf("已设置为 %d 副本", req.Replicas)})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) restartDeploymentWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) restartDeploymentWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
client, _, err := h.getK8sClient(clusterID)
|
||||||
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
if err != nil {
|
||||||
if err := client.RestartDeployment(ns, 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
|
||||||
|
}
|
||||||
|
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: "重启指令已下发"})
|
c.JSON(200, model.Response{Code: 200, Message: "重启指令已下发"})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) updateDeploymentImageWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) updateDeploymentImageWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||||
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
|
if clusterID == 0 {
|
||||||
var req struct { Image string `json:"image"`; ContainerName string `json:"container_name"` }
|
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
|
||||||
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 }
|
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)
|
client, _, err := h.getK8sClient(clusterID)
|
||||||
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
if err != nil {
|
||||||
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(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)})
|
c.JSON(200, model.Response{Code: 200, Message: fmt.Sprintf("镜像已更新为 %s", req.Image)})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) pauseDeploymentWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) pauseDeploymentWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
client, _, err := h.getK8sClient(clusterID)
|
||||||
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
if err != nil {
|
||||||
if err := client.PauseDeployment(ns, 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
|
||||||
|
}
|
||||||
|
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: "已暂停滚动更新"})
|
c.JSON(200, model.Response{Code: 200, Message: "已暂停滚动更新"})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) resumeDeploymentWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) resumeDeploymentWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
client, _, err := h.getK8sClient(clusterID)
|
||||||
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
if err != nil {
|
||||||
if err := client.ResumeDeployment(ns, 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
|
||||||
|
}
|
||||||
|
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: "已恢复滚动更新"})
|
c.JSON(200, model.Response{Code: 200, Message: "已恢复滚动更新"})
|
||||||
}
|
}
|
||||||
func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) {
|
func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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()
|
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 {
|
type DepSummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -342,7 +433,10 @@ func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
result := []DepSummary{}
|
result := []DepSummary{}
|
||||||
for _, d := range deps.Items {
|
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{
|
result = append(result, DepSummary{
|
||||||
Namespace: d.Metadata.Namespace,
|
Namespace: d.Metadata.Namespace,
|
||||||
Name: d.Metadata.Name,
|
Name: d.Metadata.Name,
|
||||||
@@ -358,11 +452,20 @@ func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) {
|
|||||||
// ListPodsAllNS lists all pods across all namespaces using clusterCode.
|
// ListPodsAllNS lists all pods across all namespaces using clusterCode.
|
||||||
func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) {
|
func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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()
|
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")
|
ns := c.Param("namespace")
|
||||||
type StsSummary struct {
|
type StsSummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
@@ -373,8 +476,13 @@ func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
result := []StsSummary{}
|
result := []StsSummary{}
|
||||||
for _, s := range sts.Items {
|
for _, s := range sts.Items {
|
||||||
if s.Metadata.Namespace != ns { continue }
|
if s.Metadata.Namespace != ns {
|
||||||
image := ""; if len(s.Spec.Template.Spec.Containers) > 0 { image = s.Spec.Template.Spec.Containers[0].Image }
|
continue
|
||||||
|
}
|
||||||
|
image := ""
|
||||||
|
if len(s.Spec.Template.Spec.Containers) > 0 {
|
||||||
|
image = s.Spec.Template.Spec.Containers[0].Image
|
||||||
|
}
|
||||||
result = append(result, StsSummary{
|
result = append(result, StsSummary{
|
||||||
Namespace: s.Metadata.Namespace,
|
Namespace: s.Metadata.Namespace,
|
||||||
Name: s.Metadata.Name,
|
Name: s.Metadata.Name,
|
||||||
@@ -389,11 +497,20 @@ func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) {
|
|||||||
// ListDaemonSetsNS lists DaemonSets in a namespace
|
// ListDaemonSetsNS lists DaemonSets in a namespace
|
||||||
func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) {
|
func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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()
|
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")
|
ns := c.Param("namespace")
|
||||||
type DsSummary struct {
|
type DsSummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
@@ -405,8 +522,13 @@ func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
result := []DsSummary{}
|
result := []DsSummary{}
|
||||||
for _, d := range ds.Items {
|
for _, d := range ds.Items {
|
||||||
if d.Metadata.Namespace != ns { continue }
|
if d.Metadata.Namespace != ns {
|
||||||
image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image }
|
continue
|
||||||
|
}
|
||||||
|
image := ""
|
||||||
|
if len(d.Spec.Template.Spec.Containers) > 0 {
|
||||||
|
image = d.Spec.Template.Spec.Containers[0].Image
|
||||||
|
}
|
||||||
result = append(result, DsSummary{
|
result = append(result, DsSummary{
|
||||||
Namespace: d.Metadata.Namespace,
|
Namespace: d.Metadata.Namespace,
|
||||||
Name: d.Metadata.Name,
|
Name: d.Metadata.Name,
|
||||||
@@ -418,18 +540,31 @@ func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(200, model.Response{Code: 200, Data: result})
|
c.JSON(200, model.Response{Code: 200, Data: result})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScaleStatefulSetNS scales a StatefulSet (Kuboard code route)
|
// ScaleStatefulSetNS scales a StatefulSet (Kuboard code route)
|
||||||
func (h *KubernetesHandler) ScaleStatefulSetNS(c *gin.Context) {
|
func (h *KubernetesHandler) ScaleStatefulSetNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
var req struct{ Replicas int `json:"replicas"` }
|
var req struct {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
|
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 {
|
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)})
|
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)
|
// ScaleStatefulSet scales a StatefulSet (ID route)
|
||||||
func (h *KubernetesHandler) ScaleStatefulSet(c *gin.Context) {
|
func (h *KubernetesHandler) ScaleStatefulSet(c *gin.Context) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||||
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
|
if clusterID == 0 {
|
||||||
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
var req struct{ Replicas int `json:"replicas"` }
|
var req struct {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
|
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 {
|
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)})
|
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)
|
// RestartStatefulSetNS restarts a StatefulSet (Kuboard code route)
|
||||||
func (h *KubernetesHandler) RestartStatefulSetNS(c *gin.Context) {
|
func (h *KubernetesHandler) RestartStatefulSetNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
if err := client.RestartStatefulSet(ns, name); err != nil {
|
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 滚动重启已触发"})
|
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)
|
// RestartStatefulSet restarts a StatefulSet (ID route)
|
||||||
func (h *KubernetesHandler) RestartStatefulSet(c *gin.Context) {
|
func (h *KubernetesHandler) RestartStatefulSet(c *gin.Context) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||||
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
|
if clusterID == 0 {
|
||||||
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
if err := client.RestartStatefulSet(ns, name); err != nil {
|
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 滚动重启已触发"})
|
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)
|
// RestartDaemonSetNS restarts a DaemonSet (Kuboard code route)
|
||||||
func (h *KubernetesHandler) RestartDaemonSetNS(c *gin.Context) {
|
func (h *KubernetesHandler) RestartDaemonSetNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
if err := client.RestartDaemonSet(ns, name); err != nil {
|
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 滚动重启已触发"})
|
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)
|
// RestartDaemonSet restarts a DaemonSet (ID route)
|
||||||
func (h *KubernetesHandler) RestartDaemonSet(c *gin.Context) {
|
func (h *KubernetesHandler) RestartDaemonSet(c *gin.Context) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||||
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
|
if clusterID == 0 {
|
||||||
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
if err := client.RestartDaemonSet(ns, name); err != nil {
|
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 滚动重启已触发"})
|
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)
|
// GetStatefulSetDetailNS returns a single StatefulSet detail (Kuboard code route)
|
||||||
func (h *KubernetesHandler) GetStatefulSetDetailNS(c *gin.Context) {
|
func (h *KubernetesHandler) GetStatefulSetDetailNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
sts, err := client.GetStatefulSet(ns, 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})
|
c.JSON(200, model.Response{Code: 200, Data: sts})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDaemonSetDetailNS returns a single DaemonSet detail (Kuboard code route)
|
// GetDaemonSetDetailNS returns a single DaemonSet detail (Kuboard code route)
|
||||||
func (h *KubernetesHandler) GetDaemonSetDetailNS(c *gin.Context) {
|
func (h *KubernetesHandler) GetDaemonSetDetailNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
ds, err := client.GetDaemonSet(ns, 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})
|
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) {
|
func (h *KubernetesHandler) listReplicaSetsWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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)
|
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)
|
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 {
|
type RSSummary struct {
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -564,11 +771,16 @@ func (h *KubernetesHandler) listReplicaSetsWithNS(c *gin.Context, ns string) {
|
|||||||
result := []RSSummary{}
|
result := []RSSummary{}
|
||||||
for _, rs := range items {
|
for _, rs := range items {
|
||||||
image := ""
|
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"]
|
revision := rs.Metadata.Annotations["deployment.kubernetes.io/revision"]
|
||||||
owner := ""
|
owner := ""
|
||||||
for _, o := range rs.Metadata.OwnerReferences {
|
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{
|
result = append(result, RSSummary{
|
||||||
Namespace: rs.Metadata.Namespace,
|
Namespace: rs.Metadata.Namespace,
|
||||||
@@ -591,13 +803,24 @@ func (h *KubernetesHandler) ListDeploymentRevisionsNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
func (h *KubernetesHandler) listDeploymentRevisionsWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) listDeploymentRevisionsWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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")
|
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)
|
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)
|
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 {
|
type Revision struct {
|
||||||
Revision string `json:"revision"`
|
Revision string `json:"revision"`
|
||||||
ReplicaSet string `json:"replica_set"`
|
ReplicaSet string `json:"replica_set"`
|
||||||
@@ -610,11 +833,18 @@ func (h *KubernetesHandler) listDeploymentRevisionsWithNS(c *gin.Context, ns str
|
|||||||
for _, rs := range items {
|
for _, rs := range items {
|
||||||
isOwner := false
|
isOwner := false
|
||||||
for _, o := range rs.Metadata.OwnerReferences {
|
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 := ""
|
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{
|
result = append(result, Revision{
|
||||||
Revision: rs.Metadata.Annotations["deployment.kubernetes.io/revision"],
|
Revision: rs.Metadata.Annotations["deployment.kubernetes.io/revision"],
|
||||||
ReplicaSet: rs.Metadata.Name,
|
ReplicaSet: rs.Metadata.Name,
|
||||||
@@ -639,28 +869,44 @@ func (h *KubernetesHandler) RollbackDeploymentNS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
func (h *KubernetesHandler) rollbackDeploymentWithNS(c *gin.Context, ns string) {
|
func (h *KubernetesHandler) rollbackDeploymentWithNS(c *gin.Context, ns string) {
|
||||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
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")
|
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 {
|
var req struct {
|
||||||
Revision int `json:"revision"`
|
Revision int `json:"revision"`
|
||||||
ReplicaSet string `json:"replicaSet"`
|
ReplicaSet string `json:"replicaSet"`
|
||||||
}
|
}
|
||||||
_ = c.ShouldBindJSON(&req) // 允许空 body(但下面会校验)
|
_ = c.ShouldBindJSON(&req) // 允许空 body(但下面会校验)
|
||||||
client, _, err := h.getK8sClient(clusterID)
|
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
|
targetRS := req.ReplicaSet
|
||||||
// 通过 revision 定位 ReplicaSet
|
// 通过 revision 定位 ReplicaSet
|
||||||
if targetRS == "" && req.Revision > 0 {
|
if targetRS == "" && req.Revision > 0 {
|
||||||
items, err := client.ListReplicaSetsRaw(ns)
|
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 {
|
for _, rs := range items {
|
||||||
isOwner := false
|
isOwner := false
|
||||||
for _, o := range rs.Metadata.OwnerReferences {
|
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) {
|
if rs.Metadata.Annotations["deployment.kubernetes.io/revision"] == strconv.Itoa(req.Revision) {
|
||||||
targetRS = rs.Metadata.Name
|
targetRS = rs.Metadata.Name
|
||||||
break
|
break
|
||||||
@@ -671,18 +917,31 @@ func (h *KubernetesHandler) rollbackDeploymentWithNS(c *gin.Context, ns string)
|
|||||||
return
|
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 实现回滚
|
// 取 ReplicaSet 的 template,patch 到 Deployment.spec.template 实现回滚
|
||||||
rsJSON, err := client.GetReplicaSet(ns, targetRS)
|
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{})
|
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{})
|
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 {
|
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{
|
c.JSON(200, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
|
|||||||
@@ -12,9 +12,15 @@ import (
|
|||||||
|
|
||||||
func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) {
|
func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
resource := c.Param("resource")
|
resource := c.Param("resource")
|
||||||
// Fallback: specific routes (statefulsets/:name/yaml, daemonsets/:name/yaml) have no :resource param
|
// 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":
|
case "rolebindings", "rolebinding":
|
||||||
apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name)
|
apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name)
|
||||||
default:
|
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)
|
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.Header("Content-Type", "application/yaml")
|
||||||
c.String(200, string(yamlBytes))
|
c.String(200, string(yamlBytes))
|
||||||
}
|
}
|
||||||
@@ -73,9 +83,15 @@ func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) {
|
|||||||
// UpdateResourceYAMLNS applies YAML to update a K8s resource
|
// UpdateResourceYAMLNS applies YAML to update a K8s resource
|
||||||
func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) {
|
func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
resource := c.Param("resource")
|
resource := c.Param("resource")
|
||||||
// Fallback: specific routes (statefulsets/:name/yaml, daemonsets/:name/yaml) have no :resource param
|
// 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")
|
name := c.Param("name")
|
||||||
yamlData, err := io.ReadAll(c.Request.Body)
|
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
|
var apiPath string
|
||||||
switch resource {
|
switch resource {
|
||||||
case "deployments", "deployment":
|
case "deployments", "deployment":
|
||||||
@@ -124,10 +143,12 @@ func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) {
|
|||||||
case "rolebindings", "rolebinding":
|
case "rolebindings", "rolebinding":
|
||||||
apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name)
|
apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name)
|
||||||
default:
|
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 {
|
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已应用"})
|
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.)
|
// DeleteResourceYAMLNS deletes a K8s resource (Service/Ingress/ConfigMap etc.)
|
||||||
func (h *KubernetesHandler) DeleteResourceYAMLNS(c *gin.Context) {
|
func (h *KubernetesHandler) DeleteResourceYAMLNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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")
|
ns := c.Param("namespace")
|
||||||
resource := c.Param("resource")
|
resource := c.Param("resource")
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
apiPath, err := resourceAPIPath(ns, resource, 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 {
|
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: "资源已删除"})
|
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
|
// getK8sClient builds a K8s API client from cluster's stored kubeconfig
|
||||||
func (h *KubernetesHandler) ApplyYAML(c *gin.Context) {
|
func (h *KubernetesHandler) ApplyYAML(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
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)
|
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 {
|
var req struct {
|
||||||
YAML string `json:"yaml"`
|
YAML string `json:"yaml"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误: " + err.Error()}); return }
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
if req.YAML == "" { c.JSON(400, model.Response{Code: 400, Message: "YAML 内容不能为空"}); return }
|
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)
|
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})
|
c.JSON(200, model.Response{Code: 200, Message: "资源已应用", Data: result})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,20 +34,10 @@ func maskAPIKey(key string) string {
|
|||||||
return strings.Repeat("*", len(key)-4) + key[len(key)-4:]
|
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
|
// getActiveConfig 读取启用的配置(脱敏后) // Logan.Li
|
||||||
func (h *LLMHandler) getActiveConfig(maskKey bool) (model.LLMConfig, error) {
|
func (h *LLMHandler) getActiveConfig(maskKey bool) (model.LLMConfig, error) {
|
||||||
var cfg model.LLMConfig
|
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
|
return cfg, err
|
||||||
}
|
}
|
||||||
if maskKey {
|
if maskKey {
|
||||||
@@ -93,13 +83,9 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var cfg model.LLMConfig
|
var cfg model.LLMConfig
|
||||||
h.db.Take(&cfg)
|
h.db.First(&cfg)
|
||||||
// 脱敏密钥只能在提供商和 Endpoint 均未改变时复用,避免凭证被转发到其他地址。 // Logan.Li
|
// 若前端回传脱敏值(含 ****),保留原 Key
|
||||||
if strings.Contains(req.APIKey, "****") {
|
if strings.Contains(req.APIKey, "****") && cfg.ID != 0 {
|
||||||
if !canReuseMaskedAPIKey(req.Provider, req.Endpoint, cfg) {
|
|
||||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "提供商或 Endpoint 已变化,请重新填写 API Key"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.APIKey = cfg.APIKey
|
req.APIKey = cfg.APIKey
|
||||||
}
|
}
|
||||||
cfg.Provider = req.Provider
|
cfg.Provider = req.Provider
|
||||||
@@ -151,18 +137,12 @@ func resolveEndpoint(provider, endpoint string) string {
|
|||||||
return "https://open.bigmodel.cn/api/paas/v4"
|
return "https://open.bigmodel.cn/api/paas/v4"
|
||||||
case "qwen":
|
case "qwen":
|
||||||
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
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":
|
case "moonshot":
|
||||||
return "https://api.moonshot.cn/v1"
|
return "https://api.moonshot.cn/v1"
|
||||||
case "kimi": // Kimi 编码套餐 OpenAI 兼容端点(注意:该端点有客户端白名单,通用调用可能被拒) // Logan.Li
|
case "kimi": // Kimi 编码套餐 OpenAI 兼容端点(注意:该端点有客户端白名单,通用调用可能被拒) // Logan.Li
|
||||||
return "https://api.kimi.com/coding/v1"
|
return "https://api.kimi.com/coding/v1"
|
||||||
case "mimo": // 小米 MiMo OpenAI 兼容端点 // Logan.Li
|
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
|
case "anthropic": // Anthropic 官方 Messages 协议(anthropic.go 客户端拼 /v1/messages) // Logan.Li
|
||||||
return "https://api.anthropic.com"
|
return "https://api.anthropic.com"
|
||||||
case "glm-coding": // 智谱 GLM 编码套餐 Anthropic 协议端点(模型 GLM-5.2) // Logan.Li
|
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"
|
return "https://api.minimaxi.com/anthropic"
|
||||||
case "openai": // OpenAI 官方端点 // Logan.Li
|
case "openai": // OpenAI 官方端点 // Logan.Li
|
||||||
return "https://api.openai.com/v1"
|
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
|
case "siliconflow": // 硅基流动 OpenAI 兼容端点 // Logan.Li
|
||||||
return "https://api.siliconflow.cn/v1"
|
return "https://api.siliconflow.cn/v1"
|
||||||
case "ollama":
|
case "ollama":
|
||||||
return "http://localhost:11434/v1"
|
return "http://localhost:11434/v1"
|
||||||
case "vllm": // vLLM OpenAI 兼容端点 // Logan.Li
|
case "vllm": // vLLM OpenAI 兼容端点 // Logan.Li
|
||||||
return "http://localhost:8000/v1"
|
return "http://localhost:8000/v1"
|
||||||
case "lmstudio": // LM Studio OpenAI 兼容端点 // Logan.Li
|
default: // openai / custom
|
||||||
return "http://localhost:1234/v1"
|
|
||||||
case "new-api", "custom": // 网关地址必须由用户明确填写 // Logan.Li
|
|
||||||
return ""
|
|
||||||
default: // 未知的历史供应商保持 OpenAI 默认兼容 // Logan.Li
|
|
||||||
return "https://api.openai.com/v1"
|
return "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isLocalLLMProvider 本地/自建模型,API Key 可选 // Logan.Li
|
// isLocalLLMProvider 本地/自建模型,API Key 可选 // Logan.Li
|
||||||
func isLocalLLMProvider(provider string) bool {
|
func isLocalLLMProvider(provider string) bool {
|
||||||
return provider == "ollama" || provider == "vllm" || provider == "lmstudio" || provider == "custom"
|
return provider == "ollama" || provider == "vllm" || provider == "custom"
|
||||||
}
|
|
||||||
|
|
||||||
// requiresExplicitEndpoint 判断是否必须手动填写网关地址。 // Logan.Li
|
|
||||||
func requiresExplicitEndpoint(provider string) bool {
|
|
||||||
return provider == "new-api" || provider == "custom"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveProtocol 根据提供商推断接入协议(决定走 OpenAI 还是 Anthropic 客户端) // Logan.Li
|
// resolveProtocol 根据提供商推断接入协议(决定走 OpenAI 还是 Anthropic 客户端) // Logan.Li
|
||||||
|
|||||||
@@ -195,11 +195,10 @@ func (h *LLMHandler) llmConfigFromTestRequest(req model.SaveLLMConfigRequest) (m
|
|||||||
}
|
}
|
||||||
if strings.Contains(req.APIKey, "****") {
|
if strings.Contains(req.APIKey, "****") {
|
||||||
var saved model.LLMConfig
|
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) {
|
if !isLocalLLMProvider(req.Provider) {
|
||||||
return cfg, "提供商或 Endpoint 已变化,请重新填写 API Key"
|
return cfg, "请填写 API Key"
|
||||||
}
|
}
|
||||||
cfg.APIKey = ""
|
|
||||||
} else {
|
} else {
|
||||||
cfg.APIKey = saved.APIKey
|
cfg.APIKey = saved.APIKey
|
||||||
}
|
}
|
||||||
@@ -210,9 +209,6 @@ func (h *LLMHandler) llmConfigFromTestRequest(req model.SaveLLMConfigRequest) (m
|
|||||||
if cfg.Model == "" {
|
if cfg.Model == "" {
|
||||||
return cfg, "请填写模型名"
|
return cfg, "请填写模型名"
|
||||||
}
|
}
|
||||||
if requiresExplicitEndpoint(cfg.Provider) && strings.TrimSpace(cfg.Endpoint) == "" {
|
|
||||||
return cfg, "请填写 Endpoint"
|
|
||||||
}
|
|
||||||
if cfg.APIKey == "" && !isLocalLLMProvider(cfg.Provider) {
|
if cfg.APIKey == "" && !isLocalLLMProvider(cfg.Provider) {
|
||||||
return cfg, "请填写 API Key"
|
return cfg, "请填写 API Key"
|
||||||
}
|
}
|
||||||
@@ -230,7 +226,7 @@ func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
|
|||||||
if req.Provider != "" {
|
if req.Provider != "" {
|
||||||
cfg, errMsg = h.llmConfigFromTestRequest(req)
|
cfg, errMsg = h.llmConfigFromTestRequest(req)
|
||||||
} else {
|
} 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: "请先填写配置并测试连接"})
|
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先填写配置并测试连接"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -74,10 +74,10 @@ func (h *LogHandler) CreateSource(c *gin.Context) {
|
|||||||
configJSON, _ := json.Marshal(req.Config)
|
configJSON, _ := json.Marshal(req.Config)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
item := model.LogSource{
|
item := model.LogSource{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
Config: string(configJSON),
|
Config: string(configJSON),
|
||||||
LastSeen: &now,
|
LastSeen: &now,
|
||||||
}
|
}
|
||||||
h.db.Create(&item)
|
h.db.Create(&item)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func (h *LogDSHandler) Create(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
ds := model.LogDataSource{
|
ds := model.LogDataSource{
|
||||||
Name: req.Name, Type: req.Type, URL: strings.TrimRight(req.URL, "/"),
|
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,
|
BasicAuth: req.BasicAuth, BasicAuthUser: req.BasicAuthUser, BasicAuthPass: req.BasicAuthPass,
|
||||||
Token: req.Token, Headers: req.Headers,
|
Token: req.Token, Headers: req.Headers,
|
||||||
SkipTLSVerify: req.SkipTLSVerify, DefaultIndex: req.DefaultIndex,
|
SkipTLSVerify: req.SkipTLSVerify, DefaultIndex: req.DefaultIndex,
|
||||||
|
|||||||
@@ -134,15 +134,15 @@ func (h *MetricsHandler) TopHosts(c *gin.Context) {
|
|||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "5"))
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "5"))
|
||||||
|
|
||||||
type HostRank struct {
|
type HostRank struct {
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
CPUUsage float64 `json:"cpu_usage"`
|
CPUUsage float64 `json:"cpu_usage"`
|
||||||
MemUsage float64 `json:"memory_usage"`
|
MemUsage float64 `json:"memory_usage"`
|
||||||
DiskUsage float64 `json:"disk_usage"`
|
DiskUsage float64 `json:"disk_usage"`
|
||||||
CPUCores int `json:"cpu_cores"`
|
CPUCores int `json:"cpu_cores"`
|
||||||
MemMB int64 `json:"memory_mb"`
|
MemMB int64 `json:"memory_mb"`
|
||||||
DiskGB int64 `json:"disk_gb"`
|
DiskGB int64 `json:"disk_gb"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var servers []model.Server
|
var servers []model.Server
|
||||||
@@ -166,15 +166,15 @@ func (h *MetricsHandler) TopHosts(c *gin.Context) {
|
|||||||
result := make([]HostRank, len(servers))
|
result := make([]HostRank, len(servers))
|
||||||
for i, s := range servers {
|
for i, s := range servers {
|
||||||
result[i] = HostRank{
|
result[i] = HostRank{
|
||||||
Hostname: s.Hostname,
|
Hostname: s.Hostname,
|
||||||
IP: s.IP,
|
IP: s.IP,
|
||||||
CPUUsage: s.CPUUsage,
|
CPUUsage: s.CPUUsage,
|
||||||
MemUsage: s.MemoryUsage,
|
MemUsage: s.MemoryUsage,
|
||||||
DiskUsage: s.DiskUsage,
|
DiskUsage: s.DiskUsage,
|
||||||
CPUCores: s.CPU,
|
CPUCores: s.CPU,
|
||||||
MemMB: s.Memory,
|
MemMB: s.Memory,
|
||||||
DiskGB: s.Disk,
|
DiskGB: s.Disk,
|
||||||
Status: s.Status,
|
Status: s.Status,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
// MetricsUpdate 单个主机的指标更新 // Logan.Li
|
// MetricsUpdate 单个主机的指标更新 // Logan.Li
|
||||||
type MetricsUpdate struct {
|
type MetricsUpdate struct {
|
||||||
Type string `json:"type"` // "metrics_update"
|
Type string `json:"type"` // "metrics_update"
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
CPU float64 `json:"cpu_usage"`
|
CPU float64 `json:"cpu_usage"`
|
||||||
Memory float64 `json:"memory_usage"`
|
Memory float64 `json:"memory_usage"`
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
"ezops/model"
|
"ezops/model"
|
||||||
"ezops/pkg/systask"
|
"ezops/pkg/systask"
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,8 +28,8 @@ import (
|
|||||||
// MiddlewareConfig 存储在 MonitorTarget.Config JSON 字段中
|
// MiddlewareConfig 存储在 MonitorTarget.Config JSON 字段中
|
||||||
type MiddlewareConfig struct {
|
type MiddlewareConfig struct {
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
Username string `json:"username,omitempty"` // MySQL 专用
|
Username string `json:"username,omitempty"` // MySQL 专用
|
||||||
Database string `json:"database,omitempty"` // MySQL 专用
|
Database string `json:"database,omitempty"` // MySQL 专用
|
||||||
RedisMode string `json:"redis_mode,omitempty"` // Redis: standalone / cluster
|
RedisMode string `json:"redis_mode,omitempty"` // Redis: standalone / cluster
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import (
|
|||||||
|
|
||||||
// middlewareSeedResult 种子创建结果 // Logan.Li
|
// middlewareSeedResult 种子创建结果 // Logan.Li
|
||||||
type middlewareSeedResult struct {
|
type middlewareSeedResult struct {
|
||||||
Created int `json:"created"`
|
Created int `json:"created"`
|
||||||
Skipped []string `json:"skipped"`
|
Skipped []string `json:"skipped"`
|
||||||
Failed []string `json:"failed"`
|
Failed []string `json:"failed"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedDefaultMiddlewareTargets 首次启动时创建平台默认 Redis/MySQL 监控目标 // Logan.Li
|
// SeedDefaultMiddlewareTargets 首次启动时创建平台默认 Redis/MySQL 监控目标 // Logan.Li
|
||||||
|
|||||||
+143
-64
@@ -6,8 +6,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ezops/model"
|
|
||||||
"ezops/middleware"
|
"ezops/middleware"
|
||||||
|
"ezops/model"
|
||||||
|
"ezops/pkg/crypto"
|
||||||
ezredis "ezops/pkg/redis"
|
ezredis "ezops/pkg/redis"
|
||||||
"ezops/pkg/toolinstaller"
|
"ezops/pkg/toolinstaller"
|
||||||
|
|
||||||
@@ -57,11 +58,12 @@ type PipelineHandler struct {
|
|||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
cache ezredis.Cache
|
cache ezredis.Cache
|
||||||
toolInstaller *toolinstaller.ToolInstaller
|
toolInstaller *toolinstaller.ToolInstaller
|
||||||
|
crypt *crypto.SecretBox
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPipelineHandler 创建流水线处理器
|
// NewPipelineHandler 创建流水线处理器
|
||||||
func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache, ti *toolinstaller.ToolInstaller) *PipelineHandler {
|
func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache, ti *toolinstaller.ToolInstaller, masterKey string) *PipelineHandler {
|
||||||
h := &PipelineHandler{db: db, cache: cache, toolInstaller: ti}
|
h := &PipelineHandler{db: db, cache: cache, toolInstaller: ti, crypt: crypto.NewSecretBox(masterKey)}
|
||||||
h.ensureBuildMasterConfig()
|
h.ensureBuildMasterConfig()
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
@@ -69,7 +71,7 @@ func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache, ti *toolinstaller.Tool
|
|||||||
// PipelineListItem 列表项(含最近一次运行摘要)// Logan.Li
|
// PipelineListItem 列表项(含最近一次运行摘要)// Logan.Li
|
||||||
type PipelineListItem struct {
|
type PipelineListItem struct {
|
||||||
model.Pipeline
|
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"`
|
LastImageTag string `json:"last_image_tag,omitempty"`
|
||||||
LastCommitID string `json:"last_commit_id,omitempty"`
|
LastCommitID string `json:"last_commit_id,omitempty"`
|
||||||
LastRunDuration int `json:"last_duration,omitempty"`
|
LastRunDuration int `json:"last_duration,omitempty"`
|
||||||
@@ -112,7 +114,11 @@ func (h *PipelineHandler) ListPipelines(c *gin.Context) {
|
|||||||
db.Count(&total)
|
db.Count(&total)
|
||||||
|
|
||||||
var pipelines []model.Pipeline
|
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
|
// 批量获取每个流水线最近一次运行的摘要(阶段状态 + 镜像 + 耗时)// Logan.Li
|
||||||
items := make([]PipelineListItem, len(pipelines))
|
items := make([]PipelineListItem, len(pipelines))
|
||||||
@@ -123,7 +129,7 @@ func (h *PipelineHandler) ListPipelines(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// 子查询:每个 pipeline_id 的最新 run id
|
// 子查询:每个 pipeline_id 的最新 run id
|
||||||
var latestRuns []model.PipelineRun
|
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)
|
Find(&latestRuns)
|
||||||
runMap := map[uint]*model.PipelineRun{}
|
runMap := map[uint]*model.PipelineRun{}
|
||||||
for i := range latestRuns {
|
for i := range latestRuns {
|
||||||
@@ -147,8 +153,8 @@ func (h *PipelineHandler) ListPipelines(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.Response{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Data: model.ListResponse{List: items, Total: total},
|
Data: model.ListResponse{List: items, Total: total},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,30 +217,29 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pipeline := model.Pipeline{
|
pipeline := model.Pipeline{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Code: req.Code,
|
Code: req.Code,
|
||||||
JobName: req.JobName,
|
JobName: req.JobName,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Source: req.Source,
|
CIProvider: normalizeCIProvider(req.CIProvider),
|
||||||
Branch: req.Branch,
|
Source: req.Source,
|
||||||
Dockerfile: req.Dockerfile,
|
Branch: req.Branch,
|
||||||
Stages: req.Stages,
|
Dockerfile: req.Dockerfile,
|
||||||
Variables: req.Variables,
|
Stages: req.Stages,
|
||||||
DeployMode: req.DeployMode,
|
Variables: req.Variables,
|
||||||
DeployTarget: req.DeployTarget,
|
DeployMode: req.DeployMode,
|
||||||
ServerIDs: req.ServerIDs,
|
DeployTarget: req.DeployTarget,
|
||||||
DeployRemotePath: req.DeployRemotePath,
|
ServerIDs: req.ServerIDs,
|
||||||
DeployScript: req.DeployScript,
|
DeployRemotePath: req.DeployRemotePath,
|
||||||
K8sClusterID: req.K8sClusterID,
|
DeployScript: req.DeployScript,
|
||||||
K8sNamespace: req.K8sNamespace,
|
K8sClusterID: req.K8sClusterID,
|
||||||
K8sDeployment: req.K8sDeployment,
|
K8sNamespace: req.K8sNamespace,
|
||||||
K8sContainerName: req.K8sContainerName,
|
K8sDeployment: req.K8sDeployment,
|
||||||
RepoID: req.RepoID,
|
K8sContainerName: req.K8sContainerName,
|
||||||
RegistryID: req.RegistryID,
|
|
||||||
GitCredentialID: req.GitCredentialID,
|
GitCredentialID: req.GitCredentialID,
|
||||||
RegistryCredentialID: req.RegistryCredentialID,
|
RegistryCredentialID: req.RegistryCredentialID,
|
||||||
EnvironmentID: req.EnvironmentID,
|
EnvironmentID: req.EnvironmentID,
|
||||||
// 触发配置
|
// 触发配置
|
||||||
TriggerMode: req.TriggerMode,
|
TriggerMode: req.TriggerMode,
|
||||||
WatchBranches: req.WatchBranches,
|
WatchBranches: req.WatchBranches,
|
||||||
@@ -242,11 +247,13 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) {
|
|||||||
CronExpr: req.CronExpr,
|
CronExpr: req.CronExpr,
|
||||||
WebhookToken: req.WebhookToken,
|
WebhookToken: req.WebhookToken,
|
||||||
// 缓存
|
// 缓存
|
||||||
CachePaths: req.CachePaths,
|
CachePaths: req.CachePaths,
|
||||||
|
JenkinsJobName: req.JenkinsJobName,
|
||||||
|
JenkinsJobURL: req.JenkinsJobURL,
|
||||||
// 并发控制
|
// 并发控制
|
||||||
MaxExecutors: req.MaxExecutors,
|
MaxExecutors: req.MaxExecutors,
|
||||||
BuildNodeID: req.BuildNodeID,
|
BuildNodeID: req.BuildNodeID,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.db.Create(&pipeline).Error; err != nil {
|
if err := h.db.Create(&pipeline).Error; err != nil {
|
||||||
@@ -301,38 +308,105 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
if req.Name != "" { updates["name"] = req.Name }
|
if req.Name != "" {
|
||||||
if req.JobName != "" { updates["job_name"] = req.JobName }
|
updates["name"] = req.Name
|
||||||
if req.Description != "" { updates["description"] = req.Description }
|
}
|
||||||
if req.Type != "" { updates["type"] = req.Type }
|
if req.JobName != "" {
|
||||||
if req.Source != "" { updates["source"] = req.Source }
|
updates["job_name"] = req.JobName
|
||||||
if req.Branch != "" { updates["branch"] = req.Branch }
|
}
|
||||||
if req.Dockerfile != "" { updates["dockerfile"] = req.Dockerfile }
|
if req.Description != "" {
|
||||||
if req.Stages != "" { updates["stages"] = req.Stages }
|
updates["description"] = req.Description
|
||||||
if req.Variables != "" { updates["variables"] = req.Variables }
|
}
|
||||||
if req.DeployMode != "" { updates["deploy_mode"] = req.DeployMode }
|
if req.Type != "" {
|
||||||
if req.DeployTarget != "" { updates["deploy_target"] = req.DeployTarget }
|
updates["type"] = req.Type
|
||||||
if req.ServerIDs != "" { updates["server_ids"] = req.ServerIDs }
|
}
|
||||||
if req.DeployRemotePath != "" { updates["deploy_remote_path"] = req.DeployRemotePath }
|
if req.CIProvider != "" {
|
||||||
if req.DeployScript != "" { updates["deploy_script"] = req.DeployScript }
|
updates["ci_provider"] = normalizeCIProvider(req.CIProvider)
|
||||||
if req.K8sNamespace != "" { updates["k8s_namespace"] = req.K8sNamespace }
|
}
|
||||||
if req.K8sDeployment != "" { updates["k8s_deployment"] = req.K8sDeployment }
|
if req.Source != "" {
|
||||||
if req.K8sContainerName != "" { updates["k8s_container_name"] = req.K8sContainerName }
|
updates["source"] = req.Source
|
||||||
if req.K8sClusterID != nil { updates["k8s_cluster_id"] = req.K8sClusterID }
|
}
|
||||||
if req.RepoID != nil { updates["repo_id"] = req.RepoID }
|
if req.Branch != "" {
|
||||||
if req.RegistryID != nil { updates["registry_id"] = req.RegistryID }
|
updates["branch"] = req.Branch
|
||||||
if req.GitCredentialID != nil { updates["git_credential_id"] = req.GitCredentialID }
|
}
|
||||||
if req.RegistryCredentialID != nil { updates["registry_credential_id"] = req.RegistryCredentialID }
|
if req.Dockerfile != "" {
|
||||||
if req.EnvironmentID != nil { updates["environment_id"] = req.EnvironmentID }
|
updates["dockerfile"] = req.Dockerfile
|
||||||
if req.TriggerMode != "" { updates["trigger_mode"] = req.TriggerMode }
|
}
|
||||||
if req.WatchBranches != "" { updates["watch_branches"] = req.WatchBranches }
|
if req.Stages != "" {
|
||||||
if req.ExcludeBranch != "" { updates["exclude_branch"] = req.ExcludeBranch }
|
updates["stages"] = req.Stages
|
||||||
if req.CronExpr != "" { updates["cron_expr"] = req.CronExpr }
|
}
|
||||||
if req.WebhookToken != "" { updates["webhook_token"] = req.WebhookToken }
|
if req.Variables != "" {
|
||||||
if req.CachePaths != "" { updates["cache_paths"] = req.CachePaths }
|
updates["variables"] = req.Variables
|
||||||
if req.Enabled != nil { updates["enabled"] = req.Enabled }
|
}
|
||||||
if req.MaxExecutors > 0 { updates["max_executors"] = req.MaxExecutors }
|
if req.DeployMode != "" {
|
||||||
if req.BuildNodeID != nil { updates["build_node_id"] = req.BuildNodeID }
|
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 {
|
if err := h.db.Model(&pipeline).Updates(updates).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, model.Response{
|
c.JSON(http.StatusInternalServerError, model.Response{
|
||||||
@@ -404,6 +478,11 @@ func (h *PipelineHandler) TriggerPipeline(c *gin.Context) {
|
|||||||
var req model.TriggerPipelineRequest
|
var req model.TriggerPipelineRequest
|
||||||
c.ShouldBindJSON(&req)
|
c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
if pipeline.CIProvider == "jenkins" {
|
||||||
|
h.triggerJenkinsPipeline(c, pipeline, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var maxBuildNumber int
|
var maxBuildNumber int
|
||||||
h.db.Model(&model.PipelineRun{}).Where("pipeline_id = ?", id).
|
h.db.Model(&model.PipelineRun{}).Where("pipeline_id = ?", id).
|
||||||
Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber)
|
Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber)
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ func (h *PipelineHandler) executePipeline(pipeline model.Pipeline, run model.Pip
|
|||||||
}
|
}
|
||||||
|
|
||||||
runUpdates := map[string]interface{}{
|
runUpdates := map[string]interface{}{
|
||||||
"workspace_path": workspace,
|
"workspace_path": workspace,
|
||||||
"build_node_name": execCtx.NodeName,
|
"build_node_name": execCtx.NodeName,
|
||||||
}
|
}
|
||||||
if nodeID > 0 {
|
if nodeID > 0 {
|
||||||
@@ -545,13 +545,10 @@ func (h *PipelineHandler) runDeploy(workspace string, pipeline model.Pipeline, t
|
|||||||
// resolvePipelineImage 解析流水线构建镜像全名(与镜像打包阶段一致) // Logan.Li
|
// resolvePipelineImage 解析流水线构建镜像全名(与镜像打包阶段一致) // Logan.Li
|
||||||
func (h *PipelineHandler) resolvePipelineImage(pipeline model.Pipeline, tag string) string {
|
func (h *PipelineHandler) resolvePipelineImage(pipeline model.Pipeline, tag string) string {
|
||||||
var registryURL, imageName string
|
var registryURL, imageName string
|
||||||
if pipeline.RegistryID != nil {
|
if pipeline.RegistryCredentialID != nil {
|
||||||
var reg model.PipelineRegistry
|
var credential model.Credential
|
||||||
if err := h.db.First(®, *pipeline.RegistryID).Error; err == nil {
|
if err := h.db.Take(&credential, *pipeline.RegistryCredentialID).Error; err == nil {
|
||||||
registryURL = reg.RegistryURL
|
registryURL = credential.Summary
|
||||||
if reg.Namespace != "" {
|
|
||||||
imageName = reg.Namespace + "/"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
appName := pipeline.Code
|
appName := pipeline.Code
|
||||||
@@ -838,7 +835,7 @@ func cleanOldWorkspaces(baseDir string, maxAge time.Duration) {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
pipelineRunningCount = make(map[uint]int) // pipelineID → 当前运行数
|
pipelineRunningCount = make(map[uint]int) // pipelineID → 当前运行数
|
||||||
pipelineQueues = make(map[uint][]uint) // pipelineID → 排队 runID 列表
|
pipelineQueues = make(map[uint][]uint) // pipelineID → 排队 runID 列表
|
||||||
queueMu sync.Mutex
|
queueMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -909,9 +906,13 @@ func (h *PipelineHandler) dequeueNext(pipelineID uint) {
|
|||||||
|
|
||||||
// 加载 pipeline + run
|
// 加载 pipeline + run
|
||||||
var pipeline model.Pipeline
|
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
|
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)
|
nodeID, canNode := h.tryAcquireBuildNode(pipeline)
|
||||||
if !canNode {
|
if !canNode {
|
||||||
@@ -935,8 +936,8 @@ func (h *PipelineHandler) GetQueueStatus(pipelineID uint) map[string]interface{}
|
|||||||
running := pipelineRunningCount[pipelineID]
|
running := pipelineRunningCount[pipelineID]
|
||||||
queued := pipelineQueues[pipelineID]
|
queued := pipelineQueues[pipelineID]
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"running": running,
|
"running": running,
|
||||||
"queued": len(queued),
|
"queued": len(queued),
|
||||||
"queue_ids": queued,
|
"queue_ids": queued,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -266,9 +266,7 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
|||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
updates["error"] = errMsg
|
updates["error"] = errMsg
|
||||||
}
|
}
|
||||||
if err := h.db.Model(&model.RDPSession{}).Where("id = ?", session.ID).Updates(updates).Error; err != nil {
|
_ = h.db.Model(&model.RDPSession{}).Where("id = ?", session.ID).Updates(updates).Error
|
||||||
log.Printf("[RDP] 更新会话结束状态失败 (session_id=%d, status=%s): %v", session.ID, status, err)
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
audit := newRDPOperationRecorder(h.db, session, userIDPtr, username)
|
audit := newRDPOperationRecorder(h.db, session, userIDPtr, username)
|
||||||
@@ -288,6 +286,9 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
modifiers.Apply(&event)
|
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)
|
audit.Record(event)
|
||||||
enqueueRDPEvent(events, event)
|
enqueueRDPEvent(events, event)
|
||||||
}
|
}
|
||||||
@@ -530,9 +531,7 @@ func (r *rdpOperationRecorder) create(command string) {
|
|||||||
Command: command,
|
Command: command,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
}
|
}
|
||||||
if err := r.db.Create(&cmd).Error; err != nil {
|
_ = r.db.Create(&cmd).Error
|
||||||
log.Printf("[RDPAudit] 记录 RDP 操作命令失败 (session_id=%d, user=%s, cmd=%q): %v", r.session.ID, r.username, command, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RDPHandler) loadRDPCredential(server model.Server) (model.RDPSecretContent, 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 {
|
func writeRDPFrame(mgr *wshub.WSManager, conn *websocket.Conn, frame rdpengine.ServerFrame) error {
|
||||||
mgr.SetWriteDeadline(conn)
|
mgr.SetWriteDeadline(conn)
|
||||||
if frame.Type == "bitmap" && len(frame.RGBA) > 0 {
|
if frame.Type == "bitmap" && len(frame.RGBA) > 0 {
|
||||||
return conn.WriteMessage(websocket.BinaryMessage, encodeRDPBitmapBinary(frame))
|
return writeCompressedRDPBitmap(conn, frame)
|
||||||
}
|
}
|
||||||
return conn.WriteJSON(frame)
|
return conn.WriteJSON(frame)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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("短位图数据应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -128,9 +127,7 @@ func (h *RDPSessionHandler) KillRDPSession(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
duration := int(now.Sub(session.StartTime).Seconds())
|
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 {
|
_ = h.db.Model(&session).Updates(map[string]interface{}{"status": "closed", "end_time": now, "duration": duration}).Error
|
||||||
log.Printf("[RDPSession] 终止会话更新状态失败 (session_id=%d): %v", id, err)
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已终止"})
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已终止"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +160,7 @@ func (h *RDPSessionHandler) LogRDPReplayView(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := middleware.GetUserID(c)
|
uid := middleware.GetUserID(c)
|
||||||
auditLog := model.AuditLog{
|
log := model.AuditLog{
|
||||||
UserID: &uid,
|
UserID: &uid,
|
||||||
Username: middleware.GetUsername(c),
|
Username: middleware.GetUsername(c),
|
||||||
IP: c.ClientIP(),
|
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),
|
Request: fmt.Sprintf("回放 Windows RDP 会话 #%d(%s → %s/%s)", id, session.Username, session.ServerName, session.ServerIP),
|
||||||
Status: 200,
|
Status: 200,
|
||||||
}
|
}
|
||||||
if err := h.db.Create(&auditLog).Error; err != nil {
|
_ = h.db.Create(&log).Error
|
||||||
log.Printf("[RDPAudit] 记录回放审计日志失败 (session_id=%d, user=%s): %v", id, session.Username, err)
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"})
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"})
|
||||||
}
|
}
|
||||||
@@ -84,8 +84,8 @@ func (h *RemoteExecHandler) BatchExec(c *gin.Context) {
|
|||||||
|
|
||||||
// BatchUploadRequest 批量上传请求
|
// BatchUploadRequest 批量上传请求
|
||||||
type BatchUploadRequest struct {
|
type BatchUploadRequest struct {
|
||||||
ServerIDs []uint `json:"server_ids" binding:"required"` // 目标服务器 ID 列表
|
ServerIDs []uint `json:"server_ids" binding:"required"` // 目标服务器 ID 列表
|
||||||
LocalPath string `json:"local_path" binding:"required"` // AIOps 主机上的文件路径
|
LocalPath string `json:"local_path" binding:"required"` // AIOps 主机上的文件路径
|
||||||
RemotePath string `json:"remote_path" binding:"required"` // 远程目标路径
|
RemotePath string `json:"remote_path" binding:"required"` // 远程目标路径
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ func (h *ServerHandler) ListServers(c *gin.Context) {
|
|||||||
|
|
||||||
// 关键词搜索
|
// 关键词搜索
|
||||||
if query.Keyword != "" {
|
if query.Keyword != "" {
|
||||||
db = db.Where("hostname LIKE ? OR ip LIKE ? OR public_ip LIKE ?",
|
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+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 状态过滤
|
// 状态过滤
|
||||||
@@ -194,23 +194,26 @@ func (h *ServerHandler) CreateServer(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server := model.Server{
|
server := model.Server{
|
||||||
Hostname: req.Hostname,
|
Hostname: req.Hostname,
|
||||||
IP: req.IP,
|
Alias: req.Alias,
|
||||||
PublicIP: req.PublicIP,
|
CloudInstanceName: req.CloudInstanceName,
|
||||||
Port: req.Port,
|
CloudInstanceID: req.CloudInstanceID,
|
||||||
Username: req.Username,
|
IP: req.IP,
|
||||||
Password: req.Password,
|
PublicIP: req.PublicIP,
|
||||||
SSHKey: req.SSHKey,
|
Port: req.Port,
|
||||||
AuthType: req.AuthType,
|
Username: req.Username,
|
||||||
OS: req.OS,
|
Password: req.Password,
|
||||||
CPU: req.CPU,
|
SSHKey: req.SSHKey,
|
||||||
Memory: req.Memory,
|
AuthType: req.AuthType,
|
||||||
Disk: req.Disk,
|
OS: req.OS,
|
||||||
RegionID: req.RegionID,
|
CPU: req.CPU,
|
||||||
CloudID: req.CloudID,
|
Memory: req.Memory,
|
||||||
Tags: req.Tags,
|
Disk: req.Disk,
|
||||||
Description: req.Description,
|
RegionID: req.RegionID,
|
||||||
Status: "stopped",
|
CloudID: req.CloudID,
|
||||||
|
Tags: req.Tags,
|
||||||
|
Description: req.Description,
|
||||||
|
Status: "stopped",
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Status != "" {
|
if req.Status != "" {
|
||||||
@@ -295,6 +298,15 @@ func (h *ServerHandler) UpdateServer(c *gin.Context) {
|
|||||||
if req.Hostname != "" {
|
if req.Hostname != "" {
|
||||||
updates["hostname"] = 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 != "" {
|
if req.IP != "" {
|
||||||
updates["ip"] = req.IP
|
updates["ip"] = req.IP
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,9 +57,7 @@ func (h *ServerHandlerV2) ListServers(c *gin.Context) {
|
|||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
regionID := c.Query("region_id")
|
regionID := c.Query("region_id")
|
||||||
|
|
||||||
list, total, err := h.svc.ListServers(
|
list, total, err := h.svc.ListServers(query.Keyword, status, regionID, query.Page, query.PageSize)
|
||||||
query.Keyword, status, regionID, query.Sort, query.Order, query.Page, query.PageSize,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.InternalError(c, "查询失败")
|
utils.InternalError(c, "查询失败")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) {
|
|||||||
|
|
||||||
// 联表带出会话录制大小,前端可判断能否回放 // Logan.Li
|
// 联表带出会话录制大小,前端可判断能否回放 // Logan.Li
|
||||||
var items []model.SessionCommandItem
|
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").
|
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_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'")
|
Joins("LEFT JOIN sys_rdp_sessions AS rs ON rs.id = sc.session_id AND sc.session_type = 'rdp'")
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ func (h *SFTPHandler) sftpClient(serverID uint) (*sftp.Client, *ssh.Client, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConfig := &ssh.ClientConfig{
|
sshConfig := &ssh.ClientConfig{
|
||||||
User: server.Username,
|
User: server.Username,
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,23 +12,23 @@ import (
|
|||||||
|
|
||||||
// SiteTaskView 前端拨测任务视图(字段与前端对齐)// Logan.Li
|
// SiteTaskView 前端拨测任务视图(字段与前端对齐)// Logan.Li
|
||||||
type SiteTaskView struct {
|
type SiteTaskView struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
FrequencySec int `json:"frequency_sec"`
|
FrequencySec int `json:"frequency_sec"`
|
||||||
TimeoutSec int `json:"timeout_sec"`
|
TimeoutSec int `json:"timeout_sec"`
|
||||||
ExpectedStatusCode int `json:"expected_status_code"`
|
ExpectedStatusCode int `json:"expected_status_code"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
LastStatus string `json:"last_status,omitempty"`
|
LastStatus string `json:"last_status,omitempty"`
|
||||||
LastResponseTimeMs float64 `json:"last_response_time_ms,omitempty"`
|
LastResponseTimeMs float64 `json:"last_response_time_ms,omitempty"`
|
||||||
LastCheckedAt *string `json:"last_checked_at,omitempty"`
|
LastCheckedAt *string `json:"last_checked_at,omitempty"`
|
||||||
SuccessRate float64 `json:"success_rate"`
|
SuccessRate float64 `json:"success_rate"`
|
||||||
AlertStatus string `json:"alert_status"`
|
AlertStatus string `json:"alert_status"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
SSLDaysRemaining *int `json:"ssl_days_remaining,omitempty"`
|
SSLDaysRemaining *int `json:"ssl_days_remaining,omitempty"`
|
||||||
SSLCertExpiry *string `json:"ssl_cert_expiry,omitempty"`
|
SSLCertExpiry *string `json:"ssl_cert_expiry,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toSiteTaskView(task model.SiteTask) SiteTaskView {
|
func toSiteTaskView(task model.SiteTask) SiteTaskView {
|
||||||
|
|||||||
+12
-12
@@ -50,16 +50,16 @@ func (h *SNMPHandler) ListDevices(c *gin.Context) {
|
|||||||
// POST /api/v1/snmp/devices
|
// POST /api/v1/snmp/devices
|
||||||
func (h *SNMPHandler) CreateDevice(c *gin.Context) {
|
func (h *SNMPHandler) CreateDevice(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
IP string `json:"ip" binding:"required"`
|
IP string `json:"ip" binding:"required"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
SnmpVersion string `json:"snmp_version"`
|
SnmpVersion string `json:"snmp_version"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Community string `json:"community"`
|
Community string `json:"community"`
|
||||||
TemplateID uint `json:"template_id"`
|
TemplateID uint `json:"template_id"`
|
||||||
DeviceType string `json:"device_type"`
|
DeviceType string `json:"device_type"`
|
||||||
Vendor string `json:"vendor"`
|
Vendor string `json:"vendor"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
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)
|
h.db.Model(&model.SNMPDevice{}).Where("status = ?", "online").Count(&online)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||||
"total": total,
|
"total": total,
|
||||||
"online": online,
|
"online": online,
|
||||||
"abnormal": total - online,
|
"abnormal": total - online,
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
@@ -260,7 +260,7 @@ func snmpMetricLabel(name, index string) string {
|
|||||||
"h3c_fan_speed": "H3C 风扇", "hw_fan_speed": "华为 风扇",
|
"h3c_fan_speed": "H3C 风扇", "hw_fan_speed": "华为 风扇",
|
||||||
"ruijie_fan_speed": "锐捷 风扇", "dell_fan_speed": "Dell 风扇", "inspur_fan_speed": "浪潮 风扇",
|
"ruijie_fan_speed": "锐捷 风扇", "dell_fan_speed": "Dell 风扇", "inspur_fan_speed": "浪潮 风扇",
|
||||||
"ent_sensor_value": "实体传感器",
|
"ent_sensor_value": "实体传感器",
|
||||||
"if_in_octets": "入方向流量", "if_out_octets": "出方向流量",
|
"if_in_octets": "入方向流量", "if_out_octets": "出方向流量",
|
||||||
}
|
}
|
||||||
label := labels[name]
|
label := labels[name]
|
||||||
if label == "" {
|
if label == "" {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func (h *SystemTaskHandler) ListSystemTasks(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, model.Response{
|
c.JSON(http.StatusOK, model.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "success",
|
Message: "success",
|
||||||
Data: model.ListResponse{List: list, Total: int64(len(list))},
|
Data: model.ListResponse{List: list, Total: int64(len(list))},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -265,7 +264,7 @@ func (h *TerminalSessionHandler) LogReplayView(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid := middleware.GetUserID(c)
|
uid := middleware.GetUserID(c)
|
||||||
auditLog := model.AuditLog{
|
log := model.AuditLog{
|
||||||
UserID: &uid,
|
UserID: &uid,
|
||||||
Username: middleware.GetUsername(c),
|
Username: middleware.GetUsername(c),
|
||||||
IP: c.ClientIP(),
|
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),
|
Request: fmt.Sprintf("回放终端会话 #%d(%s → %s/%s)", id, session.Username, session.ServerName, session.ServerIP),
|
||||||
Status: 200,
|
Status: 200,
|
||||||
}
|
}
|
||||||
if err := h.db.Create(&auditLog).Error; err != nil {
|
_ = h.db.Create(&log).Error // 落库失败不阻断回放
|
||||||
log.Printf("[TerminalAudit] 记录回放审计日志失败 (session_id=%d, user=%s): %v", id, session.Username, err)
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"})
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,11 +105,11 @@ func (h *ToolConfigHandler) UpdateTool(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
InstallMethod *model.ToolInstallMethod `json:"install_method"`
|
InstallMethod *model.ToolInstallMethod `json:"install_method"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
SortOrder *int `json:"sort_order"`
|
SortOrder *int `json:"sort_order"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||||
|
|||||||
@@ -34,37 +34,37 @@ type jaegerServicesResp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type jaegerTraceResp struct {
|
type jaegerTraceResp struct {
|
||||||
Data []jaegerTrace `json:"data"`
|
Data []jaegerTrace `json:"data"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jaegerTrace struct {
|
type jaegerTrace struct {
|
||||||
TraceID string `json:"traceID"`
|
TraceID string `json:"traceID"`
|
||||||
Spans []jaegerSpan `json:"spans"`
|
Spans []jaegerSpan `json:"spans"`
|
||||||
Processes map[string]jaegerProc `json:"processes"`
|
Processes map[string]jaegerProc `json:"processes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jaegerSpan struct {
|
type jaegerSpan struct {
|
||||||
TraceID string `json:"traceID"`
|
TraceID string `json:"traceID"`
|
||||||
SpanID string `json:"spanID"`
|
SpanID string `json:"spanID"`
|
||||||
OperationName string `json:"operationName"`
|
OperationName string `json:"operationName"`
|
||||||
References []jaegerRef `json:"references"`
|
References []jaegerRef `json:"references"`
|
||||||
StartTime int64 `json:"startTime"` // microseconds
|
StartTime int64 `json:"startTime"` // microseconds
|
||||||
Duration int64 `json:"duration"` // microseconds
|
Duration int64 `json:"duration"` // microseconds
|
||||||
Tags []jaegerTag `json:"tags"`
|
Tags []jaegerTag `json:"tags"`
|
||||||
ProcessID string `json:"processID"`
|
ProcessID string `json:"processID"`
|
||||||
Logs []jaegerLog `json:"logs"`
|
Logs []jaegerLog `json:"logs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jaegerRef struct {
|
type jaegerRef struct {
|
||||||
RefType string `json:"refType"`
|
RefType string `json:"refType"`
|
||||||
TraceID string `json:"traceID"`
|
TraceID string `json:"traceID"`
|
||||||
SpanID string `json:"spanID"`
|
SpanID string `json:"spanID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jaegerProc struct {
|
type jaegerProc struct {
|
||||||
ServiceName string `json:"serviceName"`
|
ServiceName string `json:"serviceName"`
|
||||||
Tags []jaegerTag `json:"tags"`
|
Tags []jaegerTag `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jaegerTag struct {
|
type jaegerTag struct {
|
||||||
@@ -74,8 +74,8 @@ type jaegerTag struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type jaegerLog struct {
|
type jaegerLog struct {
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
Fields []jaegerTag `json:"fields"`
|
Fields []jaegerTag `json:"fields"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 前端期望的格式 ----
|
// ---- 前端期望的格式 ----
|
||||||
@@ -115,7 +115,7 @@ type serviceMetric struct {
|
|||||||
ServiceName string `json:"serviceName"`
|
ServiceName string `json:"serviceName"`
|
||||||
RequestCount int `json:"requestCount"`
|
RequestCount int `json:"requestCount"`
|
||||||
ErrorRate float64 `json:"errorRate"`
|
ErrorRate float64 `json:"errorRate"`
|
||||||
AvgDuration float64 `json:"avgDuration"` // ms
|
AvgDuration float64 `json:"avgDuration"` // ms
|
||||||
P95Duration float64 `json:"p95Duration"`
|
P95Duration float64 `json:"p95Duration"`
|
||||||
P99Duration float64 `json:"p99Duration"`
|
P99Duration float64 `json:"p99Duration"`
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-14
@@ -4,8 +4,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ezops/middleware"
|
|
||||||
"ezops/config"
|
"ezops/config"
|
||||||
|
"ezops/middleware"
|
||||||
"ezops/model"
|
"ezops/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -62,20 +62,31 @@ func parseUserAgent(ua string) (browser, os string) {
|
|||||||
}
|
}
|
||||||
// Browser
|
// Browser
|
||||||
switch {
|
switch {
|
||||||
case containsAny(ua, "Edg/"): browser = "Edge"
|
case containsAny(ua, "Edg/"):
|
||||||
case containsAny(ua, "Chrome"): browser = "Chrome"
|
browser = "Edge"
|
||||||
case containsAny(ua, "Safari") && !containsAny(ua, "Chrome"): browser = "Safari"
|
case containsAny(ua, "Chrome"):
|
||||||
case containsAny(ua, "Firefox"): browser = "Firefox"
|
browser = "Chrome"
|
||||||
case containsAny(ua, "curl"): browser = "curl"
|
case containsAny(ua, "Safari") && !containsAny(ua, "Chrome"):
|
||||||
case containsAny(ua, "Python"): browser = "Python"
|
browser = "Safari"
|
||||||
|
case containsAny(ua, "Firefox"):
|
||||||
|
browser = "Firefox"
|
||||||
|
case containsAny(ua, "curl"):
|
||||||
|
browser = "curl"
|
||||||
|
case containsAny(ua, "Python"):
|
||||||
|
browser = "Python"
|
||||||
}
|
}
|
||||||
// OS
|
// OS
|
||||||
switch {
|
switch {
|
||||||
case containsAny(ua, "Windows"): os = "Windows"
|
case containsAny(ua, "Windows"):
|
||||||
case containsAny(ua, "Mac OS"): os = "macOS"
|
os = "Windows"
|
||||||
case containsAny(ua, "Linux") || containsAny(ua, "X11"): os = "Linux"
|
case containsAny(ua, "Mac OS"):
|
||||||
case containsAny(ua, "Android"): os = "Android"
|
os = "macOS"
|
||||||
case containsAny(ua, "iPhone") || containsAny(ua, "iPad"): os = "iOS"
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -363,7 +374,7 @@ func collectUserPermissionCodes(db *gorm.DB, userID uint, isAdmin bool) []string
|
|||||||
if isAdmin {
|
if isAdmin {
|
||||||
return []string{"*"}
|
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,任一层缺失自然返回空
|
// 逐层 JOIN,任一层缺失自然返回空
|
||||||
type permRow struct {
|
type permRow struct {
|
||||||
Code string
|
Code string
|
||||||
@@ -373,7 +384,7 @@ func collectUserPermissionCodes(db *gorm.DB, userID uint, isAdmin bool) []string
|
|||||||
SELECT DISTINCT p.code
|
SELECT DISTINCT p.code
|
||||||
FROM iam_user_roles ur
|
FROM iam_user_roles ur
|
||||||
JOIN iam_roles r ON r.id = ur.role_id AND r.status = 1
|
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
|
JOIN iam_permissions p ON p.id = rp.permission_id AND p.status = 1
|
||||||
WHERE ur.user_id = ?
|
WHERE ur.user_id = ?
|
||||||
`, userID).Scan(&rows).Error
|
`, userID).Scan(&rows).Error
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ func (h *UserHandler) ListRoles(c *gin.Context) {
|
|||||||
Code: 200, Data: model.ListResponse{List: roles, Total: int64(len(roles))},
|
Code: 200, Data: model.ListResponse{List: roles, Total: int64(len(roles))},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/admin/users/import-template
|
// GET /api/v1/admin/users/import-template
|
||||||
func (h *UserHandler) ImportTemplate(c *gin.Context) {
|
func (h *UserHandler) ImportTemplate(c *gin.Context) {
|
||||||
// UTF-8 BOM for Excel compatibility
|
// UTF-8 BOM for Excel compatibility
|
||||||
|
|||||||
Loaded 100 of 439 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user