From fb1a75a2a46aa62bfc2be2ae54bdcf1e4301a8a4 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 21 May 2026 16:43:57 +0800 Subject: [PATCH] =?UTF-8?q?=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 26 + README.md | 19 +- deploy/README.md | 10 +- deploy/server.mjs | 195 +++- deploy/standalone/README.md | 10 +- deploy/tencent-tat/README.md | 37 +- ...hyapp-services.inventory.prod.example.json | 49 +- deploy/tencent-tat/hyapp_data_resources.py | 918 ++++++++++++++++++ deploy/tencent-tat/hyapp_tat_deploy.py | 280 +++++- deploy/tencent-tat/hyapp_testbox_config.py | 636 +++++++++++- src/app/router.tsx | 9 +- .../data-resource/api/getDataResources.ts | 30 + .../api/useDataResourcesQuery.ts | 14 + src/entities/data-resource/index.ts | 11 + src/entities/data-resource/model/types.ts | 74 ++ .../api/getDeployApiInventory.ts | 212 +++- .../api/useDeployApiQueries.ts | 35 +- src/entities/deployment-plan/index.ts | 31 +- .../lib/getHyappDeploymentService.ts | 24 - .../mock/hyappDeploymentPlan.ts | 575 ----------- src/entities/deployment-plan/model/types.ts | 171 ---- src/entities/service/api/deployApi.ts | 206 ++++ src/entities/service/api/getServiceDetail.ts | 502 ++++------ src/entities/service/api/getServices.ts | 106 +- .../service/api/useServiceDetailQuery.ts | 6 +- src/entities/service/index.ts | 1 - src/entities/service/lib/getServiceSummary.ts | 17 +- src/entities/service/mock/services.ts | 16 - src/entities/service/model/types.ts | 17 + .../release-service/api/createRelease.ts | 25 +- .../lib/getReleasePrechecks.ts | 35 +- .../lib/getReleaseVersionOptions.ts | 39 - src/features/release-service/model/types.ts | 2 + .../ui/ReleaseDrawer.module.css | 24 + .../release-service/ui/ReleaseDrawer.tsx | 54 +- src/pages/audit/AuditPage.tsx | 5 - src/pages/audit/index.ts | 1 - src/pages/logs-events/LogsEventsPage.tsx | 5 - src/pages/logs-events/index.ts | 1 - src/pages/overview/OverviewPage.tsx | 37 +- src/pages/placeholder/PlaceholderPage.tsx | 23 - src/pages/placeholder/index.ts | 1 - src/pages/releases/ReleasesPage.tsx | 660 ++++++------- src/pages/resources/ResourcesPage.module.css | 290 ++++++ src/pages/resources/ResourcesPage.tsx | 291 +++++- .../ServiceDetailPage.module.css | 55 ++ .../service-detail/ServiceDetailPage.tsx | 89 +- src/pages/services/ServicesPage.module.css | 117 +++ src/pages/services/ServicesPage.tsx | 109 ++- .../test-services/TestServicesPage.module.css | 302 ++++++ src/pages/test-services/TestServicesPage.tsx | 557 +++++++++++ src/pages/test-services/index.ts | 1 + src/shared/api/queryKeys.ts | 5 +- src/widgets/app-shell/AppShell.tsx | 12 +- src/widgets/service-table/ServiceTable.tsx | 12 +- 55 files changed, 5154 insertions(+), 1835 deletions(-) create mode 100644 AGENTS.md create mode 100644 deploy/tencent-tat/hyapp_data_resources.py create mode 100644 src/entities/data-resource/api/getDataResources.ts create mode 100644 src/entities/data-resource/api/useDataResourcesQuery.ts create mode 100644 src/entities/data-resource/index.ts create mode 100644 src/entities/data-resource/model/types.ts delete mode 100644 src/entities/deployment-plan/lib/getHyappDeploymentService.ts delete mode 100644 src/entities/deployment-plan/mock/hyappDeploymentPlan.ts delete mode 100644 src/entities/deployment-plan/model/types.ts create mode 100644 src/entities/service/api/deployApi.ts delete mode 100644 src/entities/service/mock/services.ts delete mode 100644 src/features/release-service/lib/getReleaseVersionOptions.ts delete mode 100644 src/pages/audit/AuditPage.tsx delete mode 100644 src/pages/audit/index.ts delete mode 100644 src/pages/logs-events/LogsEventsPage.tsx delete mode 100644 src/pages/logs-events/index.ts delete mode 100644 src/pages/placeholder/PlaceholderPage.tsx delete mode 100644 src/pages/placeholder/index.ts create mode 100644 src/pages/resources/ResourcesPage.module.css create mode 100644 src/pages/test-services/TestServicesPage.module.css create mode 100644 src/pages/test-services/TestServicesPage.tsx create mode 100644 src/pages/test-services/index.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..449a817 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# AGENTS.md + +## Working agreements + +1. 做任何事情之前不要猜测,先看代码和现有链路。 +2. 找最直接的方案解决问题。 +3. 所有解决的问题都要验证。 +4. 修改时碰到已有改动无需询问,继续修改,不回滚无关改动。 + +## 数据资源 + +1. MySQL、Redis、MQ 都是腾讯云托管资源。 +2. 数据资源状态入口:`GET /deploy/api/hyapp/resources?remote=1`。 +3. 读取链路:Tencent TAT -> deploy 机器。 +4. 业务服务器配置优先用 deploy 机器 SSH 读取。 +5. 业务服务器 SSH 失败时,对对应 CVM 使用 TAT 兜底读取。 +6. 禁止把 MySQL、Redis、MQ 当服务器 SSH。 +7. 禁止开放生产 SQL、Mongo 查询入口。 +8. TAT 未返回前,页面保持加载状态。 + +## 微服务 + +1. 微服务列表和详情必须使用 `GET /deploy/api/hyapp/discover?remote=1` 的真实数据。 +2. 禁止用静态假数据兜底展示微服务运行态。 +3. 配置 YAML 只在列表右侧三点和详情配置区展示。 +4. 读取 YAML 时使用 `includeConfig=1`,密钥字段保持脱敏。 diff --git a/README.md b/README.md index bfaab76..c53deed 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Deploy Platform -液态玻璃风格的项目运维管理平台。 +项目运维平台。 ## Scripts @@ -43,3 +43,20 @@ make up ``` 部署模板和 TAT 脚本见 `deploy/README.md`。 + +## 数据资源 + +- API:`GET /deploy/api/hyapp/resources?remote=1` +- 链路:Tencent TAT -> deploy 机器 +- MySQL、Redis、MQ:腾讯云托管资源 +- 业务服务器配置:优先 SSH,失败后 TAT 兜底 +- 前端状态:TAT 未返回前保持加载 +- 禁止:生产 SQL/Mongo 查询入口 + +## 微服务 + +- 列表:`GET /deploy/api/hyapp/discover?remote=1` +- 详情 YAML:`GET /deploy/api/hyapp/discover?remote=1&includeConfig=1` +- 数据来源:TAT 读取 systemd、Docker、镜像、资源、日志和配置 +- YAML 入口:列表右侧三点、详情配置区 +- 禁止:静态假数据兜底展示运行态 diff --git a/deploy/README.md b/deploy/README.md index 1244cdb..ef90d8a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -42,9 +42,17 @@ npm run deploy:serve - `GET /deploy/api/health`:只返回凭据是否存在,不返回密钥明文。 - `GET /deploy/api/hyapp/inventory`:读取 HyApp 生产 inventory。 - `GET /deploy/api/hyapp/plan?services=gateway-service,room-service`:生成发布计划,不执行远端动作。 -- `GET /deploy/api/hyapp/discover?services=user-service&remote=1`:通过 Tencent TAT 读取真实实例、容器、端口和 CLB 权重。 +- `GET /deploy/api/hyapp/discover?services=user-service&remote=1`:通过 Tencent TAT 读取真实实例、systemd、Docker、镜像、资源、端口和 CLB 权重。 +- `GET /deploy/api/hyapp/discover?services=user-service&remote=1&includeConfig=1`:读取详情 YAML,密钥字段脱敏。 +- `GET /deploy/api/hyapp/resources?remote=1`:数据资源状态。链路:TAT -> deploy;业务服务器配置 SSH 失败后 TAT 兜底。 - `POST /deploy/api/hyapp/deploy`:真实发布,必须带 `confirmed=true`;测试时使用 `dryRun=true`。 - `POST /deploy/api/hyapp/restart`:真实重启,必须带 `confirmed=true`;测试时使用 `dryRun=true`。 +- `GET /deploy/api/hyapp/testbox/status`:读取测试机 test 分支、timer 和 Compose/systemd 服务状态。 +- `POST /deploy/api/hyapp/testbox/service`:测试机服务 `update/start/stop/restart`,必须带 `confirmed=true`。 +- `POST /deploy/api/hyapp/testbox/timer`:测试机自动更新 timer `start/stop/restart`,必须带 `confirmed=true`。 +- `POST /deploy/api/hyapp/testbox/config`:写入测试机单服务配置,自动备份旧文件,可选保存后重启。 +- `GET /deploy/api/hyapp/testbox/migrations`:只列出测试机仓库 `server/admin/migrations/*.sql`。 +- `POST /deploy/api/hyapp/testbox/migrations/apply`:只执行测试机仓库中的 migration 文件,必须带 `confirmed=true`。 详细命令见: diff --git a/deploy/server.mjs b/deploy/server.mjs index 8b54406..4da3797 100644 --- a/deploy/server.mjs +++ b/deploy/server.mjs @@ -163,7 +163,7 @@ function readRequestJson(request) { request.on("data", (chunk) => { raw += chunk; - if (raw.length > 64 * 1024) { + if (raw.length > 256 * 1024) { reject(new Error("request body too large")); request.destroy(); } @@ -226,6 +226,44 @@ function buildEnvArgs() { return tencentEnvFile ? ["--env-file", tencentEnvFile] : []; } +function parseMigrationFiles(value) { + const source = Array.isArray(value) ? value.join(",") : value; + const files = String(source || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + + for (const file of files) { + if (!/^[0-9][A-Za-z0-9_.-]*\.sql$/.test(file)) { + throw new Error(`invalid migration file name: ${file}`); + } + } + + return [...new Set(files)]; +} + +function requireConfirmed(body) { + if (body.dryRun === true) { + return; + } + if (body.confirmed !== true) { + throw new Error("confirmed=true is required for real execution"); + } +} + +function buildTestboxBaseArgs(action) { + return [ + action, + ...buildEnvArgs(), + "--region", + hyappTestbox.region, + "--instance-id", + hyappTestbox.instanceId, + "--root", + hyappTestbox.root, + ]; +} + function buildActionArgs(action, body) { const services = parseServices(body.services, ""); const hosts = parseHosts(body.hosts); @@ -358,6 +396,8 @@ async function handleApi(request, response, url) { if (url.pathname === "/deploy/api/hyapp/discover") { const services = parseServices(url.searchParams.get("services"), "gateway-service,room-service,user-service"); const executeRemote = url.searchParams.get("remote") === "1"; + const includeConfig = url.searchParams.get("includeConfig") === "1" || url.searchParams.get("includeConfig") === "true"; + const maxConfigBytes = Number(url.searchParams.get("maxConfigBytes") || 12000); const args = [ "discover", ...buildEnvArgs(), @@ -370,27 +410,32 @@ async function handleApi(request, response, url) { if (!executeRemote) { args.push("--dry-run"); } + if (includeConfig) { + args.push("--include-config", "--max-config-bytes", String(Math.max(1000, Math.min(maxConfigBytes, 50000)))); + } const result = await runDeployScript("hyapp_tat_deploy.py", args); sendJson(response, result.ok ? 200 : 500, result.payload); return; } + if (url.pathname === "/deploy/api/hyapp/resources") { + const executeRemote = url.searchParams.get("remote") !== "0"; + const args = ["resources", ...buildEnvArgs(), "--inventory", hyappInventoryPath]; + + if (!executeRemote) { + args.push("--dry-run"); + } + + const result = await runDeployScript("hyapp_data_resources.py", args); + sendJson(response, result.ok ? 200 : 500, result.payload); + return; + } + if (url.pathname === "/deploy/api/hyapp/testbox/configs") { const services = parseServices(url.searchParams.get("services"), hyappTestboxConfigServices); const raw = url.searchParams.get("raw") === "1" || url.searchParams.get("raw") === "true"; - const args = [ - "configs", - ...buildEnvArgs(), - "--region", - hyappTestbox.region, - "--instance-id", - hyappTestbox.instanceId, - "--root", - hyappTestbox.root, - "--services", - services.join(","), - ]; + const args = [...buildTestboxBaseArgs("configs"), "--services", services.join(",")]; if (raw && process.env.HYAPP_TESTBOX_CONFIG_ALLOW_RAW === "1") { args.push("--raw"); @@ -401,6 +446,130 @@ async function handleApi(request, response, url) { return; } + if (url.pathname === "/deploy/api/hyapp/testbox/status") { + const services = parseServices(url.searchParams.get("services"), hyappTestboxConfigServices); + const args = [...buildTestboxBaseArgs("status"), "--services", services.join(",")]; + const result = await runDeployScript("hyapp_testbox_config.py", args); + sendJson(response, result.ok ? 200 : 500, result.payload); + return; + } + + if (url.pathname === "/deploy/api/hyapp/testbox/service") { + if (request.method !== "POST") { + sendError(response, 405, "POST is required"); + return; + } + + const body = await readRequestJson(request); + requireConfirmed(body); + + const services = parseServices(body.services, hyappTestboxConfigServices); + const operation = String(body.operation || "").trim(); + + if (!["restart", "start", "stop", "update"].includes(operation)) { + throw new Error(`invalid service operation: ${operation}`); + } + + const args = [...buildTestboxBaseArgs("service"), "--services", services.join(","), "--operation", operation]; + const result = await runDeployScript("hyapp_testbox_config.py", args); + sendJson(response, result.ok ? 200 : 500, result.payload); + return; + } + + if (url.pathname === "/deploy/api/hyapp/testbox/timer") { + if (request.method !== "POST") { + sendError(response, 405, "POST is required"); + return; + } + + const body = await readRequestJson(request); + requireConfirmed(body); + + const operation = String(body.operation || "").trim(); + + if (!["start", "stop", "restart", "status"].includes(operation)) { + throw new Error(`invalid timer operation: ${operation}`); + } + + const args = [...buildTestboxBaseArgs("timer"), "--operation", operation]; + const result = await runDeployScript("hyapp_testbox_config.py", args); + sendJson(response, result.ok ? 200 : 500, result.payload); + return; + } + + if (url.pathname === "/deploy/api/hyapp/testbox/config") { + if (request.method !== "POST") { + sendError(response, 405, "POST is required"); + return; + } + + const body = await readRequestJson(request); + requireConfirmed(body); + + const service = parseServices(body.service, "")[0]; + const content = String(body.content ?? ""); + + if (!content.trim()) { + throw new Error("config content is required"); + } + + if (content.length > 60_000) { + throw new Error("config content is too large"); + } + + if (content.includes("***")) { + throw new Error("redacted config cannot be saved; replace masked secret fields before saving"); + } + + const args = [ + ...buildTestboxBaseArgs("write-config"), + "--service", + service, + "--content-base64", + Buffer.from(content, "utf-8").toString("base64"), + ]; + + if (body.restart === true) { + args.push("--restart"); + } + + const result = await runDeployScript("hyapp_testbox_config.py", args); + sendJson(response, result.ok ? 200 : 500, result.payload); + return; + } + + if (url.pathname === "/deploy/api/hyapp/testbox/migrations") { + const args = buildTestboxBaseArgs("migrations"); + const result = await runDeployScript("hyapp_testbox_config.py", args); + sendJson(response, result.ok ? 200 : 500, result.payload); + return; + } + + if (url.pathname === "/deploy/api/hyapp/testbox/migrations/apply") { + if (request.method !== "POST") { + sendError(response, 405, "POST is required"); + return; + } + + const body = await readRequestJson(request); + requireConfirmed(body); + + const files = parseMigrationFiles(body.files); + const args = buildTestboxBaseArgs("migrations-apply"); + + if (files.length > 0) { + args.push("--files", files.join(",")); + } + + if (body.dryRun === true) { + args.push("--dry-run"); + } + + const result = await runDeployScript("hyapp_testbox_config.py", args); + sendJson(response, result.ok ? 200 : 500, result.payload); + return; + } + sendError(response, 404, "deploy api route not found"); } diff --git a/deploy/standalone/README.md b/deploy/standalone/README.md index c249019..7931125 100644 --- a/deploy/standalone/README.md +++ b/deploy/standalone/README.md @@ -17,10 +17,18 @@ - `GET /deploy/api/hyapp/plan?services=gateway-service,room-service` - `GET /deploy/api/hyapp/discover?services=gateway-service&remote=0` - `GET /deploy/api/hyapp/discover?services=user-service&remote=1` +- `GET /deploy/api/hyapp/discover?services=user-service&remote=1&includeConfig=1` +- `GET /deploy/api/hyapp/resources?remote=1` - `POST /deploy/api/hyapp/deploy` - `POST /deploy/api/hyapp/restart` +- `GET /deploy/api/hyapp/testbox/status` +- `POST /deploy/api/hyapp/testbox/service` +- `POST /deploy/api/hyapp/testbox/timer` +- `POST /deploy/api/hyapp/testbox/config` +- `GET /deploy/api/hyapp/testbox/migrations` +- `POST /deploy/api/hyapp/testbox/migrations/apply` -`deploy` 和 `restart` 是真实操作接口,必须在 JSON body 中显式传入 `confirmed: true`。联调和验收使用 `dryRun: true`,只返回执行计划,不修改远端实例。 +`resources?remote=1`:TAT -> deploy;业务服务器配置 SSH 失败后 TAT 兜底;不开放 SQL/Mongo 查询入口。`deploy` 和 `restart` 是真实操作接口,必须在 JSON body 中显式传入 `confirmed: true`。联调和验收使用 `dryRun: true`。 ## Build Image diff --git a/deploy/tencent-tat/README.md b/deploy/tencent-tat/README.md index 1624ed9..ebe9661 100644 --- a/deploy/tencent-tat/README.md +++ b/deploy/tencent-tat/README.md @@ -3,7 +3,8 @@ 本目录包含两类 TAT 发布入口: - `deploy_platform_tat_deploy.py`:发布当前 `deploy-platform` 前端项目。 -- `hyapp_tat_deploy.py`:发布 HyApp 后端 Go 微服务,逻辑复制自 `/Users/hy/Documents/hy/hyapp-server/deploy/tencent-tat`。 +- `hyapp_tat_deploy.py`:发布 HyApp 后端 Go 微服务,读取真实 systemd、Docker、镜像、资源、日志和 YAML。 +- `hyapp_data_resources.py`:数据资源状态。TAT -> deploy;业务服务器配置 SSH 失败后 TAT 兜底。 两者使用同一套 TAT/CLB 滚动部署模型:可选 CLB 摘流,TAT 远端更新 `docker.env` 镜像并重启 systemd unit,健康恢复后恢复权重。 @@ -101,6 +102,16 @@ python3 deploy/tencent-tat/hyapp_tat_deploy.py plan \ --services gateway-service,room-service,user-service ``` +读取真实运行态和配置 YAML: + +```bash +python3 deploy/tencent-tat/hyapp_tat_deploy.py discover \ + --env-file /opt/hy-app-monitor/.env \ + --inventory deploy/tencent-tat/hyapp-services.inventory.prod.json \ + --services gateway-service \ + --include-config +``` + 生产使用时复制成真实 inventory: ```bash @@ -108,6 +119,22 @@ cp deploy/tencent-tat/hyapp-services.inventory.prod.example.json deploy/tencent- vi deploy/tencent-tat/hyapp-services.inventory.prod.json ``` +数据资源状态只读查询: + +```bash +python3 deploy/tencent-tat/hyapp_data_resources.py resources \ + --env-file /opt/hy-app-monitor/.env \ + --inventory deploy/tencent-tat/hyapp-services.inventory.prod.json +``` + +规则: + +- MySQL、Redis、MQ 是腾讯云托管资源。 +- 读取入口固定为 TAT -> deploy。 +- 业务服务器配置优先 SSH;SSH 失败后 TAT 兜底。 +- 只读 `/etc/hyapp//config.yaml`、`docker.env`、systemd active。 +- 不执行 SQL/Mongo 查询,不修改 MySQL、Redis、MQ。 + ## HyApp Testbox 测试机部署不走生产镜像仓库,也不摘 CLB。当前模式是测试机自己拉取远端 `test` 分支: @@ -128,3 +155,11 @@ python3 deploy/tencent-tat/hyapp_testbox_config.py configs \ ``` 默认返回会遮罩 `secret`、`password`、`token`、`key` 等字段;确实需要原始值时,服务端必须显式设置 `HYAPP_TESTBOX_CONFIG_ALLOW_RAW=1`,并在 API 请求里带 `raw=1`。 + +测试服务后台还使用同一个脚本封装固定动作,不开放任意 shell: + +- `status`:读取 test 分支、已部署 commit、timer 和服务状态。 +- `service --operation update|start|stop|restart`:对 Compose 服务或 `admin-server` systemd 执行固定操作。 +- `timer --operation start|stop|restart|status`:控制 `hyapp-server-test-deploy.timer`。 +- `write-config --service --content-base64 `:写配置前备份旧文件,可选 `--restart`。 +- `migrations` / `migrations-apply`:只列出或执行 `source/server/admin/migrations/*.sql`,不接收页面手写 SQL。 diff --git a/deploy/tencent-tat/hyapp-services.inventory.prod.example.json b/deploy/tencent-tat/hyapp-services.inventory.prod.example.json index 53aec04..3e237ed 100644 --- a/deploy/tencent-tat/hyapp-services.inventory.prod.example.json +++ b/deploy/tencent-tat/hyapp-services.inventory.prod.example.json @@ -8,9 +8,52 @@ "service_health_timeout_seconds": 150, "stabilize_seconds": 5, "managed_dependencies": { - "mysql": "TencentDB for MySQL private endpoint; this deployment never starts local MySQL.", - "redis": "TencentDB for Redis private endpoint; room owner route and login/risk cache use this managed Redis.", - "mq": "Tencent Cloud managed MQ; future consumers only receive connection/topic config and are not deployed as local infra." + "mysql": "TencentDB MySQL", + "redis": "TencentDB Redis", + "mq": "Tencent Cloud MQ" + }, + "data_resources": { + "mysql": { + "name": "TencentDB MySQL", + "type": "mysql", + "topology": "托管主从 MySQL", + "backup_status": "云产品自动备份,平台不执行恢复动作", + "owner": "后端平台", + "related_services": [ + "room-service", + "wallet-service", + "user-service", + "activity-service", + "cron-service", + "game-service", + "notice-service", + "admin-server" + ] + }, + "redis": { + "name": "TencentDB Redis", + "type": "redis", + "topology": "托管 Redis", + "backup_status": "缓存资源不作为恢复事实源", + "owner": "后端平台", + "related_services": [ + "gateway-service", + "room-service", + "user-service" + ] + }, + "mq": { + "name": "Tencent Cloud MQ", + "type": "mq", + "topology": "托管消息队列", + "backup_status": "云产品托管,平台不执行恢复动作", + "owner": "后端平台", + "related_services": [ + "room-service", + "activity-service", + "notice-service" + ] + } }, "deploy_server": { "name": "deploy", diff --git a/deploy/tencent-tat/hyapp_data_resources.py b/deploy/tencent-tat/hyapp_data_resources.py new file mode 100644 index 0000000..7be2905 --- /dev/null +++ b/deploy/tencent-tat/hyapp_data_resources.py @@ -0,0 +1,918 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import base64 +import json +import os +import sys +import time +from pathlib import Path +from typing import Any + + +DEFAULT_RESOURCE_META = { + "mq": { + "backup_status": "云产品托管,平台不执行恢复动作", + "name": "Tencent Cloud MQ", + "owner": "后端平台", + "topology": "托管消息队列", + "type": "mq", + }, + "mysql": { + "backup_status": "云产品自动备份,平台不执行恢复动作", + "name": "TencentDB MySQL", + "owner": "后端平台", + "topology": "托管主从 MySQL", + "type": "mysql", + }, + "redis": { + "backup_status": "缓存资源不作为恢复事实源", + "name": "TencentDB Redis", + "owner": "后端平台", + "topology": "托管 Redis", + "type": "redis", + }, +} + + +def load_env_file(path: str) -> None: + if not path: + return + env_path = Path(path).expanduser().resolve() + if not env_path.exists(): + raise RuntimeError(f"env file not found: {env_path}") + for raw_line in env_path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +def load_inventory(path: str) -> dict[str, Any]: + inventory_path = Path(path).expanduser().resolve() + with inventory_path.open("r", encoding="utf-8") as handle: + inventory = json.load(handle) + validate_inventory(inventory) + return inventory + + +def validate_inventory(inventory: dict[str, Any]) -> None: + if not isinstance(inventory.get("deploy_server"), dict): + raise RuntimeError("inventory.deploy_server is required") + if not str(inventory["deploy_server"].get("instance_id") or "").strip(): + raise RuntimeError("inventory.deploy_server.instance_id is required") + if not isinstance(inventory.get("hosts"), dict) or not inventory["hosts"]: + raise RuntimeError("inventory.hosts is required") + if not isinstance(inventory.get("services"), dict) or not inventory["services"]: + raise RuntimeError("inventory.services is required") + + +def secret_id() -> str: + return os.environ.get("TENCENTCLOUD_SECRET_ID") or os.environ.get("TENCENT_SECRET_ID") or "" + + +def secret_key() -> str: + return os.environ.get("TENCENTCLOUD_SECRET_KEY") or os.environ.get("TENCENT_SECRET_KEY") or "" + + +def region(inventory: dict[str, Any]) -> str: + return os.environ.get("TENCENTCLOUD_REGION") or os.environ.get("DEPLOY_REGION") or str(inventory.get("region") or "") + + +def require_tencent_credentials(inventory: dict[str, Any]) -> tuple[str, str, str]: + sid = secret_id() + skey = secret_key() + reg = region(inventory) + missing = [] + if not sid: + missing.append("TENCENTCLOUD_SECRET_ID") + if not skey: + missing.append("TENCENTCLOUD_SECRET_KEY") + if not reg: + missing.append("TENCENTCLOUD_REGION") + if missing: + raise RuntimeError("missing Tencent Cloud credentials: " + ", ".join(missing)) + return sid, skey, reg + + +def build_tat_client(inventory: dict[str, Any]) -> Any: + sid, skey, reg = require_tencent_credentials(inventory) + try: + from tencentcloud.common import credential + from tencentcloud.common.profile.client_profile import ClientProfile + from tencentcloud.common.profile.http_profile import HttpProfile + from tencentcloud.tat.v20201028 import tat_client + except Exception as exc: # noqa: BLE001 + raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc + return tat_client.TatClient( + credential.Credential(sid, skey), + reg, + ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")), + ) + + +def invoke_tencent(action: Any, label: str, retries: int = 3) -> Any: + try: + from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException + except Exception: # noqa: BLE001 + TencentCloudSDKException = Exception # type: ignore[assignment] + last_error: Exception | None = None + for attempt in range(1, retries + 1): + try: + return action() + except TencentCloudSDKException as exc: # type: ignore[misc] + last_error = exc + if attempt == retries: + break + time.sleep(2 * attempt) + raise RuntimeError(f"{label} failed: {last_error}") from last_error + + +def decode_tat_output(output: str) -> str: + if not output: + return "" + return base64.b64decode(output).decode("utf-8", errors="replace") + + +def run_tat_shell( + client: Any, + *, + instance_id: str, + script: str, + command_name: str, + timeout_seconds: int, + poll_seconds: int, +) -> dict[str, Any]: + from tencentcloud.tat.v20201028 import models as tat_models + + request = tat_models.RunCommandRequest() + request.from_json_string( + json.dumps( + { + "CommandName": command_name, + "CommandType": "SHELL", + "Content": base64.b64encode(script.encode("utf-8")).decode("ascii"), + "InstanceIds": [instance_id], + "Timeout": timeout_seconds, + "WorkingDirectory": "/root", + } + ) + ) + response = json.loads(invoke_tencent(lambda: client.RunCommand(request), "RunCommand").to_json_string()) + invocation_id = response["InvocationId"] + deadline = time.time() + timeout_seconds + 60 + while time.time() < deadline: + time.sleep(max(poll_seconds, 1)) + query = tat_models.DescribeInvocationTasksRequest() + query.from_json_string( + json.dumps( + { + "HideOutput": False, + "Limit": 50, + "Filters": [{"Name": "invocation-id", "Values": [invocation_id]}], + } + ) + ) + payload = json.loads(invoke_tencent(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").to_json_string()) + for task in payload.get("InvocationTaskSet") or []: + if str(task.get("InstanceId") or "") != instance_id: + continue + status = str(task.get("TaskStatus") or "") + if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}: + return { + "status": status, + "output": decode_tat_output(str((task.get("TaskResult") or {}).get("Output") or "")), + } + return {"status": "TIMEOUT", "output": ""} + + +def normalize_data_resources(inventory: dict[str, Any]) -> list[dict[str, Any]]: + raw_resources = inventory.get("data_resources") + resources: list[dict[str, Any]] = [] + if isinstance(raw_resources, dict) and raw_resources: + for resource_id, raw in raw_resources.items(): + if isinstance(raw, dict): + resource = dict(raw) + else: + resource = {"description": str(raw)} + resource["id"] = str(resource.get("id") or resource_id) + resources.append(resource) + else: + managed_dependencies = inventory.get("managed_dependencies") or {} + if isinstance(managed_dependencies, dict): + for resource_id, description in managed_dependencies.items(): + resource = {"description": str(description), "id": str(resource_id)} + resources.append(resource) + + service_names = sorted(str(name) for name in inventory.get("services", {}).keys()) + normalized = [] + for resource in resources: + resource_id = str(resource.get("id") or "").strip() + defaults = DEFAULT_RESOURCE_META.get(resource_id, {}) + resource_type = str(resource.get("type") or defaults.get("type") or resource_id or "unknown") + related_services = resource.get("related_services") or resource.get("relatedServices") or service_names + normalized.append( + { + "backupStatus": str(resource.get("backup_status") or resource.get("backupStatus") or defaults.get("backup_status") or "未采集"), + "capacityUsedPercent": resource.get("capacity_used_percent") or resource.get("capacityUsedPercent"), + "connections": resource.get("connections"), + "description": str(resource.get("description") or ""), + "endpoints": resource.get("endpoints") or ([resource.get("endpoint")] if resource.get("endpoint") else []), + "id": resource_id, + "name": str(resource.get("name") or defaults.get("name") or resource_id), + "owner": str(resource.get("owner") or defaults.get("owner") or "后端平台"), + "relatedServices": [str(item) for item in related_services if str(item).strip()], + "slowQueryCount": resource.get("slow_query_count") or resource.get("slowQueryCount"), + "topology": str(resource.get("topology") or defaults.get("topology") or "托管资源"), + "type": resource_type, + } + ) + return normalized + + +def build_remote_payload(inventory: dict[str, Any], *, max_bytes: int) -> dict[str, Any]: + services = [] + for service_name, service in inventory["services"].items(): + services.append( + { + "configPath": service.get("config_path") or f"/etc/hyapp/{service_name}/config.yaml", + "envFile": service.get("env_file") or "", + "hosts": list(service.get("hosts") or []), + "name": service_name, + "unit": service.get("unit") or "", + } + ) + return { + "dataResources": normalize_data_resources(inventory), + "hosts": inventory["hosts"], + "maxBytes": max_bytes, + "region": region(inventory), + "services": services, + "sshUser": os.environ.get("DEPLOY_DATA_SSH_USER", "root"), + "tatFallbackEnvFiles": [ + item.strip() + for item in os.environ.get( + "DEPLOY_DATA_TAT_FALLBACK_ENV_FILES", + "/opt/hy-app-monitor/.env,/etc/hyapp/deploy-platform/docker.env", + ).split(",") + if item.strip() + ], + "tatFallbackTimeout": int(inventory.get("tat_data_fallback_timeout_seconds") or 120), + "tatPollSeconds": int(inventory.get("tat_poll_seconds") or 2), + } + + +def remote_collect_script(payload: dict[str, Any]) -> str: + blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + return f"""set -euo pipefail +python3 - <<'PY' +import base64 +import json +import os +import re +import socket +import subprocess +import sys +import time + +payload = json.loads(base64.b64decode({blob!r}).decode("utf-8")) +services = payload["services"] +hosts = payload["hosts"] +data_resources = payload["dataResources"] +ssh_user = payload.get("sshUser") or "root" +max_bytes = int(payload.get("maxBytes") or 24000) +region_name = payload.get("region") or "" +tat_fallback_env_files = payload.get("tatFallbackEnvFiles") or [] +tat_fallback_timeout = int(payload.get("tatFallbackTimeout") or 120) +tat_poll_seconds = int(payload.get("tatPollSeconds") or 2) + +ssh_options = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=1", + "-o", "StrictHostKeyChecking=accept-new", +] + +def clean_env_value(value): + return str(value or "").strip().replace("export ", "", 1).strip().strip('"').strip("'") + +def load_env_file(path): + if not path: + return + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + lines = handle.read().splitlines() + except Exception: + return + for raw_line in lines: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip().replace("export ", "", 1) + if key and key not in os.environ: + os.environ[key] = clean_env_value(value) + if "TENCENTCLOUD_SECRET_ID" not in os.environ and os.environ.get("TENCENT_SECRET_ID"): + os.environ["TENCENTCLOUD_SECRET_ID"] = os.environ["TENCENT_SECRET_ID"] + if "TENCENTCLOUD_SECRET_KEY" not in os.environ and os.environ.get("TENCENT_SECRET_KEY"): + os.environ["TENCENTCLOUD_SECRET_KEY"] = os.environ["TENCENT_SECRET_KEY"] + +def cloud_secret_id(): + return os.environ.get("TENCENTCLOUD_SECRET_ID") or os.environ.get("TENCENT_SECRET_ID") or "" + +def cloud_secret_key(): + return os.environ.get("TENCENTCLOUD_SECRET_KEY") or os.environ.get("TENCENT_SECRET_KEY") or "" + +def build_tat_client(): + for env_file in tat_fallback_env_files: + load_env_file(env_file) + sid = cloud_secret_id() + skey = cloud_secret_key() + missing = [] + if not sid: + missing.append("TENCENTCLOUD_SECRET_ID") + if not skey: + missing.append("TENCENTCLOUD_SECRET_KEY") + if not region_name: + missing.append("TENCENTCLOUD_REGION") + if missing: + raise RuntimeError("missing Tencent Cloud credentials on deploy machine: " + ", ".join(missing)) + from tencentcloud.common import credential + from tencentcloud.common.profile.client_profile import ClientProfile + from tencentcloud.common.profile.http_profile import HttpProfile + from tencentcloud.tat.v20201028 import tat_client + return tat_client.TatClient( + credential.Credential(sid, skey), + region_name, + ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")), + ) + +def invoke_tencent(action, label, retries=3): + try: + from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException + except Exception: + TencentCloudSDKException = Exception + last_error = None + for attempt in range(1, retries + 1): + try: + return action() + except TencentCloudSDKException as exc: + last_error = exc + if attempt == retries: + break + time.sleep(2 * attempt) + raise RuntimeError(f"{{label}} failed: {{last_error}}") from last_error + +def decode_tat_output(output): + if not output: + return "" + return base64.b64decode(output).decode("utf-8", errors="replace") + +def run_tat_shell(instance_id, script, command_name): + from tencentcloud.tat.v20201028 import models as tat_models + + client = build_tat_client() + request = tat_models.RunCommandRequest() + request.from_json_string( + json.dumps( + {{ + "CommandName": command_name, + "CommandType": "SHELL", + "Content": base64.b64encode(script.encode("utf-8")).decode("ascii"), + "InstanceIds": [instance_id], + "Timeout": tat_fallback_timeout, + "WorkingDirectory": "/root", + }} + ) + ) + response = json.loads(invoke_tencent(lambda: client.RunCommand(request), "RunCommand").to_json_string()) + invocation_id = response["InvocationId"] + deadline = time.time() + tat_fallback_timeout + 60 + while time.time() < deadline: + time.sleep(max(tat_poll_seconds, 1)) + query = tat_models.DescribeInvocationTasksRequest() + query.from_json_string( + json.dumps( + {{ + "HideOutput": False, + "Limit": 50, + "Filters": [{{"Name": "invocation-id", "Values": [invocation_id]}}], + }} + ) + ) + payload = json.loads(invoke_tencent(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").to_json_string()) + for task in payload.get("InvocationTaskSet") or []: + if str(task.get("InstanceId") or "") != instance_id: + continue + status = str(task.get("TaskStatus") or "") + if status in {{"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}}: + return {{ + "status": status, + "output": decode_tat_output(str((task.get("TaskResult") or {{}}).get("Output") or "")), + }} + return {{"status": "TIMEOUT", "output": ""}} + +remote_reader = r''' +import json +import os +import subprocess +import sys + +payload = json.load(sys.stdin) +max_bytes = int(payload.get("maxBytes") or 24000) + +def read_path(path): + item = {{"content": "", "exists": False, "path": path, "truncated": False}} + if not path: + return item + item["exists"] = os.path.exists(path) + if item["exists"]: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + content = handle.read(max_bytes + 1) + item["truncated"] = len(content) > max_bytes + item["content"] = content[:max_bytes] + return item + +def command(args): + try: + return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=5).strip() + except Exception as exc: + return str(exc) + +items = [] +for service in payload.get("services") or []: + seen = [] + paths = [] + for path in [service.get("configPath") or "", service.get("envFile") or ""]: + if path and path not in seen: + seen.append(path) + paths.append(read_path(path)) + items.append({{ + "name": service.get("name") or "", + "paths": paths, + "unit": service.get("unit") or "", + "unitState": command(["systemctl", "is-active", service.get("unit") or ""]) if service.get("unit") else "", + }}) + +print(json.dumps({{"ok": True, "services": items}}, ensure_ascii=False)) +''' + +def run_tat_fallback(host_name, host, host_services, reason): + instance_id = str(host.get("instance_id") or "").strip() + private_ip = str(host.get("private_ip") or "").strip() + if not instance_id: + return {{ + "error": "ssh failed and instance_id missing: " + reason, + "host": host_name, + "privateIp": private_ip, + "services": [], + "status": "TAT_FALLBACK_UNAVAILABLE", + "transport": "tat-fallback", + }} + remote_payload = {{ + "maxBytes": max_bytes, + "services": host_services, + }} + reader_blob = base64.b64encode(remote_reader.encode("utf-8")).decode("ascii") + payload_blob = base64.b64encode(json.dumps(remote_payload).encode("utf-8")).decode("ascii") + script = '''set -euo pipefail +python3 - <<'FALLBACKPY' +import base64 +import subprocess +import sys + +code = base64.b64decode(%r).decode("utf-8") +payload_text = base64.b64decode(%r).decode("utf-8") +completed = subprocess.run( + [sys.executable, "-c", code], + input=payload_text, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=30, + check=False, +) +sys.stdout.write(completed.stdout) +raise SystemExit(completed.returncode) +FALLBACKPY +''' % (reader_blob, payload_blob) + try: + result = run_tat_shell(instance_id, script, "hyapp-data-resource-fallback-" + host_name) + except Exception as exc: + return {{ + "error": "ssh failed: " + reason + "; tat fallback failed: " + str(exc), + "host": host_name, + "privateIp": private_ip, + "services": [], + "status": "TAT_FALLBACK_ERROR", + "transport": "tat-fallback", + }} + if result["status"] != "SUCCESS": + return {{ + "error": "ssh failed: " + reason + "; tat fallback status: " + result["status"], + "host": host_name, + "privateIp": private_ip, + "services": [], + "status": "TAT_FALLBACK_ERROR", + "transport": "tat-fallback", + }} + try: + decoded = json.loads(result.get("output") or "{{}}") + except Exception as exc: + return {{ + "error": "ssh failed: " + reason + "; invalid tat fallback json: " + str(exc), + "host": host_name, + "privateIp": private_ip, + "services": [], + "status": "TAT_FALLBACK_ERROR", + "transport": "tat-fallback", + }} + return {{ + "fallbackReason": reason[-500:], + "host": host_name, + "privateIp": private_ip, + "services": decoded.get("services") or [], + "status": "TAT_FALLBACK_SUCCESS", + "transport": "tat-fallback", + }} + +def run_ssh(host_name, host, host_services): + private_ip = str(host.get("private_ip") or "").strip() + if not private_ip: + return {{ + "error": "private_ip missing", + "host": host_name, + "privateIp": "", + "services": [], + "status": "ERROR", + "transport": "ssh", + }} + ssh_host = str(host.get("ssh_user") or ssh_user) + "@" + private_ip + remote_payload = {{ + "maxBytes": max_bytes, + "services": host_services, + }} + try: + completed = subprocess.run( + ["ssh", *ssh_options, ssh_host, "python3", "-c", remote_reader], + input=json.dumps(remote_payload), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=20, + check=False, + ) + except Exception as exc: + return run_tat_fallback(host_name, host, host_services, str(exc)) + if completed.returncode != 0: + return run_tat_fallback(host_name, host, host_services, completed.stdout[-1000:]) + try: + decoded = json.loads(completed.stdout) + except Exception as exc: + return run_tat_fallback(host_name, host, host_services, f"invalid ssh json: {{exc}}") + return {{ + "host": host_name, + "privateIp": private_ip, + "services": decoded.get("services") or [], + "status": "SUCCESS", + "transport": "ssh", + }} + +def endpoint_from_host_port(host, port): + host = str(host or "").strip().strip('"').strip("'") + port = str(port or "").strip() + if not host: + return "" + return host + ((":" + port) if port else "") + +def normalize_endpoint(value, default_port=""): + text = str(value or "").strip().strip('"').strip("'") + if not text: + return "" + tcp_match = re.search(r"@tcp\\(([^)]+)\\)", text) + if tcp_match: + text = tcp_match.group(1) + if "://" in text: + text = text.split("://", 1)[1].split("/", 1)[0] + text = text.split(",", 1)[0].strip() + if not text: + return "" + if ":" not in text and default_port: + text = text + ":" + default_port + return text + +def endpoint_candidates(line, default_port=""): + found = [] + for host, port in re.findall(r"((?:\\d{{1,3}}\\.){{3}}\\d{{1,3}}|[A-Za-z0-9][A-Za-z0-9.-]*):([0-9]{{2,5}})", line): + endpoint = endpoint_from_host_port(host, port) + if endpoint: + found.append(endpoint) + if not found: + endpoint = normalize_endpoint(line.split("=", 1)[-1].split(":", 1)[-1], default_port) + if endpoint and re.search(r"[A-Za-z0-9.:-]", endpoint): + found.append(endpoint) + return found + +def extract_config_observations(text): + observations = {{ + "mq": {{"endpoints": set(), "services": set(), "values": set()}}, + "mysql": {{"databases": set(), "endpoints": set(), "services": set(), "values": set()}}, + "redis": {{"endpoints": set(), "services": set(), "values": set()}}, + }} + + for match in re.finditer(r"@tcp\\(([^)]+)\\)/([^?\\s\\\"']+)", text): + endpoint = normalize_endpoint(match.group(1), "3306") + if endpoint: + observations["mysql"]["endpoints"].add(endpoint) + observations["mysql"]["databases"].add(match.group(2)) + observations["mysql"]["values"].add(endpoint + "/" + match.group(2)) + + section = "" + for raw_line in text.splitlines(): + indent = len(raw_line) - len(raw_line.lstrip()) + line = raw_line.strip() + if not line or line.startswith("#"): + continue + section_match = re.match(r"^([A-Za-z0-9_.-]+):\\s*$", line) + if section_match: + key = section_match.group(1).lower() + if indent == 0 or key in ("redis", "rocketmq"): + section = key + continue + lower = line.lower() + if "mysql" in lower and ("dsn" in lower or "@tcp(" in lower): + endpoint = normalize_endpoint(line, "3306") + if endpoint: + observations["mysql"]["endpoints"].add(endpoint) + if "redis" in lower and ("addr" in lower or "host" in lower or "endpoint" in lower): + for endpoint in endpoint_candidates(line, "6379"): + observations["redis"]["endpoints"].add(endpoint) + if section == "redis" and re.match(r"^(addr|host|endpoint)\\s*:", lower): + for endpoint in endpoint_candidates(line, "6379"): + observations["redis"]["endpoints"].add(endpoint) + if "rocketmq" in lower or "name_server" in lower or "nameserver" in lower or section == "rocketmq": + if re.search(r"((?:\\d{{1,3}}\\.){{3}}\\d{{1,3}}|[A-Za-z0-9][A-Za-z0-9.-]*):([0-9]{{2,5}})", line) or any( + key in lower for key in ("addr", "endpoint", "host", "name_server", "nameserver") + ): + for endpoint in endpoint_candidates(line, "9876"): + observations["mq"]["endpoints"].add(endpoint) + if "topic" in lower or "group" in lower: + observations["mq"]["values"].add(line[:160]) + + return observations + +def merge_observation(target, source, service_name): + for resource_id, fields in source.items(): + target[resource_id]["services"].add(service_name) + for key, values in fields.items(): + if key == "services": + continue + target[resource_id].setdefault(key, set()).update(values) + +def tcp_check(endpoint): + host, sep, raw_port = endpoint.rpartition(":") + if not sep: + return {{"detail": "missing port", "endpoint": endpoint, "status": "unknown"}} + try: + port = int(raw_port) + except ValueError: + return {{"detail": "invalid port", "endpoint": endpoint, "status": "unknown"}} + started = time.time() + try: + with socket.create_connection((host, port), timeout=4): + pass + return {{ + "detail": "connect ok", + "endpoint": endpoint, + "latencyMs": round((time.time() - started) * 1000, 1), + "status": "success", + }} + except Exception as exc: + return {{ + "detail": str(exc), + "endpoint": endpoint, + "status": "failed", + }} + +services_by_host = {{}} +for service in services: + for host_name in service.get("hosts") or []: + services_by_host.setdefault(host_name, []).append(service) + +host_results = [] +for host_name in services_by_host: + host = hosts.get(host_name) or {{}} + host_results.append(run_ssh(host_name, host, services_by_host[host_name])) + +observations = {{ + "mq": {{"endpoints": set(), "services": set(), "values": set()}}, + "mysql": {{"databases": set(), "endpoints": set(), "services": set(), "values": set()}}, + "redis": {{"endpoints": set(), "services": set(), "values": set()}}, +}} + +for host_result in host_results: + if host_result["status"] not in ("SUCCESS", "TAT_FALLBACK_SUCCESS"): + continue + for service in host_result.get("services") or []: + text_parts = [] + for path in service.get("paths") or []: + if path.get("exists"): + text_parts.append(str(path.get("content") or "")) + service_observations = extract_config_observations("\\n".join(text_parts)) + merge_observation(observations, service_observations, str(service.get("name") or "")) + +resources = [] +successful_sources = [item for item in host_results if item["status"] in ("SUCCESS", "TAT_FALLBACK_SUCCESS")] +for resource in data_resources: + resource_id = str(resource.get("id") or "") + resource_type = str(resource.get("type") or resource_id) + observation_key = "mq" if resource_type in ("mq", "rocketmq", "kafka") else resource_type + observed = observations.get(observation_key, {{"endpoints": set(), "services": set(), "values": set()}}) + endpoints = set(str(item) for item in observed.get("endpoints", set()) if str(item).strip()) + for endpoint in resource.get("endpoints") or []: + normalized = normalize_endpoint(endpoint) + if normalized: + endpoints.add(normalized) + checks = [] + for endpoint in sorted(endpoints)[:12]: + checks.append(tcp_check(endpoint)) + if not checks and resource.get("endpoints"): + checks.append({{"detail": "endpoint 格式无法识别", "status": "unknown"}}) + + if not successful_sources: + status = "critical" + elif checks and all(check["status"] == "success" for check in checks): + status = "healthy" + elif any(check["status"] == "success" for check in checks): + status = "warning" + elif checks: + status = "critical" + else: + status = "unknown" + + related_services = sorted(observed.get("services", set())) or resource.get("relatedServices") or [] + databases = sorted(observed.get("databases", set())) if observation_key == "mysql" else [] + resources.append({{ + "accessPath": "TAT -> deploy;服务器配置 SSH/TAT 兜底", + "backupStatus": resource.get("backupStatus") or "未采集", + "capacityUsedPercent": resource.get("capacityUsedPercent"), + "checks": checks, + "connections": resource.get("connections"), + "description": resource.get("description") or "", + "endpoints": sorted(endpoints), + "id": resource_id, + "name": resource.get("name") or resource_id, + "observedDatabases": databases, + "owner": resource.get("owner") or "后端平台", + "relatedServices": related_services, + "slowQueryCount": resource.get("slowQueryCount"), + "sourceHosts": [ + {{ + "error": item.get("error") or "", + "fallbackReason": item.get("fallbackReason") or "", + "host": item["host"], + "privateIp": item.get("privateIp") or "", + "serviceCount": len(item.get("services") or []), + "status": item["status"], + "transport": item.get("transport") or "", + }} + for item in host_results + ], + "status": status, + "topology": resource.get("topology") or "托管资源", + "type": resource_type, + }}) + +summary = {{ + "critical": len([item for item in resources if item["status"] == "critical"]), + "healthy": len([item for item in resources if item["status"] == "healthy"]), + "unknown": len([item for item in resources if item["status"] == "unknown"]), + "warning": len([item for item in resources if item["status"] == "warning"]), +}} +print(json.dumps({{ + "lastSyncedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "ok": True, + "resources": resources, + "sourceHosts": [ + {{ + "error": item.get("error") or "", + "fallbackReason": item.get("fallbackReason") or "", + "host": item["host"], + "privateIp": item.get("privateIp") or "", + "serviceCount": len(item.get("services") or []), + "status": item["status"], + "transport": item.get("transport") or "", + }} + for item in host_results + ], + "summary": summary, +}}, ensure_ascii=False)) +PY +""" + + +def target_payload(inventory: dict[str, Any]) -> dict[str, Any]: + deploy_server = inventory["deploy_server"] + return { + "instanceId": str(deploy_server.get("instance_id") or ""), + "name": str(deploy_server.get("name") or "deploy"), + "privateIp": str(deploy_server.get("private_ip") or ""), + "publicIp": str(deploy_server.get("public_ip") or ""), + } + + +def dry_run_payload(inventory: dict[str, Any], *, max_bytes: int) -> dict[str, Any]: + return { + "dryRun": True, + "ok": True, + "resources": [ + { + "accessPath": "TAT -> deploy;服务器配置 SSH/TAT 兜底", + "backupStatus": resource["backupStatus"], + "capacityUsedPercent": resource["capacityUsedPercent"], + "checks": [], + "connections": resource["connections"], + "description": resource["description"], + "endpoints": resource["endpoints"], + "id": resource["id"], + "name": resource["name"], + "observedDatabases": [], + "owner": resource["owner"], + "relatedServices": resource["relatedServices"], + "slowQueryCount": resource["slowQueryCount"], + "sourceHosts": [], + "status": "unknown", + "topology": resource["topology"], + "type": resource["type"], + } + for resource in normalize_data_resources(inventory) + ], + "sourceHosts": [], + "summary": {"critical": 0, "healthy": 0, "unknown": len(normalize_data_resources(inventory)), "warning": 0}, + "target": target_payload(inventory), + "tat": {"status": "DRY_RUN"}, + "maxBytes": max_bytes, + } + + +def read_resources(args: argparse.Namespace) -> dict[str, Any]: + inventory = load_inventory(args.inventory) + if args.dry_run: + return dry_run_payload(inventory, max_bytes=args.max_bytes) + + deploy_server = inventory["deploy_server"] + deploy_instance_id = str(deploy_server.get("instance_id") or "").strip() + client = build_tat_client(inventory) + result = run_tat_shell( + client, + instance_id=deploy_instance_id, + script=remote_collect_script(build_remote_payload(inventory, max_bytes=args.max_bytes)), + command_name="hyapp-data-resources", + timeout_seconds=args.timeout_seconds, + poll_seconds=args.poll_seconds, + ) + tat = {"instanceId": deploy_instance_id, "status": result["status"], "target": target_payload(inventory)} + if result["status"] != "SUCCESS": + return {"error": "TAT command failed", "ok": False, "output": result.get("output") or "", "resources": [], "tat": tat} + try: + payload = json.loads(str(result.get("output") or "{}")) + except json.JSONDecodeError: + return {"error": "invalid TAT json output", "ok": False, "output": result.get("output") or "", "resources": [], "tat": tat} + payload["target"] = target_payload(inventory) + payload["tat"] = tat + return payload + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Read HYApp data resource status through Tencent TAT on deploy machine.") + parser.add_argument("action", choices=("resources",)) + parser.add_argument("--dry-run", action="store_true", help="Print resources configured in inventory without calling Tencent APIs.") + parser.add_argument("--env-file", default="", help="Optional .env file with Tencent Cloud credentials.") + parser.add_argument("--inventory", default="deploy/tencent-tat/hyapp-services.inventory.prod.example.json") + parser.add_argument("--max-bytes", type=int, default=24_000) + parser.add_argument("--poll-seconds", type=int, default=2) + parser.add_argument("--timeout-seconds", type=int, default=180) + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + load_env_file(args.env_file) + payload = read_resources(args) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 if payload.get("ok") else 1 + except Exception as exc: # noqa: BLE001 + print(json.dumps({"error": str(exc), "ok": False}, ensure_ascii=False), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/tencent-tat/hyapp_tat_deploy.py b/deploy/tencent-tat/hyapp_tat_deploy.py index 488cdec..cc21bd4 100644 --- a/deploy/tencent-tat/hyapp_tat_deploy.py +++ b/deploy/tencent-tat/hyapp_tat_deploy.py @@ -137,6 +137,8 @@ def build_plan( "target_port": int(service["target_port"]), "unit": service["unit"], "container": service["container"], + "env_file": service["env_file"], + "config_path": service.get("config_path") or f"/etc/hyapp/{service_name}/config.yaml", "image": image, "clb_enabled": bool(clb.get("enabled")) and not no_clb, "clb_load_balancer_id": str(clb.get("load_balancer_id") or ""), @@ -507,33 +509,247 @@ def remote_restart_script(service: dict[str, Any], *, image: str, health_timeout return "\n".join(lines) + "\n" -def remote_discover_script() -> str: - # 只读取 hyapp 命名空间内的 unit/env/container,不扫描或修改 Java 项目。 - return r"""set -euo pipefail -echo "== hyapp units ==" -systemctl list-units 'hyapp-*.service' --no-pager --all || true -echo "== hyapp env files ==" -if [ -d /etc/hyapp ]; then - find /etc/hyapp -maxdepth 2 -name docker.env -print 2>/dev/null | sort | while read -r env_file; do - echo "-- $env_file" - sed -n 's/^\(IMAGE\|CONTAINER_NAME\|CONFIG_PATH\|STOP_TIMEOUT_SEC\)=/\1=/p' "$env_file" || true - done -else - echo "/etc/hyapp missing" -fi -echo "== hyapp containers ==" -docker ps --filter 'name=hyapp-' --format '{{.Names}} {{.Image}} {{.Status}}' || true -echo "== listening ports ==" -ss -lntp 2>/dev/null | awk 'NR==1 || /:13000|:13001|:13004|:13005|:13006|:13008|:13009|:13100|:13101|:13104|:13105|:13106|:13108|:13109/' || true +def remote_discover_script(*, host_services: list[dict[str, Any]], include_config: bool, max_config_bytes: int) -> str: + # 只读取 inventory 明确声明的 hyapp unit/env/container/config,不扫描或修改 Java 项目。 + payload = { + "includeConfig": include_config, + "maxConfigBytes": max_config_bytes, + "services": [ + { + "configPath": service.get("config_path") or "", + "container": service.get("container") or "", + "envFile": service.get("env_file") or "", + "name": service.get("service") or "", + "targetPort": service.get("target_port") or 0, + "unit": service.get("unit") or "", + } + for service in host_services + ], + } + blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + script = r"""set -euo pipefail +python3 - <<'PY' +import base64 +import datetime +import json +import os +import re +import subprocess + +payload = json.loads(base64.b64decode(__PAYLOAD__).decode("utf-8")) +services = payload.get("services") or [] +include_config = bool(payload.get("includeConfig")) +max_config_bytes = int(payload.get("maxConfigBytes") or 12000) +secret_key_re = re.compile(r"(secret|password|passwd|token|credential|private[_-]?key|access[_-]?key|sign[_-]?key|dsn)", re.I) + + +def command(args, timeout=8): + try: + return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=timeout).strip() + except Exception as exc: + return str(exc).strip() + + +def percent_value(value): + match = re.search(r"[-+]?\d+(?:\.\d+)?", str(value or "")) + return float(match.group(0)) if match else None + + +def read_file(path, max_bytes): + item = { + "content": "", + "exists": False, + "path": path, + "truncated": False, + } + if not path or not os.path.exists(path): + return item + item["exists"] = True + with open(path, "r", encoding="utf-8", errors="replace") as handle: + content = handle.read(max_bytes + 1) + item["truncated"] = len(content) > max_bytes + item["content"] = content[:max_bytes] + return item + + +def redact_text(text): + redacted = [] + for line in text.splitlines(): + match = re.match(r"^(\s*[-\w.]+\s*(?::|=)\s*)(.*)$", line) + if match and secret_key_re.search(match.group(1)): + redacted.append(match.group(1) + '"***"') + else: + redacted.append(line) + return "\n".join(redacted) + ("\n" if text.endswith("\n") else "") + + +def parse_env(text): + env = {} + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + env[key] = "***" if secret_key_re.search(key) else value + return env + + +def parse_docker_inspect(container): + if not container: + return {} + raw = command(["docker", "inspect", container], timeout=10) + try: + decoded = json.loads(raw) + except Exception: + return {"inspectError": raw[-1000:]} + return decoded[0] if decoded else {} + + +def parse_docker_stats(container): + if not container: + return {} + raw = command(["docker", "stats", "--no-stream", "--format", "{{json .}}", container], timeout=12) + line = raw.splitlines()[0] if raw else "" + try: + stats = json.loads(line) + except Exception: + return {"statsError": raw[-1000:]} + return { + "cpuPercent": percent_value(stats.get("CPUPerc")), + "memoryPercent": percent_value(stats.get("MemPerc")), + "memoryUsage": stats.get("MemUsage") or "", + } + + +def parse_time(value): + text = str(value or "") + if not text or text.startswith("0001-"): + return None + try: + return datetime.datetime.fromisoformat(text.replace("Z", "+00:00")) + except Exception: + return None + + +def duration_text(value): + started_at = parse_time(value) + if started_at is None: + return "未采集" + now = datetime.datetime.now(datetime.timezone.utc) + delta = now - started_at.astimezone(datetime.timezone.utc) + seconds = max(int(delta.total_seconds()), 0) + days, remainder = divmod(seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes = remainder // 60 + if days > 0: + return f"{days} 天 {hours} 小时" + if hours > 0: + return f"{hours} 小时 {minutes} 分钟" + return f"{minutes} 分钟" + + +def image_tag(image): + text = str(image or "") + if ":" not in text: + return text + return text.rsplit(":", 1)[1] + + +def service_status(unit_state, container_status, health_status): + if unit_state == "active" and container_status == "running" and health_status in ("", "healthy"): + return "healthy" + if unit_state == "activating" or container_status in ("created", "restarting") or health_status == "starting": + return "running" + if unit_state == "active" and container_status == "running": + return "warning" + if unit_state or container_status or health_status: + return "critical" + return "unknown" + + +items = [] +for service in services: + name = str(service.get("name") or "") + unit = str(service.get("unit") or "") + container = str(service.get("container") or "") + env_file = str(service.get("envFile") or "") + env_payload = read_file(env_file, max_config_bytes) + env = parse_env(env_payload["content"]) + config_path = env.get("CONFIG_PATH") or str(service.get("configPath") or "") or f"/etc/hyapp/{name}/config.yaml" + config_payload = read_file(config_path, max_config_bytes) if include_config else { + "content": "", + "exists": os.path.exists(config_path) if config_path else False, + "path": config_path, + "truncated": False, + } + inspect = parse_docker_inspect(container) + state = inspect.get("State") or {} + config = inspect.get("Config") or {} + health = (state.get("Health") or {}).get("Status") or "" + image = env.get("IMAGE") or config.get("Image") or "" + started_at = state.get("StartedAt") or "" + created_at = inspect.get("Created") or "" + stats = parse_docker_stats(container) + target_port = int(service.get("targetPort") or 0) + listeners = command(["bash", "-lc", f"ss -lntp 2>/dev/null | grep ':{target_port} ' || true"]) if target_port else "" + logs = command(["journalctl", "-u", unit, "-n", "30", "--no-pager", "-o", "short-iso"], timeout=10) if include_config and unit else "" + + items.append({ + "configExists": bool(config_payload["exists"]), + "configPath": config_path, + "configTruncated": bool(config_payload["truncated"]), + "configYaml": redact_text(config_payload["content"]) if include_config and config_payload["exists"] else "", + "container": container, + "containerId": str(inspect.get("Id") or "")[:12], + "containerStatus": str(state.get("Status") or ""), + "cpuPercent": stats.get("cpuPercent"), + "createdAt": created_at, + "env": env, + "envFile": env_file, + "envFileExists": bool(env_payload["exists"]), + "healthStatus": health, + "image": image, + "imageTag": image_tag(image), + "listeners": listeners, + "logs": logs, + "memoryPercent": stats.get("memoryPercent"), + "memoryUsage": stats.get("memoryUsage") or "", + "name": name, + "restartCount": int(inspect.get("RestartCount") or 0) if inspect else 0, + "runningTime": duration_text(started_at), + "startedAt": started_at, + "status": service_status(command(["systemctl", "is-active", unit]) if unit else "", str(state.get("Status") or ""), health), + "targetPort": target_port, + "unit": unit, + "unitState": command(["systemctl", "is-active", unit]) if unit else "", + }) + +print(json.dumps({ + "hostname": command(["hostname"]), + "ok": True, + "services": items, +}, ensure_ascii=False)) +PY """ + return script.replace("__PAYLOAD__", repr(blob)) -def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool) -> dict[str, Any]: +def execute_discovery( + inventory: dict[str, Any], + plan: list[dict[str, Any]], + *, + dry_run: bool, + include_config: bool, + max_config_bytes: int, +) -> dict[str, Any]: if dry_run: return {"ok": True, "dry_run": True, "steps": plan} clb_client = build_clb_client(inventory) seen_hosts = list(dict.fromkeys(step["host"] for step in plan)) by_host = {name: next(step for step in plan if step["host"] == name) for name in seen_hosts} + services_by_host = {name: [step for step in plan if step["host"] == name] for name in seen_hosts} def discover_host(host_name: str, seed: dict[str, Any]) -> dict[str, Any]: try: @@ -541,17 +757,28 @@ def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *, result = run_tat_shell( tat_client, instance_id=seed["instance_id"], - script=remote_discover_script(), + script=remote_discover_script( + host_services=services_by_host[host_name], + include_config=include_config, + max_config_bytes=max_config_bytes, + ), command_name=f"hyapp-discover-{host_name}", timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900), poll_seconds=int(inventory.get("tat_poll_seconds") or 2), ) + output = str(result.get("output") or "") + try: + payload = json.loads(output or "{}") + except json.JSONDecodeError: + payload = {} + services = list(payload.get("services") or []) return { "host": host_name, "instance_id": seed["instance_id"], "private_ip": seed["private_ip"], "status": result["status"], - "output": str(result.get("output") or "")[-5000:], + "output": "" if services else output[-5000:], + "services": services, } except Exception as exc: # noqa: BLE001 return { @@ -560,6 +787,7 @@ def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *, "private_ip": seed["private_ip"], "status": "ERROR", "error": str(exc), + "services": [], } host_results: dict[str, dict[str, Any]] = {} @@ -670,6 +898,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--image", action="append", default=[], help="Override one image: service=image. Can be repeated.") parser.add_argument("--no-clb", action="store_true", help="Skip CLB drain. Only use for isolated emergency maintenance.") parser.add_argument("--dry-run", action="store_true", help="Print the execution plan without calling Tencent APIs.") + parser.add_argument("--include-config", action="store_true", help="Include redacted service config YAML in discover output.") + parser.add_argument("--max-config-bytes", type=int, default=12_000, help="Maximum bytes to read from each config file during discover.") parser.add_argument("--yes", action="store_true", help="Required for real restart/deploy execution.") return parser.parse_args(argv) @@ -695,7 +925,13 @@ def main(argv: list[str]) -> int: print(json.dumps({"ok": True, "steps": plan}, ensure_ascii=False, indent=2)) return 0 if args.action == "discover": - result = execute_discovery(inventory, plan, dry_run=args.dry_run) + result = execute_discovery( + inventory, + plan, + dry_run=args.dry_run, + include_config=args.include_config, + max_config_bytes=args.max_config_bytes, + ) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if result.get("ok") else 1 result = execute_plan(inventory, plan, dry_run=args.dry_run, assume_yes=args.yes) diff --git a/deploy/tencent-tat/hyapp_testbox_config.py b/deploy/tencent-tat/hyapp_testbox_config.py index 98fc2a2..2ddcc95 100755 --- a/deploy/tencent-tat/hyapp_testbox_config.py +++ b/deploy/tencent-tat/hyapp_testbox_config.py @@ -27,6 +27,9 @@ DEFAULT_SERVICES = ( "admin-server", ) SERVICE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +MIGRATION_FILE_RE = re.compile(r"^[0-9][A-Za-z0-9_.-]*\.sql$") +SERVICE_OPERATIONS = {"restart", "start", "stop", "update"} +TIMER_OPERATIONS = {"start", "stop", "restart", "status"} def load_env_file(path: str) -> None: @@ -79,6 +82,21 @@ def parse_services(raw_services: str) -> list[str]: return list(dict.fromkeys(services)) +def parse_migration_files(raw_files: str) -> list[str]: + files = csv_values(raw_files) + invalid = [file for file in files if not MIGRATION_FILE_RE.match(file)] + if invalid: + raise RuntimeError(f"invalid migration file names: {', '.join(invalid)}") + return list(dict.fromkeys(files)) + + +def parse_operation(operation: str, allowed: set[str], label: str) -> str: + value = str(operation or "").strip() + if value not in allowed: + raise RuntimeError(f"invalid {label} operation: {value}") + return value + + def build_tat_client(region: str) -> Any: sid, skey = require_credentials() try: @@ -191,7 +209,7 @@ root = payload["root"] services = payload["services"] redact = bool(payload["redact"]) max_bytes = int(payload["maxBytes"]) -secret_key_re = re.compile(r"(secret|password|passwd|token|credential|private[_-]?key|access[_-]?key|dsn)", re.I) +secret_key_re = re.compile(r"(secret|password|passwd|token|credential|private[_-]?key|access[_-]?key|sign[_-]?key|dsn)", re.I) def service_path(service): if service == "admin-server": @@ -252,6 +270,507 @@ PY """ +def remote_status_script(*, root: str, services: list[str]) -> str: + payload = { + "root": root, + "services": services, + } + blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + return f"""set -euo pipefail +python3 - <<'PY' +import base64 +import json +import os +import subprocess + +payload = json.loads(base64.b64decode({blob!r}).decode("utf-8")) +root = payload["root"] +services = payload["services"] +source = os.path.join(root, "source") +timer = "hyapp-server-test-deploy.timer" +deploy_service = "hyapp-server-test-deploy.service" + +def command_output(args, timeout=12): + try: + return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=timeout).strip() + except Exception as exc: + return str(exc) + +def compose_args(): + args = ["docker", "compose", "-p", "hyapp-test", "-f", os.path.join(source, "docker-compose.yml")] + override = os.path.join(root, "docker-compose.testbox.yml") + if os.path.exists(override): + args.extend(["-f", override]) + return args + +def service_path(service): + if service == "admin-server": + return os.path.join(root, "admin-server", "config.yaml") + return os.path.join(source, "services", service, "configs", "config.docker.yaml") + +def inspect_container(name): + raw = command_output(["docker", "inspect", name], timeout=8) + try: + data = json.loads(raw)[0] + except Exception: + return {{ + "composeService": name, + "configPath": service_path(name), + "exists": False, + "health": "unknown", + "image": "", + "name": name, + "restartCount": 0, + "service": name, + "startedAt": "", + "state": "missing", + "status": "unknown", + }} + state = data.get("State") or {{}} + health = (state.get("Health") or {{}}).get("Status") or "none" + return {{ + "composeService": name, + "configPath": service_path(name), + "containerId": str(data.get("Id") or "")[:12], + "exists": True, + "health": health, + "image": (data.get("Config") or {{}}).get("Image") or "", + "name": data.get("Name", "").lstrip("/") or name, + "restartCount": state.get("RestartCount") or 0, + "service": name, + "startedAt": state.get("StartedAt") or "", + "state": state.get("Status") or "unknown", + "status": "healthy" if state.get("Status") == "running" and health in ("healthy", "none") else ("running" if state.get("Status") == "running" else "critical"), + }} + +service_states = [] +for service in services: + if service == "admin-server": + active = command_output(["systemctl", "is-active", "hyapp-admin-server.service"], timeout=5) + service_states.append({{ + "composeService": "hyapp-admin-server.service", + "configPath": service_path(service), + "exists": active not in ("inactive", "unknown"), + "health": "systemd", + "image": "systemd", + "name": "hyapp-admin-server.service", + "restartCount": 0, + "service": service, + "startedAt": command_output(["systemctl", "show", "hyapp-admin-server.service", "--property=ActiveEnterTimestamp", "--value"], timeout=5), + "state": active, + "status": "healthy" if active == "active" else ("unknown" if active.startswith("Command") else "critical"), + }}) + else: + service_states.append(inspect_container(service)) + +remote_test_raw = command_output(["git", "-C", source, "ls-remote", "origin", "refs/heads/test"], timeout=15) + +result = {{ + "deployedCommit": command_output(["bash", "-lc", f"cat {{root}}/.deployed_commit 2>/dev/null || true"], timeout=5), + "deployServiceState": command_output(["systemctl", "is-active", deploy_service], timeout=5), + "git": {{ + "branch": command_output(["git", "-C", source, "rev-parse", "--abbrev-ref", "HEAD"], timeout=8), + "localCommit": command_output(["git", "-C", source, "rev-parse", "HEAD"], timeout=8), + "remoteTestCommit": remote_test_raw.split()[0] if remote_test_raw else "", + }}, + "ok": True, + "services": service_states, + "target": {{ + "instanceId": "lhins-q0m38zc6", + "privateIp": "10.11.0.2", + "publicIp": "43.165.195.39", + "root": root, + "timer": timer, + }}, + "timerState": command_output(["systemctl", "is-active", timer], timeout=5), + "updatedAt": command_output(["date", "-u", "+%Y-%m-%dT%H:%M:%SZ"], timeout=5), +}} +print(json.dumps(result, ensure_ascii=False)) +PY +""" + + +def remote_service_action_script(*, root: str, services: list[str], operation: str) -> str: + payload = { + "operation": operation, + "root": root, + "services": services, + } + blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + return f"""set -euo pipefail +python3 - <<'PY' +import base64 +import json +import os +import subprocess + +payload = json.loads(base64.b64decode({blob!r}).decode("utf-8")) +root = payload["root"] +source = os.path.join(root, "source") +services = payload["services"] +operation = payload["operation"] + +def run(args, cwd=None, timeout=900): + proc = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout) + return {{"args": args, "code": proc.returncode, "output": proc.stdout.strip()}} + +def compose_args(): + args = ["docker", "compose", "-p", "hyapp-test", "-f", os.path.join(source, "docker-compose.yml")] + override = os.path.join(root, "docker-compose.testbox.yml") + if os.path.exists(override): + args.extend(["-f", override]) + return args + +go_services = [service for service in services if service != "admin-server"] +has_admin = "admin-server" in services +commands = [] +ok = True + +def record(result): + global ok + commands.append(result) + if result["code"] != 0: + ok = False + +if operation == "update": + record(run(["git", "fetch", "origin", "test"], cwd=source, timeout=120)) + if ok: + record(run(["git", "checkout", "test"], cwd=source, timeout=60)) + if ok: + record(run(["git", "pull", "--ff-only", "origin", "test"], cwd=source, timeout=180)) + if ok and go_services: + record(run(compose_args() + ["up", "-d", "--build"] + go_services, cwd=source, timeout=1200)) + if ok and has_admin: + record(run(["systemctl", "restart", "hyapp-admin-server.service"], timeout=180)) + if ok and not services: + record(run([os.path.join(root, "bin", "deploy.sh")], timeout=1800)) +elif operation in ("restart", "start", "stop"): + if go_services: + compose_operation = "up" if operation == "start" else operation + extra = ["-d"] + go_services if operation == "start" else go_services + record(run(compose_args() + [compose_operation] + extra, cwd=source, timeout=420)) + if has_admin: + record(run(["systemctl", operation, "hyapp-admin-server.service"], timeout=180)) +else: + ok = False + commands.append({{"args": [], "code": 1, "output": f"unsupported operation: {{operation}}"}}) + +status = run(compose_args() + ["ps"], cwd=source, timeout=60) +print(json.dumps({{ + "commands": commands, + "ok": ok, + "operation": operation, + "services": services, + "status": status, +}}, ensure_ascii=False)) +PY +""" + + +def remote_timer_action_script(*, root: str, operation: str) -> str: + payload = { + "operation": operation, + "root": root, + } + blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + return f"""set -euo pipefail +python3 - <<'PY' +import base64 +import json +import subprocess + +payload = json.loads(base64.b64decode({blob!r}).decode("utf-8")) +operation = payload["operation"] +timer = "hyapp-server-test-deploy.timer" + +def run(args, timeout=120): + proc = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout) + return {{"args": args, "code": proc.returncode, "output": proc.stdout.strip()}} + +commands = [] +if operation == "status": + commands.append(run(["systemctl", "status", timer, "--no-pager"], timeout=30)) +else: + commands.append(run(["systemctl", operation, timer], timeout=120)) +state = run(["systemctl", "is-active", timer], timeout=20) +print(json.dumps({{ + "commands": commands, + "ok": all(command["code"] == 0 for command in commands), + "operation": operation, + "timerState": state["output"], +}}, ensure_ascii=False)) +PY +""" + + +def remote_write_config_script(*, root: str, service: str, content_b64: str, restart: bool) -> str: + payload = { + "content": content_b64, + "restart": restart, + "root": root, + "service": service, + } + blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + return f"""set -euo pipefail +python3 - <<'PY' +import base64 +import json +import os +import shutil +import subprocess +import time + +payload = json.loads(base64.b64decode({blob!r}).decode("utf-8")) +root = payload["root"] +source = os.path.join(root, "source") +service = payload["service"] +restart = bool(payload["restart"]) +content = base64.b64decode(payload["content"].encode("ascii")) +if b"\\x00" in content: + raise RuntimeError("config content contains NUL byte") +if len(content) > 60000: + raise RuntimeError("config content is too large") + +def service_path(name): + if name == "admin-server": + return os.path.join(root, "admin-server", "config.yaml") + return os.path.join(source, "services", name, "configs", "config.docker.yaml") + +def compose_args(): + args = ["docker", "compose", "-p", "hyapp-test", "-f", os.path.join(source, "docker-compose.yml")] + override = os.path.join(root, "docker-compose.testbox.yml") + if os.path.exists(override): + args.extend(["-f", override]) + return args + +def run(args, cwd=None, timeout=300): + proc = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout) + return {{"args": args, "code": proc.returncode, "output": proc.stdout.strip()}} + +path = service_path(service) +os.makedirs(os.path.dirname(path), exist_ok=True) +backup = "" +if os.path.exists(path): + backup = path + ".bak." + time.strftime("%Y%m%d%H%M%S", time.gmtime()) + shutil.copy2(path, backup) +with open(path, "wb") as handle: + handle.write(content) +if backup: + shutil.copymode(backup, path) +else: + os.chmod(path, 0o600) + +commands = [] +if restart: + if service == "admin-server": + commands.append(run(["systemctl", "restart", "hyapp-admin-server.service"], timeout=180)) + else: + commands.append(run(compose_args() + ["restart", service], cwd=source, timeout=300)) + +print(json.dumps({{ + "backupPath": backup, + "commands": commands, + "ok": all(command["code"] == 0 for command in commands), + "path": path, + "restarted": restart, + "service": service, +}}, ensure_ascii=False)) +PY +""" + + +def remote_migrations_script(*, root: str, files: list[str], operation: str, dry_run: bool) -> str: + payload = { + "dryRun": dry_run, + "files": files, + "operation": operation, + "root": root, + } + blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + return f"""set -euo pipefail +python3 - <<'PY' +import base64 +import glob +import hashlib +import json +import os +import re +import shlex +import shutil +import subprocess +import time + +payload = json.loads(base64.b64decode({blob!r}).decode("utf-8")) +root = payload["root"] +source = os.path.join(root, "source") +operation = payload["operation"] +requested_files = payload["files"] +dry_run = bool(payload["dryRun"]) +migration_dir = os.path.join(source, "server", "admin", "migrations") +config_path = os.path.join(root, "admin-server", "config.yaml") + +def read_text(path): + with open(path, "r", encoding="utf-8", errors="replace") as handle: + return handle.read() + +def sha256_file(path): + with open(path, "rb") as handle: + return hashlib.sha256(handle.read()).hexdigest() + +def parse_dsn(): + if not os.path.exists(config_path): + return None + text = read_text(config_path) + match = re.search(r'^mysql_dsn:\\s*["\\']?([^"\\'\\n]+)', text, re.M) + if not match: + return None + dsn = match.group(1).strip() + match = re.match(r"([^:@/]+):([^@]*)@tcp\\(([^:)]+)(?::(\\d+))?\\)/([^?]+)", dsn) + if not match: + return None + return {{ + "database": match.group(5), + "host": match.group(3), + "password": match.group(2), + "port": match.group(4) or "3306", + "user": match.group(1), + }} + +def mysql_args(target): + return [ + "mysql", + "--batch", + "--raw", + "--skip-column-names", + "-h", + target["host"], + "-P", + target["port"], + "-u", + target["user"], + f"-p{{target['password']}}", + target["database"], + ] + +def run_mysql(target, sql, timeout=120): + proc = subprocess.run(mysql_args(target), input=sql, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout) + return {{"code": proc.returncode, "output": proc.stdout.strip()}} + +def list_files(): + rows = [] + for path in sorted(glob.glob(os.path.join(migration_dir, "*.sql"))): + name = os.path.basename(path) + rows.append({{ + "applied": False, + "appliedAtMs": 0, + "checksum": sha256_file(path), + "dirty": False, + "path": path, + "status": "pending", + "version": name, + }}) + return rows + +def sql_quote(value): + return "'" + str(value).replace("\\\\", "\\\\\\\\").replace("'", "''") + "'" + +target = parse_dsn() +mysql_available = bool(shutil.which("mysql")) +migrations = list_files() +applied = {{}} +error = "" +if target and mysql_available: + ensure = run_mysql(target, \"\"\" +CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(128) PRIMARY KEY, + checksum CHAR(64) NOT NULL DEFAULT '', + dirty BOOLEAN NOT NULL DEFAULT FALSE, + applied_at_ms BIGINT NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +\"\"\", timeout=60) + if ensure["code"] == 0: + query = run_mysql(target, "SELECT version, checksum, dirty, applied_at_ms FROM schema_migrations;", timeout=60) + if query["code"] == 0: + for line in query["output"].splitlines(): + parts = line.split("\\t") + if len(parts) >= 4: + applied[parts[0]] = {{"checksum": parts[1], "dirty": parts[2] in ("1", "true", "TRUE"), "appliedAtMs": int(parts[3] or 0)}} + else: + error = query["output"] + else: + error = ensure["output"] + +for row in migrations: + current = applied.get(row["version"]) + if current: + row["applied"] = True + row["appliedAtMs"] = current["appliedAtMs"] + row["dirty"] = current["dirty"] + if current["dirty"]: + row["status"] = "dirty" + elif current["checksum"] and current["checksum"] != row["checksum"]: + row["status"] = "checksum_changed" + else: + row["status"] = "applied" + +commands = [] +if operation == "apply": + if dry_run: + commands.append({{"code": 0, "output": "dry run only"}}) + elif not target: + commands.append({{"code": 1, "output": "admin-server mysql_dsn not found or unsupported"}}) + elif not mysql_available: + commands.append({{"code": 1, "output": "mysql client is not installed on testbox"}}) + elif error: + commands.append({{"code": 1, "output": error}}) + else: + selected = requested_files or [row["version"] for row in migrations if row["status"] == "pending"] + by_version = {{row["version"]: row for row in migrations}} + for version in selected: + row = by_version.get(version) + if not row: + commands.append({{"code": 1, "output": f"migration not found: {{version}}"}}) + break + if row["status"] == "applied": + commands.append({{"code": 0, "output": f"skip applied {{version}}"}}) + continue + if row["status"] in ("dirty", "checksum_changed"): + commands.append({{"code": 1, "output": f"migration {{version}} is {{row['status']}}"}}) + break + checksum = row["checksum"] + insert = run_mysql(target, f"INSERT INTO schema_migrations(version, checksum, dirty) VALUES ({{sql_quote(version)}}, {{sql_quote(checksum)}}, TRUE);", timeout=60) + commands.append({{"code": insert["code"], "output": f"mark dirty {{version}}\\n{{insert['output']}}".strip()}}) + if insert["code"] != 0: + break + with open(row["path"], "r", encoding="utf-8", errors="replace") as handle: + body = handle.read() + apply = run_mysql(target, body, timeout=600) + commands.append({{"code": apply["code"], "output": f"apply {{version}}\\n{{apply['output']}}".strip()}}) + if apply["code"] != 0: + break + applied_at_ms = int(time.time() * 1000) + finish = run_mysql(target, f"UPDATE schema_migrations SET dirty = FALSE, checksum = {{sql_quote(checksum)}}, applied_at_ms = {{applied_at_ms}} WHERE version = {{sql_quote(version)}};", timeout=60) + commands.append({{"code": finish["code"], "output": f"finish {{version}}\\n{{finish['output']}}".strip()}}) + if finish["code"] != 0: + break + +ok = not commands or all(command["code"] == 0 for command in commands) +print(json.dumps({{ + "commands": commands, + "configPath": config_path, + "database": {{"database": target["database"], "host": target["host"], "port": target["port"], "user": target["user"]}} if target else None, + "dryRun": dry_run, + "error": error, + "migrationDir": migration_dir, + "migrations": migrations, + "mysqlAvailable": mysql_available, + "ok": ok, + "operation": operation, +}}, ensure_ascii=False)) +PY +""" + + def read_configs(args: argparse.Namespace) -> dict[str, Any]: services = parse_services(args.services) client = build_tat_client(args.region) @@ -272,17 +791,111 @@ def read_configs(args: argparse.Namespace) -> dict[str, Any]: return payload +def parse_tat_json(result: dict[str, Any]) -> dict[str, Any]: + if result["status"] != "SUCCESS": + return {"ok": False, "status": result["status"], "output": result.get("output") or ""} + try: + payload = json.loads(str(result.get("output") or "{}")) + except json.JSONDecodeError: + return {"ok": False, "status": result["status"], "output": result.get("output") or ""} + return payload + + +def read_status(args: argparse.Namespace) -> dict[str, Any]: + services = parse_services(args.services) + client = build_tat_client(args.region) + result = run_tat_shell( + client, + instance_id=args.instance_id, + script=remote_status_script(root=args.root, services=services), + command_name="hyapp-testbox-status", + timeout_seconds=args.timeout_seconds, + poll_seconds=args.poll_seconds, + ) + return parse_tat_json(result) + + +def run_service_action(args: argparse.Namespace) -> dict[str, Any]: + services = parse_services(args.services) + operation = parse_operation(args.operation, SERVICE_OPERATIONS, "service") + client = build_tat_client(args.region) + result = run_tat_shell( + client, + instance_id=args.instance_id, + script=remote_service_action_script(root=args.root, services=services, operation=operation), + command_name=f"hyapp-testbox-service-{operation}", + timeout_seconds=args.timeout_seconds, + poll_seconds=args.poll_seconds, + ) + return parse_tat_json(result) + + +def run_timer_action(args: argparse.Namespace) -> dict[str, Any]: + operation = parse_operation(args.operation, TIMER_OPERATIONS, "timer") + client = build_tat_client(args.region) + result = run_tat_shell( + client, + instance_id=args.instance_id, + script=remote_timer_action_script(root=args.root, operation=operation), + command_name=f"hyapp-testbox-timer-{operation}", + timeout_seconds=args.timeout_seconds, + poll_seconds=args.poll_seconds, + ) + return parse_tat_json(result) + + +def write_config(args: argparse.Namespace) -> dict[str, Any]: + if not args.service: + raise RuntimeError("service is required for write-config") + service = parse_services(args.service)[0] + raw_content = base64.b64decode(args.content_base64.encode("ascii")) + if len(raw_content) > 60_000: + raise RuntimeError("config content is too large") + content_b64 = base64.b64encode(raw_content).decode("ascii") + client = build_tat_client(args.region) + result = run_tat_shell( + client, + instance_id=args.instance_id, + script=remote_write_config_script(root=args.root, service=service, content_b64=content_b64, restart=args.restart), + command_name=f"hyapp-testbox-write-config-{service}", + timeout_seconds=args.timeout_seconds, + poll_seconds=args.poll_seconds, + ) + return parse_tat_json(result) + + +def read_or_apply_migrations(args: argparse.Namespace) -> dict[str, Any]: + operation = "apply" if args.action == "migrations-apply" else "list" + files = parse_migration_files(args.files) + client = build_tat_client(args.region) + result = run_tat_shell( + client, + instance_id=args.instance_id, + script=remote_migrations_script(root=args.root, files=files, operation=operation, dry_run=args.dry_run), + command_name=f"hyapp-testbox-migrations-{operation}", + timeout_seconds=args.timeout_seconds, + poll_seconds=args.poll_seconds, + ) + return parse_tat_json(result) + + def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Read HYApp testbox deployment config via Tencent TAT.") - parser.add_argument("action", choices=("configs",)) + parser = argparse.ArgumentParser(description="Manage HYApp testbox through Tencent TAT.") + parser.add_argument("action", choices=("configs", "status", "service", "timer", "write-config", "migrations", "migrations-apply")) parser.add_argument("--env-file", default="", help="optional env file with Tencent Cloud credentials") parser.add_argument("--region", default=DEFAULT_REGION) parser.add_argument("--instance-id", default=DEFAULT_INSTANCE_ID) parser.add_argument("--root", default=DEFAULT_ROOT) parser.add_argument("--services", default=",".join(DEFAULT_SERVICES)) + parser.add_argument("--service", default="", help="single service for write-config") + parser.add_argument("--operation", default="", help="service or timer operation") + parser.add_argument("--content-base64", default="", help="base64 encoded YAML content for write-config") + parser.add_argument("--restart", action="store_true", help="restart service after write-config") + parser.add_argument("--files", default="", help="comma separated migration file names") + parser.add_argument("--dry-run", action="store_true", help="validate action without applying migrations") parser.add_argument("--raw", action="store_true", help="return unredacted YAML") parser.add_argument("--max-bytes", type=int, default=20_000) - parser.add_argument("--timeout-seconds", type=int, default=120) + parser.add_argument("--timeout-seconds", type=int, default=1200) parser.add_argument("--poll-seconds", type=int, default=2) return parser @@ -292,7 +905,20 @@ def main() -> int: args = parser.parse_args() try: load_env_file(args.env_file) - payload = read_configs(args) + if args.action == "configs": + payload = read_configs(args) + elif args.action == "status": + payload = read_status(args) + elif args.action == "service": + payload = run_service_action(args) + elif args.action == "timer": + payload = run_timer_action(args) + elif args.action == "write-config": + payload = write_config(args) + elif args.action in {"migrations", "migrations-apply"}: + payload = read_or_apply_migrations(args) + else: + raise RuntimeError(f"unsupported action: {args.action}") print(json.dumps(payload, ensure_ascii=False, indent=2)) return 0 if payload.get("ok") else 1 except Exception as exc: # noqa: BLE001 diff --git a/src/app/router.tsx b/src/app/router.tsx index 043ba6a..84047d7 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,13 +1,11 @@ import { createBrowserRouter, Navigate } from "react-router-dom"; import { App } from "./App"; -import { AuditPage } from "../pages/audit"; -import { LogsEventsPage } from "../pages/logs-events"; import { OverviewPage } from "../pages/overview"; -import { PlaceholderPage } from "../pages/placeholder"; import { ReleasesPage } from "../pages/releases"; import { ResourcesPage } from "../pages/resources"; import { ServiceDetailPage } from "../pages/service-detail"; import { ServicesPage } from "../pages/services"; +import { TestServicesPage } from "../pages/test-services"; export const router = createBrowserRouter([ { @@ -18,12 +16,9 @@ export const router = createBrowserRouter([ { path: "overview", element: }, { path: "services", element: }, { path: "services/:serviceId", element: }, + { path: "test-services", element: }, { path: "releases", element: }, { path: "resources", element: }, - { path: "logs-events", element: }, - { path: "audit", element: }, - { path: "settings", element: }, - { path: "alerts", element: }, { path: "*", element: }, ], }, diff --git a/src/entities/data-resource/api/getDataResources.ts b/src/entities/data-resource/api/getDataResources.ts new file mode 100644 index 0000000..07165a1 --- /dev/null +++ b/src/entities/data-resource/api/getDataResources.ts @@ -0,0 +1,30 @@ +import type { DataResourcesResponse } from "../model/types"; + +async function readJsonResponse(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? ""; + + if (!response.ok) { + if (contentType.includes("application/json")) { + const payload = (await response.json()) as { detail?: string; error?: string }; + const detail = payload.detail || payload.error; + throw new Error(detail ? `Deploy API HTTP ${response.status}: ${detail}` : `Deploy API HTTP ${response.status}`); + } + throw new Error(`Deploy API HTTP ${response.status}`); + } + + if (!contentType.includes("application/json")) { + throw new Error("Deploy API returned non-JSON response"); + } + + return (await response.json()) as T; +} + +export async function getDataResources(): Promise { + const response = await fetch("/deploy/api/hyapp/resources?remote=1", { + headers: { + accept: "application/json", + }, + }); + + return readJsonResponse(response); +} diff --git a/src/entities/data-resource/api/useDataResourcesQuery.ts b/src/entities/data-resource/api/useDataResourcesQuery.ts new file mode 100644 index 0000000..3203738 --- /dev/null +++ b/src/entities/data-resource/api/useDataResourcesQuery.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "../../../shared/api/queryKeys"; +import { getDataResources } from "./getDataResources"; + +const DATA_RESOURCE_REFRESH_INTERVAL_MS = 60_000; + +export function useDataResourcesQuery() { + return useQuery({ + queryFn: getDataResources, + queryKey: queryKeys.dataResources(), + refetchInterval: DATA_RESOURCE_REFRESH_INTERVAL_MS, + retry: 0, + }); +} diff --git a/src/entities/data-resource/index.ts b/src/entities/data-resource/index.ts new file mode 100644 index 0000000..547e9ed --- /dev/null +++ b/src/entities/data-resource/index.ts @@ -0,0 +1,11 @@ +export { useDataResourcesQuery } from "./api/useDataResourcesQuery"; +export type { + DataResource, + DataResourceCheck, + DataResourceCheckStatus, + DataResourceSourceHost, + DataResourceStatus, + DataResourceSummary, + DataResourceType, + DataResourcesResponse, +} from "./model/types"; diff --git a/src/entities/data-resource/model/types.ts b/src/entities/data-resource/model/types.ts new file mode 100644 index 0000000..2bae4c1 --- /dev/null +++ b/src/entities/data-resource/model/types.ts @@ -0,0 +1,74 @@ +export type DataResourceType = "mysql" | "mongodb" | "redis" | "mq" | "rocketmq" | "kafka" | "unknown"; + +export type DataResourceStatus = "healthy" | "warning" | "critical" | "unknown"; + +export type DataResourceCheckStatus = "success" | "failed" | "unknown"; + +export type DataResourceCheck = { + detail?: string; + endpoint?: string; + latencyMs?: number; + status: DataResourceCheckStatus; +}; + +export type DataResourceSourceHost = { + error?: string; + fallbackReason?: string; + host: string; + privateIp: string; + serviceCount: number; + status: string; + transport?: "ssh" | "tat-fallback" | string; +}; + +export type DataResource = { + accessPath: string; + backupStatus: string; + capacityUsedPercent?: number | null; + checks: DataResourceCheck[]; + connections?: number | null; + description?: string; + endpoints: string[]; + id: string; + name: string; + observedDatabases: string[]; + owner: string; + relatedServices: string[]; + slowQueryCount?: number | null; + sourceHosts: DataResourceSourceHost[]; + status: DataResourceStatus; + topology: string; + type: DataResourceType; +}; + +export type DataResourceSummary = { + critical: number; + healthy: number; + unknown: number; + warning: number; +}; + +export type DataResourcesResponse = { + dryRun?: boolean; + lastSyncedAt?: string; + ok: boolean; + resources: DataResource[]; + sourceHosts: DataResourceSourceHost[]; + summary?: DataResourceSummary; + target?: { + instanceId: string; + name: string; + privateIp: string; + publicIp: string; + }; + tat?: { + instanceId?: string; + status: string; + target?: { + instanceId: string; + name: string; + privateIp: string; + publicIp: string; + }; + }; +}; diff --git a/src/entities/deployment-plan/api/getDeployApiInventory.ts b/src/entities/deployment-plan/api/getDeployApiInventory.ts index 61a43b3..04740c5 100644 --- a/src/entities/deployment-plan/api/getDeployApiInventory.ts +++ b/src/entities/deployment-plan/api/getDeployApiInventory.ts @@ -1,16 +1,45 @@ export type DeployApiInventoryResponse = { - hosts: Record; + hosts: Record< + string, + { + instance_id?: string; + private_ip?: string; + } + >; managedDependencies: Record; ok: boolean; region: string; registry: string; - services: Record; + services: Record< + string, + { + clb?: { + drain_seconds?: number; + enabled?: boolean; + listener_ids?: string[]; + load_balancer_id?: string; + }; + config_path?: string; + container?: string; + env_file?: string; + hosts?: string[]; + image_template?: string; + owner?: string; + target_port?: number; + unit?: string; + } + >; }; async function readJsonResponse(response: Response): Promise { const contentType = response.headers.get("content-type") ?? ""; if (!response.ok) { + if (contentType.includes("application/json")) { + const payload = (await response.json()) as { detail?: string; error?: string }; + throw new Error(payload.detail || payload.error || `Deploy API HTTP ${response.status}`); + } + throw new Error(`Deploy API HTTP ${response.status}`); } @@ -21,6 +50,19 @@ async function readJsonResponse(response: Response): Promise { return (await response.json()) as T; } +async function postJson(path: string, body: Record): Promise { + const response = await fetch(path, { + body: JSON.stringify(body), + headers: { + accept: "application/json", + "content-type": "application/json", + }, + method: "POST", + }); + + return readJsonResponse(response); +} + export async function getDeployApiInventory(): Promise { const response = await fetch("/deploy/api/hyapp/inventory", { headers: { @@ -74,6 +116,24 @@ export type DeployApiDiscoverResponse = { host: string; output?: string; private_ip: string; + services?: Array<{ + configPath?: string; + container?: string; + containerStatus?: string; + cpuPercent?: number | null; + healthStatus?: string; + image?: string; + imageTag?: string; + memoryPercent?: number | null; + memoryUsage?: string; + name: string; + restartCount?: number; + runningTime?: string; + status?: "healthy" | "warning" | "critical" | "unknown" | "running"; + targetPort?: number; + unit?: string; + unitState?: string; + }>; status: string; }>; ok: boolean; @@ -114,8 +174,9 @@ export type DeployApiTestboxConfigResponse = { timerState?: string; }; -export async function getDeployApiTestboxConfigs(services: string[]): Promise { +export async function getDeployApiTestboxConfigs(services: string[], raw = false): Promise { const params = new URLSearchParams({ + raw: raw ? "1" : "0", services: services.join(","), }); const response = await fetch(`/deploy/api/hyapp/testbox/configs?${params.toString()}`, { @@ -126,3 +187,148 @@ export async function getDeployApiTestboxConfigs(services: string[]): Promise(response); } + +export type DeployApiTestboxStatusResponse = { + deployedCommit?: string; + deployServiceState?: string; + git?: { + branch?: string; + localCommit?: string; + remoteTestCommit?: string; + }; + ok: boolean; + services: Array<{ + composeService: string; + configPath: string; + containerId?: string; + exists: boolean; + health: string; + image: string; + name: string; + restartCount: number; + service: string; + startedAt: string; + state: string; + status: "healthy" | "warning" | "critical" | "unknown" | "running"; + }>; + target?: DeployApiTestboxConfigResponse["target"]; + timerState?: string; + updatedAt?: string; +}; + +export async function getDeployApiTestboxStatus(services: string[]): Promise { + const params = new URLSearchParams({ + services: services.join(","), + }); + const response = await fetch(`/deploy/api/hyapp/testbox/status?${params.toString()}`, { + headers: { + accept: "application/json", + }, + }); + + return readJsonResponse(response); +} + +export type DeployApiTestboxCommand = { + args?: string[]; + code: number; + output: string; +}; + +export type DeployApiTestboxServiceActionResponse = { + commands: DeployApiTestboxCommand[]; + ok: boolean; + operation: string; + services: string[]; + status?: DeployApiTestboxCommand; +}; + +export function runDeployApiTestboxServiceAction(params: { + operation: "restart" | "start" | "stop" | "update"; + services: string[]; +}): Promise { + return postJson("/deploy/api/hyapp/testbox/service", { + confirmed: true, + operation: params.operation, + services: params.services, + }); +} + +export type DeployApiTestboxTimerActionResponse = { + commands: DeployApiTestboxCommand[]; + ok: boolean; + operation: string; + timerState: string; +}; + +export function runDeployApiTestboxTimerAction(operation: "start" | "stop" | "restart" | "status"): Promise { + return postJson("/deploy/api/hyapp/testbox/timer", { + confirmed: true, + operation, + }); +} + +export type DeployApiTestboxConfigWriteResponse = { + backupPath: string; + commands: DeployApiTestboxCommand[]; + ok: boolean; + path: string; + restarted: boolean; + service: string; +}; + +export function saveDeployApiTestboxConfig(params: { + content: string; + restart: boolean; + service: string; +}): Promise { + return postJson("/deploy/api/hyapp/testbox/config", { + confirmed: true, + content: params.content, + restart: params.restart, + service: params.service, + }); +} + +export type DeployApiTestboxMigration = { + applied: boolean; + appliedAtMs: number; + checksum: string; + dirty: boolean; + path: string; + status: "pending" | "applied" | "dirty" | "checksum_changed"; + version: string; +}; + +export type DeployApiTestboxMigrationsResponse = { + commands?: DeployApiTestboxCommand[]; + configPath: string; + database?: { + database: string; + host: string; + port: string; + user: string; + } | null; + dryRun?: boolean; + error?: string; + migrationDir: string; + migrations: DeployApiTestboxMigration[]; + mysqlAvailable: boolean; + ok: boolean; + operation: "list" | "apply"; +}; + +export function getDeployApiTestboxMigrations(): Promise { + return fetch("/deploy/api/hyapp/testbox/migrations", { + headers: { + accept: "application/json", + }, + }).then((response) => readJsonResponse(response)); +} + +export function applyDeployApiTestboxMigrations(files: string[]): Promise { + return postJson("/deploy/api/hyapp/testbox/migrations/apply", { + confirmed: true, + files, + }); +} diff --git a/src/entities/deployment-plan/api/useDeployApiQueries.ts b/src/entities/deployment-plan/api/useDeployApiQueries.ts index 00abfbf..a815fb8 100644 --- a/src/entities/deployment-plan/api/useDeployApiQueries.ts +++ b/src/entities/deployment-plan/api/useDeployApiQueries.ts @@ -1,6 +1,13 @@ import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "../../../shared/api/queryKeys"; -import { getDeployApiDiscover, getDeployApiInventory, getDeployApiPlan, getDeployApiTestboxConfigs } from "./getDeployApiInventory"; +import { + getDeployApiDiscover, + getDeployApiInventory, + getDeployApiPlan, + getDeployApiTestboxConfigs, + getDeployApiTestboxMigrations, + getDeployApiTestboxStatus, +} from "./getDeployApiInventory"; const STATUS_REFRESH_INTERVAL_MS = 15_000; @@ -13,8 +20,9 @@ export function useDeployApiInventoryQuery() { }); } -export function useDeployApiPlanQuery(services: string[]) { +export function useDeployApiPlanQuery(services: string[], enabled = true) { return useQuery({ + enabled: enabled && services.length > 0, queryFn: () => getDeployApiPlan(services), queryKey: queryKeys.deployApiPlan(services), retry: 0, @@ -31,11 +39,32 @@ export function useDeployApiDiscoverQuery(services: string[], enabled: boolean) }); } -export function useDeployApiTestboxConfigsQuery(services: string[]) { +export function useDeployApiTestboxConfigsQuery(services: string[], enabled = true) { return useQuery({ + enabled, queryFn: () => getDeployApiTestboxConfigs(services), queryKey: queryKeys.deployApiTestboxConfigs(services), refetchInterval: 60_000, retry: 0, }); } + +export function useDeployApiTestboxStatusQuery(services: string[], enabled = true) { + return useQuery({ + enabled, + queryFn: () => getDeployApiTestboxStatus(services), + queryKey: queryKeys.deployApiTestboxStatus(services), + refetchInterval: enabled ? STATUS_REFRESH_INTERVAL_MS : false, + retry: 0, + }); +} + +export function useDeployApiTestboxMigrationsQuery(enabled = true) { + return useQuery({ + enabled, + queryFn: getDeployApiTestboxMigrations, + queryKey: queryKeys.deployApiTestboxMigrations(), + refetchInterval: 60_000, + retry: 0, + }); +} diff --git a/src/entities/deployment-plan/index.ts b/src/entities/deployment-plan/index.ts index 8325d69..7bbbb64 100644 --- a/src/entities/deployment-plan/index.ts +++ b/src/entities/deployment-plan/index.ts @@ -1,28 +1,27 @@ -export { hyappDeploymentPlan } from "./mock/hyappDeploymentPlan"; -export { - getHyappDeploymentHosts, - getHyappDeploymentServiceById, - getHyappDeploymentServiceByName, -} from "./lib/getHyappDeploymentService"; export { useDeployApiDiscoverQuery, useDeployApiInventoryQuery, useDeployApiPlanQuery, useDeployApiTestboxConfigsQuery, + useDeployApiTestboxMigrationsQuery, + useDeployApiTestboxStatusQuery, } from "./api/useDeployApiQueries"; +export { + applyDeployApiTestboxMigrations, + runDeployApiTestboxServiceAction, + runDeployApiTestboxTimerAction, + saveDeployApiTestboxConfig, +} from "./api/getDeployApiInventory"; export type { DeployApiDiscoverResponse, DeployApiInventoryResponse, DeployApiPlanResponse, + DeployApiTestboxCommand, DeployApiTestboxConfigResponse, + DeployApiTestboxConfigWriteResponse, + DeployApiTestboxMigration, + DeployApiTestboxMigrationsResponse, + DeployApiTestboxServiceActionResponse, + DeployApiTestboxStatusResponse, + DeployApiTestboxTimerActionResponse, } from "./api/getDeployApiInventory"; -export type { - DeploymentCommand, - DeploymentHost, - DeploymentMode, - DeploymentStep, - HyappDeploymentPlan, - HyappDeploymentService, - ManagedDependency, - TestboxDeployTarget, -} from "./model/types"; diff --git a/src/entities/deployment-plan/lib/getHyappDeploymentService.ts b/src/entities/deployment-plan/lib/getHyappDeploymentService.ts deleted file mode 100644 index 49441eb..0000000 --- a/src/entities/deployment-plan/lib/getHyappDeploymentService.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { hyappDeploymentPlan } from "../mock/hyappDeploymentPlan"; -import type { HyappDeploymentService } from "../model/types"; - -export function getHyappDeploymentServiceById( - serviceId: string, -): HyappDeploymentService | undefined { - return hyappDeploymentPlan.services.find((service) => service.id === serviceId); -} - -export function getHyappDeploymentServiceByName( - serviceName: string, -): HyappDeploymentService | undefined { - return hyappDeploymentPlan.services.find((service) => service.name === serviceName); -} - -export function getHyappDeploymentHosts(service: HyappDeploymentService) { - if (!service.prod) { - return []; - } - - return service.prod.hosts - .map((hostId) => hyappDeploymentPlan.hosts[hostId]) - .filter((host): host is NonNullable => Boolean(host)); -} diff --git a/src/entities/deployment-plan/mock/hyappDeploymentPlan.ts b/src/entities/deployment-plan/mock/hyappDeploymentPlan.ts deleted file mode 100644 index d24a223..0000000 --- a/src/entities/deployment-plan/mock/hyappDeploymentPlan.ts +++ /dev/null @@ -1,575 +0,0 @@ -import type { HyappDeploymentPlan, HyappDeploymentService } from "../model/types"; - -const repositoryPath = "/Users/hy/Documents/hy/hyapp-server"; -const registry = "10.2.1.3:18082/hyapp"; - -const createLocalTarget = ({ - alias, - configPath, - dockerfilePath, - grpcPort, - healthPort, - httpPort, - name, -}: { - alias: string; - configPath: string; - dockerfilePath: string; - grpcPort: number | null; - healthPort: number; - httpPort: number | null; - name: string; -}) => ({ - aliases: [alias, name.replace("-service", "")], - commands: { - logs: `cd ${repositoryPath} && make logs ${alias}`, - rebuild: `cd ${repositoryPath} && make rebuild ${alias}`, - restart: `cd ${repositoryPath} && make restart ${alias}`, - status: `cd ${repositoryPath} && make ps ${alias}`, - }, - composeService: name, - configPath, - dockerfilePath, - grpcPort, - healthcheck: `http://127.0.0.1:${healthPort}/healthz/ready`, - httpPort, -}); - -const createProdTarget = ({ - drainSeconds, - entrypoint, - hosts, - listenerIds, - loadBalancerId, - name, - targetPort, -}: { - drainSeconds: number; - entrypoint: string; - hosts: string[]; - listenerIds: string[]; - loadBalancerId: string; - name: string; - targetPort: number; -}) => ({ - clb: { - drainSeconds, - entrypoint, - listenerIds, - loadBalancerId, - }, - configPath: `services/${name}/configs/config.tencent.example.yaml`, - container: `hyapp-${name}`, - dockerEnvPath: `/etc/hyapp/${name}/docker.env`, - hosts, - imageTemplate: "${REGISTRY}/" + `${name}:` + "${TAG}", - targetPort, - unit: `hyapp-${name}`, -}); - -const services: HyappDeploymentService[] = [ - { - cpuUsage: 39, - desiredInstances: 2, - domain: "HTTP 入口 / 鉴权 / 协议转换", - id: "svc-gateway", - lastReleaseText: "25 分钟前", - local: createLocalTarget({ - alias: "gs", - configPath: "services/gateway-service/configs/config.docker.yaml", - dockerfilePath: "services/gateway-service/Dockerfile", - grpcPort: null, - healthPort: 13000, - httpPort: 13000, - name: "gateway-service", - }), - memoryUsage: 48, - name: "gateway-service", - owner: "platform", - prod: createProdTarget({ - drainSeconds: 10, - entrypoint: "https://api.global-interaction.com -> 13000", - hosts: ["gateway-1", "gateway-2"], - listenerIds: ["lbl-ktvz8m6k"], - loadBalancerId: "lb-cbiabr1q", - name: "gateway-service", - targetPort: 13000, - }), - readyInstances: 2, - status: "healthy", - version: "20260513-1349", - }, - { - cpuUsage: 46, - desiredInstances: 2, - domain: "Room Cell / 房间状态 owner", - id: "svc-room", - lastReleaseText: "1 小时前", - local: createLocalTarget({ - alias: "rs", - configPath: "services/room-service/configs/config.docker.yaml", - dockerfilePath: "services/room-service/Dockerfile", - grpcPort: 13001, - healthPort: 13101, - httpPort: 13101, - name: "room-service", - }), - memoryUsage: 58, - name: "room-service", - owner: "room", - prod: createProdTarget({ - drainSeconds: 5, - entrypoint: "10.2.1.16:13001", - hosts: ["new-app-1", "new-app-2"], - listenerIds: ["lbl-d2urlzba"], - loadBalancerId: "lb-epvnr4o0", - name: "room-service", - targetPort: 13001, - }), - readyInstances: 2, - status: "healthy", - version: "20260513-1349", - }, - { - cpuUsage: 42, - desiredInstances: 2, - domain: "用户主数据 / 登录资料", - id: "svc-user", - lastReleaseText: "12 分钟前", - local: createLocalTarget({ - alias: "us", - configPath: "services/user-service/configs/config.docker.yaml", - dockerfilePath: "services/user-service/Dockerfile", - grpcPort: 13005, - healthPort: 13105, - httpPort: 13105, - name: "user-service", - }), - memoryUsage: 61, - name: "user-service", - owner: "user", - prod: createProdTarget({ - drainSeconds: 5, - entrypoint: "10.2.1.16:13005", - hosts: ["new-app-1", "new-app-2"], - listenerIds: ["lbl-honi8z3a"], - loadBalancerId: "lb-epvnr4o0", - name: "user-service", - targetPort: 13005, - }), - readyInstances: 2, - status: "healthy", - version: "20260513-1349", - }, - { - cpuUsage: 57, - desiredInstances: 2, - domain: "账务语义 / 钱包 outbox", - id: "svc-wallet", - lastReleaseText: "38 分钟前", - local: createLocalTarget({ - alias: "ws", - configPath: "services/wallet-service/configs/config.docker.yaml", - dockerfilePath: "services/wallet-service/Dockerfile", - grpcPort: 13004, - healthPort: 13104, - httpPort: 13104, - name: "wallet-service", - }), - memoryUsage: 64, - name: "wallet-service", - owner: "payment", - prod: createProdTarget({ - drainSeconds: 5, - entrypoint: "10.2.1.15:13004", - hosts: ["pay-1", "pay-2"], - listenerIds: ["lbl-9wi5mvu4"], - loadBalancerId: "lb-4f5xi6p0", - name: "wallet-service", - targetPort: 13004, - }), - readyInstances: 2, - status: "healthy", - version: "20260513-1349", - }, - { - cpuUsage: 34, - desiredInstances: 2, - domain: "活动消费 / 运营事件", - id: "svc-activity", - lastReleaseText: "2 小时前", - local: createLocalTarget({ - alias: "as", - configPath: "services/activity-service/configs/config.docker.yaml", - dockerfilePath: "services/activity-service/Dockerfile", - grpcPort: 13006, - healthPort: 13106, - httpPort: 13106, - name: "activity-service", - }), - memoryUsage: 44, - name: "activity-service", - owner: "growth", - prod: createProdTarget({ - drainSeconds: 5, - entrypoint: "10.2.1.16:13006", - hosts: ["new-app-activity-1", "new-app-activity-2"], - listenerIds: ["lbl-j7oqtfhq"], - loadBalancerId: "lb-epvnr4o0", - name: "activity-service", - targetPort: 13006, - }), - readyInstances: 2, - status: "healthy", - version: "20260513-1349", - }, - { - cpuUsage: 31, - desiredInstances: 2, - domain: "私有通知 / IM 单聊投递", - id: "svc-notice", - lastReleaseText: "昨天", - local: createLocalTarget({ - alias: "ns", - configPath: "services/notice-service/configs/config.docker.yaml", - dockerfilePath: "services/notice-service/Dockerfile", - grpcPort: 13009, - healthPort: 13109, - httpPort: 13109, - name: "notice-service", - }), - memoryUsage: 42, - name: "notice-service", - owner: "notice", - prod: createProdTarget({ - drainSeconds: 5, - entrypoint: "10.2.1.16:13009", - hosts: ["new-app-activity-1", "new-app-activity-2"], - listenerIds: ["lbl-aux9xsjq"], - loadBalancerId: "lb-epvnr4o0", - name: "notice-service", - targetPort: 13009, - }), - readyInstances: 2, - status: "healthy", - version: "20260513-1349", - }, - { - cpuUsage: 53, - desiredInstances: 2, - domain: "游戏业务 gRPC", - id: "svc-game", - lastReleaseText: "3 小时前", - local: createLocalTarget({ - alias: "game", - configPath: "services/game-service/configs/config.docker.yaml", - dockerfilePath: "services/game-service/Dockerfile", - grpcPort: 13008, - healthPort: 13108, - httpPort: 13108, - name: "game-service", - }), - memoryUsage: 59, - name: "game-service", - owner: "game", - prod: createProdTarget({ - drainSeconds: 5, - entrypoint: "10.2.1.16:13008", - hosts: ["new-game-1", "new-game-2"], - listenerIds: ["lbl-ku138b4y"], - loadBalancerId: "lb-epvnr4o0", - name: "game-service", - targetPort: 13008, - }), - readyInstances: 2, - status: "healthy", - version: "20260513-1349", - }, - { - cpuUsage: 21, - desiredInstances: 1, - domain: "后台调度 / 批处理触发", - id: "svc-cron", - lastReleaseText: "本地模板", - local: createLocalTarget({ - alias: "cs", - configPath: "services/cron-service/configs/config.docker.yaml", - dockerfilePath: "services/cron-service/Dockerfile", - grpcPort: 13007, - healthPort: 13107, - httpPort: 13107, - name: "cron-service", - }), - memoryUsage: 36, - name: "cron-service", - owner: "platform", - prod: null, - readyInstances: 1, - status: "warning", - version: "20260513-1349", - }, -]; - -export const hyappDeploymentPlan: HyappDeploymentPlan = { - adminProduction: { - backend: { - binaryPath: "/opt/hyapp/admin-server/admin-server", - configPath: "/etc/hyapp/admin-server/config.yaml", - instanceId: "ins-0x5mjwmc", - listenAddr: "172.16.0.6:13100", - migrationsPath: "/opt/hyapp/admin-server/migrations", - nodeId: "admin-guangzhou-backend", - privateIp: "172.16.0.6", - publicIp: "134.175.160.86", - unit: "hyapp-admin-server.service", - wgUnit: "wg-quick@hyapp-admin.service", - }, - endpoints: [ - { - label: "admin-platform ready", - url: "http://1.14.164.2:7001/readyz", - }, - { - label: "admin-platform", - url: "http://1.14.164.2:7001/", - }, - ], - frontend: { - apiProxy: "http://172.16.0.6:13100/api/", - branch: "main", - deployScript: "/opt/hyapp-admin-platform/bin/deploy.sh", - instanceId: "ins-ac1ls7be", - nginxConfig: "/etc/nginx/conf.d/hyapp-admin-platform.conf", - port: 7001, - privateIp: "172.16.0.10", - publicIp: "1.14.164.2", - root: "/opt/hyapp-admin-platform", - source: "/opt/hyapp-admin-platform/source", - timer: "hyapp-admin-platform-deploy.timer", - }, - previousBackend: { - instanceId: "lhins-6q58yb2f", - name: "海诣APP后台管理系统", - publicIp: "43.128.56.40", - status: "hyapp-admin-server 和 wg-quick@hyapp-admin 已停用", - }, - }, - commands: [ - { - command: `cd ${repositoryPath} && make ps`, - id: "local-status", - title: "本地查看服务状态", - }, - { - command: `cd ${repositoryPath} && make rebuild us`, - id: "local-rebuild", - title: "本地重建单服务", - }, - { - command: - "python3 deploy/tencent-tat/hyapp_tat_deploy.py plan --inventory deploy/tencent-tat/hyapp-services.inventory.prod.json --services gateway-service,room-service", - id: "prod-plan", - title: "线上发布计划预览", - }, - { - command: - "python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy --env-file /opt/hy-app-monitor/.env --inventory deploy/tencent-tat/hyapp-services.inventory.prod.json --services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service --tag 20260513-1349 --yes", - id: "prod-deploy", - title: "线上滚动发布", - }, - { - command: "systemctl start hyapp-server-test-deploy.service", - id: "testbox-trigger", - title: "测试机手动触发拉 test 分支", - }, - { - command: - "cd /opt/hyapp-server-test/source && docker compose -p hyapp-test -f docker-compose.yml -f /opt/hyapp-server-test/docker-compose.testbox.yml ps", - id: "testbox-status", - title: "测试机查看 hyapp 服务状态", - }, - { - command: "curl -fsS http://1.14.164.2:7001/readyz", - id: "admin-prod-ready", - title: "正式后台健康检查", - }, - { - command: "systemctl status hyapp-admin-server.service wg-quick@hyapp-admin.service", - id: "admin-server-prod-status", - title: "正式 admin-server 后端状态", - }, - ], - deployServer: { - id: "deploy", - instanceId: "ins-ntmpcd3a", - privateIp: "10.2.1.3", - publicIp: "43.164.75.199", - role: "构建镜像、推送镜像、调用 Tencent TAT/CLB API;不承载业务流量", - }, - hosts: { - "gateway-1": { - id: "gateway-1", - instanceId: "ins-aeyaoj40", - privateIp: "10.2.1.8", - }, - "gateway-2": { - id: "gateway-2", - instanceId: "ins-irvi5b7u", - privateIp: "10.2.2.17", - }, - "new-app-1": { - id: "new-app-1", - instanceId: "ins-fi5zufpk", - privateIp: "10.2.1.4", - }, - "new-app-2": { - id: "new-app-2", - instanceId: "ins-hwhaoe8c", - privateIp: "10.2.1.13", - }, - "new-game-1": { - id: "new-game-1", - instanceId: "ins-ir229jtw", - privateIp: "10.2.1.10", - }, - "new-game-2": { - id: "new-game-2", - instanceId: "ins-460rw7gu", - privateIp: "10.2.1.6", - }, - "new-app-activity-1": { - id: "new-app-activity-1", - instanceId: "ins-n8wut6n8", - privateIp: "10.2.1.11", - }, - "new-app-activity-2": { - id: "new-app-activity-2", - instanceId: "ins-d1n303sa", - privateIp: "10.2.1.7", - }, - "pay-1": { - id: "pay-1", - instanceId: "ins-ceqfcxd2", - privateIp: "10.2.11.14", - }, - "pay-2": { - id: "pay-2", - instanceId: "ins-awhb74q6", - privateIp: "10.2.12.5", - }, - }, - managedDependencies: [ - { - boundary: "发布系统不启动、不迁移、不重启本地 MySQL", - id: "mysql", - name: "MySQL", - provider: "TencentDB for MySQL", - role: "房间、用户、钱包、活动、游戏、通知和后台管理的持久化来源", - }, - { - boundary: "只作为托管缓存和路由状态,不能成为唯一恢复来源", - id: "redis", - name: "Redis", - provider: "TencentDB for Redis", - role: "Room owner route、Redis lease、登录/风控缓存", - }, - { - boundary: "脚本不创建 topic、consumer group 或本地 broker", - id: "mq", - name: "MQ", - provider: "Tencent Cloud managed MQ", - role: "outbox/event bus 方向的外部托管依赖", - }, - ], - modes: [ - { - description: "开发和联调使用 Makefile 包装 docker compose,支持单服务 rebuild/restart/logs。", - id: "local-compose", - title: "本地 Compose", - }, - { - description: "生产由 deploy 服务器构建镜像,TAT 在目标机执行 docker pull 和 systemd restart。", - id: "tencent-tat", - title: "腾讯云 TAT 滚动发布", - }, - { - description: "测试机每分钟拉远端 test 分支;有提交变化时按路径重建并重启对应服务,成功后记录提交。", - id: "testbox-auto-pull", - title: "测试机自动部署", - }, - ], - productionSteps: [ - { - detail: "读取 inventory 中实例、systemd unit、docker.env、镜像模板和 CLB listener 绑定。", - id: "inventory", - title: "读取部署目标", - }, - { - detail: "对配置了 CLB 的实例先把 listener/rule 权重改为 0,等待 drain 窗口结束。", - id: "drain", - title: "实例摘流", - }, - { - detail: "通过 TAT 更新 docker.env 的 IMAGE,执行 docker pull 后重启 hyapp-* systemd unit。", - id: "restart", - title: "镜像更新与重启", - }, - { - detail: "等待 systemd active 和 Docker health 恢复;失败时停止后续实例,故障实例保持权重 0。", - id: "health", - title: "健康检查", - }, - { - detail: "恢复原 CLB 权重,等待 stabilize_seconds 后进入下一台实例。", - id: "restore", - title: "恢复接流", - }, - ], - prohibitedActions: [ - "正常发布不要使用 --no-clb。", - "不要多个 room-service 共用同一个 node_id。", - "不要把 room-service.advertise_addr 写成 CLB 地址。", - "不要先 systemctl restart 再摘 CLB。", - "不要在同机 Java compose 目录里执行 hyapp-server 部署命令。", - ], - region: "me-saudi-arabia", - registry, - repositoryPath, - services, - testbox: { - adminApiUrl: "http://172.16.0.6:13100", - adminPlatformBranch: "main", - adminPlatformUrl: "http://1.14.164.2:7001/", - branch: "test", - cleanupTimer: "chatapp-test-docker-cleanup.timer", - composeCommand: - "docker compose -p hyapp-test -f /opt/hyapp-server-test/source/docker-compose.yml -f /opt/hyapp-server-test/docker-compose.testbox.yml", - deployScript: "/opt/hyapp-server-test/bin/deploy.sh", - endpoints: [ - { - label: "gateway ready", - url: "https://api-test.global-interaction.com/healthz/ready", - }, - { - label: "admin via platform", - url: "http://1.14.164.2:7001/healthz/ready", - }, - { - label: "admin-platform", - url: "http://1.14.164.2:7001/", - }, - ], - instanceId: "lhins-q0m38zc6", - privateIp: "10.11.0.2", - publicIp: "43.165.195.39", - root: "/opt/hyapp-server-test", - source: "/opt/hyapp-server-test/source", - timer: "hyapp-server-test-deploy.timer", - }, - timeouts: { - clbTaskSeconds: 180, - serviceHealthSeconds: 150, - stabilizeSeconds: 5, - tatSeconds: 900, - }, -}; diff --git a/src/entities/deployment-plan/model/types.ts b/src/entities/deployment-plan/model/types.ts deleted file mode 100644 index 9a7bfdc..0000000 --- a/src/entities/deployment-plan/model/types.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { ServiceStatus } from "../../service"; - -export type DeploymentMode = "local-compose" | "tencent-tat" | "testbox-auto-pull"; - -export type ManagedDependency = { - boundary: string; - id: string; - name: string; - provider: string; - role: string; -}; - -export type DeploymentHost = { - id: string; - instanceId: string; - privateIp: string; -}; - -export type DeployServer = { - id: string; - instanceId: string; - privateIp: string; - publicIp: string; - role: string; -}; - -export type AdminProductionDeployTarget = { - backend: { - binaryPath: string; - configPath: string; - instanceId: string; - listenAddr: string; - migrationsPath: string; - nodeId: string; - privateIp: string; - publicIp: string; - unit: string; - wgUnit: string; - }; - endpoints: Array<{ - label: string; - url: string; - }>; - frontend: { - apiProxy: string; - branch: string; - deployScript: string; - instanceId: string; - nginxConfig: string; - port: number; - privateIp: string; - publicIp: string; - root: string; - source: string; - timer: string; - }; - previousBackend: { - instanceId: string; - name: string; - publicIp: string; - status: string; - }; -}; - -export type TestboxDeployTarget = { - adminApiUrl: string; - adminPlatformBranch: string; - adminPlatformUrl: string; - branch: string; - cleanupTimer: string; - composeCommand: string; - deployScript: string; - endpoints: Array<{ - label: string; - url: string; - }>; - instanceId: string; - privateIp: string; - publicIp: string; - root: string; - source: string; - timer: string; -}; - -export type LocalDeployTarget = { - aliases: string[]; - commands: { - logs: string; - rebuild: string; - restart: string; - status: string; - }; - composeService: string; - configPath: string; - dockerfilePath: string; - grpcPort: number | null; - healthcheck: string; - httpPort: number | null; -}; - -export type ClbBinding = { - drainSeconds: number; - entrypoint: string; - listenerIds: string[]; - loadBalancerId: string; -}; - -export type ProductionDeployTarget = { - clb: ClbBinding; - configPath: string; - container: string; - dockerEnvPath: string; - hosts: string[]; - imageTemplate: string; - targetPort: number; - unit: string; -}; - -export type HyappDeploymentService = { - desiredInstances: number; - domain: string; - id: string; - lastReleaseText: string; - local: LocalDeployTarget; - memoryUsage: number; - name: string; - owner: string; - prod: ProductionDeployTarget | null; - readyInstances: number; - status: ServiceStatus; - version: string; - cpuUsage: number; -}; - -export type DeploymentCommand = { - command: string; - id: string; - title: string; -}; - -export type DeploymentStep = { - detail: string; - id: string; - title: string; -}; - -export type HyappDeploymentPlan = { - adminProduction: AdminProductionDeployTarget; - commands: DeploymentCommand[]; - deployServer: DeployServer; - hosts: Record; - managedDependencies: ManagedDependency[]; - modes: Array<{ - description: string; - id: DeploymentMode; - title: string; - }>; - productionSteps: DeploymentStep[]; - prohibitedActions: string[]; - region: string; - registry: string; - repositoryPath: string; - services: HyappDeploymentService[]; - testbox: TestboxDeployTarget; - timeouts: { - clbTaskSeconds: number; - serviceHealthSeconds: number; - stabilizeSeconds: number; - tatSeconds: number; - }; -}; diff --git a/src/entities/service/api/deployApi.ts b/src/entities/service/api/deployApi.ts new file mode 100644 index 0000000..028e715 --- /dev/null +++ b/src/entities/service/api/deployApi.ts @@ -0,0 +1,206 @@ +import type { ServiceStatus } from "../model/types"; + +export type DeployInventoryService = { + config_path?: string; + container?: string; + env_file?: string; + hosts?: string[]; + owner?: string; + target_port?: number; + unit?: string; +}; + +export type DeployInventoryHost = { + instance_id?: string; + private_ip?: string; +}; + +export type DeployInventoryResponse = { + hosts?: Record; + managedDependencies?: Record; + ok?: boolean; + region?: string; + registry?: string; + services?: Record; +}; + +export type DeployRuntimeService = { + configExists?: boolean; + configPath?: string; + configTruncated?: boolean; + configYaml?: string; + container?: string; + containerId?: string; + containerStatus?: string; + cpuPercent?: number | null; + createdAt?: string; + env?: Record; + envFile?: string; + envFileExists?: boolean; + healthStatus?: string; + host: string; + image?: string; + imageTag?: string; + instanceId?: string | undefined; + listeners?: string; + logs?: string; + memoryPercent?: number | null; + memoryUsage?: string; + name: string; + privateIp: string; + restartCount?: number; + runningTime?: string; + startedAt?: string; + status?: ServiceStatus; + targetPort?: number; + unit?: string; + unitState?: string; +}; + +export type DeployDiscoverHost = { + error?: string; + host: string; + instance_id?: string; + private_ip?: string; + services?: Array>; + status: string; +}; + +export type DeployDiscoverResponse = { + clb?: unknown[]; + hosts?: DeployDiscoverHost[]; + ok?: boolean; +}; + +async function readJsonResponse(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? ""; + + if (!response.ok) { + if (contentType.includes("application/json")) { + const payload = (await response.json()) as { detail?: string; error?: string }; + throw new Error(payload.detail || payload.error || `Deploy API HTTP ${response.status}`); + } + + throw new Error(`Deploy API HTTP ${response.status}`); + } + + if (!contentType.includes("application/json")) { + throw new Error("Deploy API returned non-JSON response"); + } + + return (await response.json()) as T; +} + +export function serviceNameToId(serviceName: string) { + return `svc-${serviceName.replace(/-service$/, "")}`; +} + +export function serviceIdToName(serviceId: string, inventory: DeployInventoryResponse) { + const services = inventory.services ?? {}; + const directName = services[serviceId] ? serviceId : ""; + + if (directName) { + return directName; + } + + return Object.keys(services).find((serviceName) => serviceNameToId(serviceName) === serviceId) ?? ""; +} + +export async function fetchDeployInventory(): Promise { + const response = await fetch("/deploy/api/hyapp/inventory", { + headers: { + accept: "application/json", + }, + }); + + const payload = await readJsonResponse(response); + + if (payload.ok === false || !payload.services) { + throw new Error("Deploy API inventory is unavailable"); + } + + return payload; +} + +export async function fetchDeployDiscover(services: string[], includeConfig = false): Promise { + const params = new URLSearchParams({ + includeConfig: includeConfig ? "1" : "0", + remote: "1", + services: services.join(","), + }); + + if (includeConfig) { + params.set("maxConfigBytes", "20000"); + } + + const response = await fetch(`/deploy/api/hyapp/discover?${params.toString()}`, { + headers: { + accept: "application/json", + }, + }); + + const payload = await readJsonResponse(response); + + if (payload.ok === false) { + throw new Error("Deploy API discover is unavailable"); + } + + return payload; +} + +export function getRuntimeServices(discover: DeployDiscoverResponse): DeployRuntimeService[] { + return (discover.hosts ?? []).flatMap((host) => + (host.services ?? []).map((service) => ({ + ...service, + host: host.host, + instanceId: host.instance_id, + privateIp: host.private_ip ?? "", + })), + ); +} + +export function statusFromRuntimes(runtimes: DeployRuntimeService[], desiredInstances: number): ServiceStatus { + const readyCount = runtimes.filter((runtime) => runtime.status === "healthy").length; + + if (desiredInstances > 0 && readyCount >= desiredInstances) { + return "healthy"; + } + + if (readyCount > 0) { + return "warning"; + } + + if (runtimes.some((runtime) => runtime.status === "running")) { + return "running"; + } + + if (runtimes.length > 0) { + return "critical"; + } + + return "unknown"; +} + +export function averagePercent(values: Array) { + const numericValues = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + + if (numericValues.length === 0) { + return 0; + } + + return Math.round(numericValues.reduce((total, value) => total + value, 0) / numericValues.length); +} + +export function mostFrequent(values: Array) { + const counts = new Map(); + + values.forEach((value) => { + const text = String(value || "").trim(); + + if (text) { + counts.set(text, (counts.get(text) ?? 0) + 1); + } + }); + + return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? ""; +} diff --git a/src/entities/service/api/getServiceDetail.ts b/src/entities/service/api/getServiceDetail.ts index 84b7ab3..3e02b5a 100644 --- a/src/entities/service/api/getServiceDetail.ts +++ b/src/entities/service/api/getServiceDetail.ts @@ -1,9 +1,3 @@ -import { mockServices } from "../mock/services"; -import { - getHyappDeploymentHosts, - getHyappDeploymentServiceByName, - hyappDeploymentPlan, -} from "../../deployment-plan"; import type { Service, ServiceDependency, @@ -14,33 +8,19 @@ import type { ServiceMetric, ServiceMetricPoint, } from "../model/types"; - -const MOCK_LATENCY_MS = 180; - -const baseTimes = [ - "14:00", - "14:05", - "14:10", - "14:15", - "14:20", - "14:25", - "14:30", - "14:35", - "14:40", - "14:45", - "14:50", - "14:55", - "15:00", -]; - -function createSeries(values: number[]): ServiceMetricPoint[] { - return values.map((value, index) => ({ - time: - baseTimes[index] ?? - `${14 + Math.floor(index / 12)}:${String((index * 5) % 60).padStart(2, "0")}`, - value, - })); -} +import { + averagePercent, + type DeployInventoryResponse, + type DeployInventoryService, + type DeployRuntimeService, + fetchDeployDiscover, + fetchDeployInventory, + getRuntimeServices, + mostFrequent, + serviceIdToName, + serviceNameToId, + statusFromRuntimes, +} from "./deployApi"; const dependencyTypeMap: Record = { mq: "message-queue", @@ -48,320 +28,236 @@ const dependencyTypeMap: Record = { redis: "cache", }; -const dependencies: ServiceDependency[] = hyappDeploymentPlan.managedDependencies.map((dependency) => ({ - id: `dep-${dependency.id}`, - name: dependency.name, - status: "healthy", - type: dependencyTypeMap[dependency.id] ?? "storage", -})); - -function createMetrics(service: Service): ServiceMetric[] { - const qpsBase = - service.status === "critical" - ? 760 - : service.status === "warning" - ? 480 - : 326.8; - const errorBase = - service.status === "critical" - ? 3.8 - : service.status === "warning" - ? 1.2 - : 0.23; +function createCurrentSeries(value: number): ServiceMetricPoint[] { + return [ + { time: "当前", value }, + { time: "当前", value }, + ]; +} +function createMetrics(cpuUsage: number, memoryUsage: number): ServiceMetric[] { return [ { color: "blue", id: "cpu", label: "CPU 使用率", max: 100, - points: createSeries([ - 47, - 41, - 52, - 44, - 49, - 45, - 58, - 42, - 46, - 43, - 48, - 39, - service.cpuUsage, - ]), + points: createCurrentSeries(cpuUsage), unit: "%", - value: `${service.cpuUsage}%`, + value: `${cpuUsage}%`, }, { color: "green", id: "memory", label: "内存使用率", max: 100, - points: createSeries([ - 55, - 52, - 56, - 51, - 53, - 58, - 59, - 61, - 57, - 55, - 58, - 54, - service.memoryUsage, - ]), + points: createCurrentSeries(memoryUsage), unit: "%", - value: `${service.memoryUsage}%`, - }, - { - color: "blue", - id: "qps", - label: "QPS", - max: 1000, - points: createSeries([ - 520, - 610, - 482, - 565, - 533, - 615, - 472, - 525, - 488, - 501, - 468, - 536, - Math.round(qpsBase), - ]), - unit: "", - value: String(qpsBase), - }, - { - color: "red", - id: "error-rate", - label: "错误率", - max: 5, - points: createSeries([ - 0.6, - 1.1, - 0.5, - 0.8, - 0.4, - 1.4, - 0.5, - 0.7, - 0.3, - 0.6, - 0.4, - 0.9, - errorBase, - ]), - unit: "%", - value: `${errorBase}%`, - }, - { - color: "purple", - id: "avg-latency", - label: "平均响应延迟", - max: 200, - points: createSeries([ - 72, 64, 81, 68, 77, 71, 92, 66, 74, 69, 76, 70, 78, - ]), - unit: "ms", - value: "78 ms", - }, - { - color: "orange", - id: "p95-latency", - label: "p95 响应延迟", - max: 400, - points: createSeries([ - 172, 196, 155, 181, 168, 205, 176, 184, 171, 194, 166, 188, 182, - ]), - unit: "ms", - value: "182 ms", + value: `${memoryUsage}%`, }, ]; } -function createInstanceConfigItems( - service: Service, - index: number, - host: { id: string; privateIp: string } | undefined, -): ServiceInstanceConfigItem[] { - const instanceIndex = index + 1; - const deploymentService = getHyappDeploymentServiceByName(service.name); - const prodTarget = deploymentService?.prod; - const localTarget = deploymentService?.local; - const servicePort = prodTarget?.targetPort ?? localTarget?.grpcPort ?? localTarget?.httpPort ?? 0; - const configPath = prodTarget?.configPath ?? localTarget?.configPath ?? "configs/config.yaml"; - const image = prodTarget?.imageTemplate ?? `${hyappDeploymentPlan.registry}/${service.name}:${service.version}`; +function formatRemoteTime(value: string | undefined) { + if (!value) { + return "-"; + } - return [ - { - description: "当前运行环境", - id: `${service.id}-instance-${instanceIndex}-cfg-env`, - key: "APP_ENV", - source: "env", - updatedAt: "14:25", - value: service.namespace, - }, - { - description: "服务监听端口", - id: `${service.id}-instance-${instanceIndex}-cfg-port`, - key: "SERVER_PORT", - source: "config-map", - updatedAt: "14:25", - value: String(servicePort), - }, - { - description: "业务配置模板", - id: `${service.id}-instance-${instanceIndex}-cfg-config`, - key: "CONFIG_PATH", - source: "system", - updatedAt: "14:25", - value: configPath, - }, - { - description: "MySQL 托管实例 DSN", - id: `${service.id}-instance-${instanceIndex}-cfg-mysql`, - key: "MYSQL_DSN", - source: "secret", - updatedAt: "14:25", - value: "hyapp:****@tcp(tencentdb.mysql.internal:3306)/hyapp?loc=UTC", - }, - { - description: "Redis 托管实例", - id: `${service.id}-instance-${instanceIndex}-cfg-redis`, - key: "REDIS_HOST", - source: "config-map", - updatedAt: "14:25", - value: "tencentdb.redis.internal:6379", - }, - { - description: "托管 MQ 接入点", - id: `${service.id}-instance-${instanceIndex}-cfg-mq`, - key: "MQ_ENDPOINT", - source: "config-map", - updatedAt: "14:25", - value: "tencentmq.internal", - }, - { - description: "当前运行镜像", - id: `${service.id}-instance-${instanceIndex}-cfg-image`, - key: "IMAGE", - source: "system", - updatedAt: "14:25", - value: image.replace("${REGISTRY}", hyappDeploymentPlan.registry).replace("${TAG}", service.version), - }, - { - description: "部署目标节点", - id: `${service.id}-instance-${instanceIndex}-cfg-host`, - key: "HOST_PRIVATE_IP", - source: "system", - updatedAt: "14:18", - value: host?.privateIp ?? "127.0.0.1", - }, - ]; + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "-"; + } + + return date.toLocaleString("zh-CN", { hour12: false }); } -function createInstances(service: Service): ServiceInstance[] { - const deploymentService = getHyappDeploymentServiceByName(service.name); - const hosts = deploymentService ? getHyappDeploymentHosts(deploymentService) : []; - const instanceCount = Math.max(hosts.length, service.desiredInstances); +function eventTime(value: string | undefined) { + if (!value) { + return "-"; + } - return Array.from({ length: instanceCount }, (_, index) => { - const isReady = index < service.readyInstances; - const host = hosts[index]; - const node = host?.id ?? "local-compose"; - const privateIp = host?.privateIp ?? `127.0.0.${index + 1}`; + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "-"; + } + + return date.toLocaleTimeString("zh-CN", { hour12: false, hour: "2-digit", minute: "2-digit" }); +} + +function managedDependencies(inventory: DeployInventoryResponse): ServiceDependency[] { + return Object.entries(inventory.managedDependencies ?? {}).map(([id, value]) => { + const name = typeof value === "string" ? value : id; return { - configItems: createInstanceConfigItems(service, index, host), - id: `${service.id}-instance-${index + 1}`, - image: `${hyappDeploymentPlan.registry}/${service.name}:${service.version}`, - name: `${service.name}@${node}`, - podIp: privateIp, - node, - restartCount: index === 1 ? 2 : index === 0 ? 1 : 0, - resourceLimit: deploymentService?.prod ? "systemd Docker / host network" : "docker compose", - runningTime: index === 2 ? "6 天 20 小时" : "7 天 12 小时", - status: isReady ? "healthy" : service.status, - zone: index % 2 === 0 ? "me-saudi-a" : "me-saudi-b", + id: `dep-${id}`, + name, + status: "unknown", + type: dependencyTypeMap[id] ?? "storage", }; }); } -function createEvents(service: Service): ServiceEvent[] { - return [ +function createConfigItems(service: Service, runtime: DeployRuntimeService): ServiceInstanceConfigItem[] { + const systemItems: ServiceInstanceConfigItem[] = [ { - id: `${service.id}-event-1`, - message: `${service.readyInstances} 个实例保持健康`, - time: "14:39", - title: "健康检查", + description: "生产配置文件", + id: `${service.id}-${runtime.host}-config-path`, + key: "CONFIG_PATH", + source: "system", + updatedAt: eventTime(runtime.startedAt), + value: runtime.configPath ?? "未采集", }, { - id: `${service.id}-event-2`, - message: "CLB 权重已恢复", - time: "14:25", - title: "恢复接流", + description: "当前运行镜像", + id: `${service.id}-${runtime.host}-image`, + key: "IMAGE", + source: "system", + updatedAt: eventTime(runtime.startedAt), + value: runtime.image || "未采集", }, { - id: `${service.id}-event-3`, - message: `${service.version} 已发布成功`, - time: "14:18", - title: "发布成功", + description: "systemd unit", + id: `${service.id}-${runtime.host}-unit`, + key: "UNIT", + source: "system", + updatedAt: eventTime(runtime.startedAt), + value: runtime.unit || "未采集", }, { - id: `${service.id}-event-4`, - message: `${service.version} 正在发布`, - time: "14:15", - title: "开始发布", - }, - { - id: `${service.id}-event-5`, - message: `${service.name} systemd unit 已启动`, - time: "14:00", - title: "实例启动", + description: "服务端口", + id: `${service.id}-${runtime.host}-target-port`, + key: "TARGET_PORT", + source: "system", + updatedAt: eventTime(runtime.startedAt), + value: String(runtime.targetPort ?? service.targetPort ?? "未采集"), }, ]; + const envItems = Object.entries(runtime.env ?? {}).map(([key, value]) => ({ + description: "docker.env", + id: `${service.id}-${runtime.host}-env-${key}`, + key, + source: "env", + updatedAt: eventTime(runtime.startedAt), + value, + })); + + return [...systemItems, ...envItems]; } -function createServiceDetail(service: Service): ServiceDetail { +function createInstances(service: Service, runtimes: DeployRuntimeService[]): ServiceInstance[] { + return runtimes.map((runtime, index) => ({ + configItems: createConfigItems(service, runtime), + configPath: runtime.configPath, + configYaml: runtime.configYaml, + configYamlTruncated: runtime.configTruncated, + containerStatus: runtime.containerStatus, + cpuUsage: runtime.cpuPercent, + envFile: runtime.envFile, + healthStatus: runtime.healthStatus, + id: `${service.id}-${runtime.host}-${index + 1}`, + image: runtime.image || "未采集", + logs: runtime.logs, + memoryUsage: runtime.memoryPercent, + memoryUsageText: runtime.memoryUsage, + name: `${service.name}@${runtime.host}`, + node: runtime.host, + podIp: runtime.privateIp, + restartCount: runtime.restartCount ?? 0, + resourceLimit: runtime.memoryUsage || "Docker", + runningTime: runtime.runningTime || "未采集", + status: runtime.status ?? "unknown", + zone: runtime.host, + })); +} + +function createEvents(service: Service, runtimes: DeployRuntimeService[]): ServiceEvent[] { + const events = runtimes.flatMap((runtime) => [ + { + id: `${service.id}-${runtime.host}-state`, + message: `${runtime.unitState || "unknown"} / ${runtime.containerStatus || "unknown"} / ${runtime.healthStatus || "no-healthcheck"}`, + time: eventTime(runtime.startedAt), + title: `${runtime.host} 运行状态`, + }, + { + id: `${service.id}-${runtime.host}-image`, + message: runtime.image || "未采集", + time: eventTime(runtime.startedAt), + title: `${runtime.host} 镜像`, + }, + ]); + + return events.length > 0 + ? events + : [ + { + id: `${service.id}-no-runtime`, + message: "TAT 未返回该服务运行态", + time: "-", + title: "未采集", + }, + ]; +} + +function createService( + params: { environment: string }, + serviceName: string, + deployService: DeployInventoryService, + runtimes: DeployRuntimeService[], +): Service { + const desiredInstances = Math.max(deployService.hosts?.length ?? runtimes.length, 1); + const readyInstances = runtimes.filter((runtime) => runtime.status === "healthy").length; + const firstRuntime = runtimes[0]; + return { - ...service, - createdAt: "2024-08-12 10:30", - dependencies, - errorRate: - service.status === "critical" - ? "3.8%" - : service.status === "warning" - ? "1.2%" - : "0.23%", - events: createEvents(service), - instances: createInstances(service), - metrics: createMetrics(service), - qps: - service.status === "critical" - ? "760" - : service.status === "warning" - ? "480" - : "326.8", - runningTime: "7 天 12 小时", + configPath: firstRuntime?.configPath ?? deployService.config_path, + configYaml: firstRuntime?.configYaml, + configYamlTruncated: firstRuntime?.configTruncated, + container: deployService.container ?? firstRuntime?.container, + cpuUsage: averagePercent(runtimes.map((runtime) => runtime.cpuPercent)), + desiredInstances, + id: serviceNameToId(serviceName), + lastReleaseText: mostFrequent(runtimes.map((runtime) => runtime.runningTime)) + ? `运行 ${mostFrequent(runtimes.map((runtime) => runtime.runningTime))}` + : "未采集", + memoryUsage: averagePercent(runtimes.map((runtime) => runtime.memoryPercent)), + name: serviceName, + namespace: params.environment, + owner: deployService.owner ?? "未配置", + readyInstances, + sourceHosts: runtimes.map((runtime) => runtime.host), + status: statusFromRuntimes(runtimes, desiredInstances), + targetPort: deployService.target_port ?? firstRuntime?.targetPort, + unit: deployService.unit ?? firstRuntime?.unit, + version: mostFrequent(runtimes.map((runtime) => runtime.imageTag || runtime.image)) || "未采集", }; } -export async function getServiceDetail( - serviceId: string, -): Promise { - await new Promise((resolve) => - globalThis.setTimeout(resolve, MOCK_LATENCY_MS), - ); +export async function getServiceDetail(serviceId: string, environment = "prod"): Promise { + const inventory = await fetchDeployInventory(); + const serviceName = serviceIdToName(serviceId, inventory); - const service = mockServices.find((item) => item.id === serviceId); + if (!serviceName) { + return null; + } - return service ? createServiceDetail(service) : null; + const discover = await fetchDeployDiscover([serviceName], true); + const runtimes = getRuntimeServices(discover).filter((runtime) => runtime.name === serviceName); + const deployService = inventory.services?.[serviceName] ?? {}; + const service = createService({ environment }, serviceName, deployService, runtimes); + const cpuUsage = averagePercent(runtimes.map((runtime) => runtime.cpuPercent)); + const memoryUsage = averagePercent(runtimes.map((runtime) => runtime.memoryPercent)); + + return { + ...service, + createdAt: formatRemoteTime(mostFrequent(runtimes.map((runtime) => runtime.createdAt))) || "未采集", + dependencies: managedDependencies(inventory), + errorRate: "未接入", + events: createEvents(service, runtimes), + instances: createInstances(service, runtimes), + metrics: createMetrics(cpuUsage, memoryUsage), + qps: "未接入", + runningTime: mostFrequent(runtimes.map((runtime) => runtime.runningTime)) || "未采集", + }; } diff --git a/src/entities/service/api/getServices.ts b/src/entities/service/api/getServices.ts index cf2a81d..c5e8949 100644 --- a/src/entities/service/api/getServices.ts +++ b/src/entities/service/api/getServices.ts @@ -1,65 +1,59 @@ -import { mockServices } from "../mock/services"; -import { ServiceListParams, ServiceListResponse } from "../model/types"; +import type { Service, ServiceListParams, ServiceListResponse } from "../model/types"; +import { + averagePercent, + fetchDeployDiscover, + fetchDeployInventory, + getRuntimeServices, + mostFrequent, + serviceNameToId, + statusFromRuntimes, +} from "./deployApi"; -const MOCK_LATENCY_MS = 160; +function createServiceFromRuntime( + params: ServiceListParams, + serviceName: string, + deployService: NonNullable>["services"]>[string], + runtimes: ReturnType, +): Service { + const desiredInstances = Math.max(deployService.hosts?.length ?? runtimes.length, 1); + const readyInstances = runtimes.filter((runtime) => runtime.status === "healthy").length; + const version = mostFrequent(runtimes.map((runtime) => runtime.imageTag || runtime.image)) || "未采集"; + const runningTime = mostFrequent(runtimes.map((runtime) => runtime.runningTime)); + const firstRuntime = runtimes[0]; -type DeployInventoryService = { - hosts?: string[]; - target_port?: number; -}; - -type DeployInventoryResponse = { - ok?: boolean; - services?: Record; -}; - -async function getServicesFromDeployApi(params: ServiceListParams) { - const response = await fetch("/deploy/api/hyapp/inventory", { - headers: { - accept: "application/json", - }, - }); - - if (!response.ok) { - throw new Error(`Deploy API HTTP ${response.status}`); - } - - const payload = (await response.json()) as DeployInventoryResponse; - - if (payload.ok === false || !payload.services) { - throw new Error("Deploy API inventory is unavailable"); - } - - return Object.entries(payload.services).map(([serviceName, deployService]) => { - const fallback = mockServices.find((service) => service.name === serviceName); - const instanceCount = Math.max(deployService.hosts?.length ?? fallback?.desiredInstances ?? 1, 1); - - return { - cpuUsage: fallback?.cpuUsage ?? 0, - desiredInstances: instanceCount, - id: fallback?.id ?? `svc-${serviceName.replace(/-service$/, "")}`, - lastReleaseText: fallback?.lastReleaseText ?? "inventory", - memoryUsage: fallback?.memoryUsage ?? 0, - name: serviceName, - namespace: params.environment, - owner: fallback?.owner ?? "platform", - readyInstances: instanceCount, - status: fallback?.status ?? "healthy", - version: fallback?.version ?? "inventory", - }; - }); + return { + configPath: firstRuntime?.configPath ?? deployService.config_path, + configYaml: firstRuntime?.configYaml, + configYamlTruncated: firstRuntime?.configTruncated, + container: deployService.container ?? firstRuntime?.container, + cpuUsage: averagePercent(runtimes.map((runtime) => runtime.cpuPercent)), + desiredInstances, + id: serviceNameToId(serviceName), + lastReleaseText: runningTime ? `运行 ${runningTime}` : "未采集", + memoryUsage: averagePercent(runtimes.map((runtime) => runtime.memoryPercent)), + name: serviceName, + namespace: params.environment, + owner: deployService.owner ?? "未配置", + readyInstances, + sourceHosts: runtimes.map((runtime) => runtime.host), + status: statusFromRuntimes(runtimes, desiredInstances), + targetPort: deployService.target_port ?? firstRuntime?.targetPort, + unit: deployService.unit ?? firstRuntime?.unit, + version, + }; } export async function getServices(params: ServiceListParams): Promise { - await new Promise((resolve) => globalThis.setTimeout(resolve, MOCK_LATENCY_MS)); + const inventory = await fetchDeployInventory(); + const serviceNames = Object.keys(inventory.services ?? {}); + const discover = await fetchDeployDiscover(serviceNames, false); + const runtimes = getRuntimeServices(discover); + const items = serviceNames.map((serviceName) => { + const deployService = inventory.services?.[serviceName] ?? {}; + const serviceRuntimes = runtimes.filter((runtime) => runtime.name === serviceName); - let items; - - try { - items = await getServicesFromDeployApi(params); - } catch { - items = mockServices.filter((service) => service.namespace === params.environment); - } + return createServiceFromRuntime(params, serviceName, deployService, serviceRuntimes); + }); return { items, diff --git a/src/entities/service/api/useServiceDetailQuery.ts b/src/entities/service/api/useServiceDetailQuery.ts index 7ad5dd7..2bc2beb 100644 --- a/src/entities/service/api/useServiceDetailQuery.ts +++ b/src/entities/service/api/useServiceDetailQuery.ts @@ -4,11 +4,11 @@ import { getServiceDetail } from "./getServiceDetail"; const STATUS_REFRESH_INTERVAL_MS = 15_000; -export function useServiceDetailQuery(serviceId: string) { +export function useServiceDetailQuery(serviceId: string, environment = "prod") { return useQuery({ enabled: serviceId.length > 0, - queryFn: () => getServiceDetail(serviceId), - queryKey: queryKeys.serviceDetail(serviceId), + queryFn: () => getServiceDetail(serviceId, environment), + queryKey: queryKeys.serviceDetail(serviceId, environment), refetchInterval: serviceId.length > 0 ? STATUS_REFRESH_INTERVAL_MS : false, }); } diff --git a/src/entities/service/index.ts b/src/entities/service/index.ts index a513d90..548b0eb 100644 --- a/src/entities/service/index.ts +++ b/src/entities/service/index.ts @@ -3,7 +3,6 @@ export { getServices } from "./api/getServices"; export { useServiceDetailQuery } from "./api/useServiceDetailQuery"; export { useServicesQuery } from "./api/useServicesQuery"; export { getServiceSummary } from "./lib/getServiceSummary"; -export { mockServices } from "./mock/services"; export type { Service, ServiceDependency, diff --git a/src/entities/service/lib/getServiceSummary.ts b/src/entities/service/lib/getServiceSummary.ts index 4cf24a2..d8b8118 100644 --- a/src/entities/service/lib/getServiceSummary.ts +++ b/src/entities/service/lib/getServiceSummary.ts @@ -2,30 +2,17 @@ import { Service } from "../model/types"; export type ServiceSummary = { abnormalCount: number; - activeAlerts: number; - databaseHealthyText: string; healthyCount: number; - latestRelease: { - serviceName: string; - version: string; - }; + totalCount: number; }; export function getServiceSummary(services: Service[]): ServiceSummary { const healthyCount = services.filter((service) => service.status === "healthy").length; const abnormalCount = services.filter((service) => service.status === "warning" || service.status === "critical").length; - const criticalCount = services.filter((service) => service.status === "critical").length; - const warningCount = services.filter((service) => service.status === "warning").length; - const latestRelease = services[0]; return { abnormalCount, - activeAlerts: criticalCount * 3 + warningCount, - databaseHealthyText: "3/3", healthyCount, - latestRelease: { - serviceName: latestRelease?.name ?? "-", - version: latestRelease?.version ?? "-", - }, + totalCount: services.length, }; } diff --git a/src/entities/service/mock/services.ts b/src/entities/service/mock/services.ts deleted file mode 100644 index b014387..0000000 --- a/src/entities/service/mock/services.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Service } from "../model/types"; -import { hyappDeploymentPlan } from "../../deployment-plan"; - -export const mockServices: Service[] = hyappDeploymentPlan.services.map((service) => ({ - cpuUsage: service.cpuUsage, - desiredInstances: service.desiredInstances, - id: service.id, - lastReleaseText: service.lastReleaseText, - memoryUsage: service.memoryUsage, - name: service.name, - namespace: "prod", - owner: service.owner, - readyInstances: service.readyInstances, - status: service.status, - version: service.version, -})); diff --git a/src/entities/service/model/types.ts b/src/entities/service/model/types.ts index f2c7e8c..543d301 100644 --- a/src/entities/service/model/types.ts +++ b/src/entities/service/model/types.ts @@ -3,6 +3,10 @@ import { StatusBadgeStatus } from "../../../shared/ui/StatusBadge"; export type ServiceStatus = StatusBadgeStatus; export type Service = { + configPath?: string | undefined; + configYaml?: string | undefined; + configYamlTruncated?: boolean | undefined; + container?: string | undefined; id: string; name: string; namespace: string; @@ -14,6 +18,9 @@ export type Service = { memoryUsage: number; lastReleaseText: string; owner: string; + sourceHosts?: string[] | undefined; + targetPort?: number | undefined; + unit?: string | undefined; }; export type ServiceDependency = { @@ -49,8 +56,18 @@ export type ServiceInstanceConfigItem = { export type ServiceInstance = { configItems: ServiceInstanceConfigItem[]; + configPath?: string | undefined; + configYaml?: string | undefined; + configYamlTruncated?: boolean | undefined; + containerStatus?: string | undefined; + cpuUsage?: number | null | undefined; + envFile?: string | undefined; + healthStatus?: string | undefined; id: string; image: string; + logs?: string | undefined; + memoryUsage?: number | null | undefined; + memoryUsageText?: string | undefined; name: string; podIp: string; node: string; diff --git a/src/features/release-service/api/createRelease.ts b/src/features/release-service/api/createRelease.ts index 56f7f80..4d53663 100644 --- a/src/features/release-service/api/createRelease.ts +++ b/src/features/release-service/api/createRelease.ts @@ -1,26 +1,18 @@ -import { mockServices } from "../../../entities/service"; -import { CreateReleaseRequest, CreateReleaseResult } from "../model/types"; +import type { CreateReleaseRequest, CreateReleaseResult } from "../model/types"; type DeployApiResult = { + error?: string; ok?: boolean; steps?: unknown[]; - error?: string; }; export async function createRelease(request: CreateReleaseRequest): Promise { - const service = mockServices.find((item) => item.id === request.serviceId); - - if (!service) { - throw new Error("服务不存在"); - } - - const fromVersion = service.version; const shouldExecute = request.environment === "prod"; const response = await fetch("/deploy/api/hyapp/deploy", { body: JSON.stringify({ confirmed: shouldExecute ? request.confirmed === true : true, dryRun: !shouldExecute, - services: [service.name], + services: [request.serviceName], tag: request.targetVersion, }), headers: { @@ -34,16 +26,11 @@ export async function createRelease(request: CreateReleaseRequest): Promise item.name).join("、") + " 不随发布重启", + result: !isProd || hasRuntimeTarget ? "passed" : "warning", }, { + detail: service.configPath ?? "TAT 未返回配置路径", id: "config", - label: "配置变更", - result: "passed", - detail: deploymentService?.prod?.configPath ?? deploymentService?.local.configPath ?? "按服务配置模板校验", + label: "配置", + result: service.configPath ? "passed" : "warning", }, { + detail: `${service.name}:${service.version}`, id: "image", - label: "镜像拉取", - result: "passed", - detail: `${hyappDeploymentPlan.registry}/${service.name}:${service.version}`, + label: "镜像", + result: service.version && service.version !== "未采集" ? "passed" : "warning", }, ]; } diff --git a/src/features/release-service/lib/getReleaseVersionOptions.ts b/src/features/release-service/lib/getReleaseVersionOptions.ts deleted file mode 100644 index 868cccb..0000000 --- a/src/features/release-service/lib/getReleaseVersionOptions.ts +++ /dev/null @@ -1,39 +0,0 @@ -export function getReleaseVersionOptions(currentVersion: string) { - if (/^\d{8}-\d{4}$/.test(currentVersion)) { - const now = new Date(); - const nextTag = [ - now.getUTCFullYear(), - String(now.getUTCMonth() + 1).padStart(2, "0"), - String(now.getUTCDate()).padStart(2, "0"), - "-", - String(now.getUTCHours()).padStart(2, "0"), - String(now.getUTCMinutes()).padStart(2, "0"), - ].join(""); - const fallbackTag = `${nextTag}-next`; - - return [ - { label: nextTag, value: nextTag }, - { label: fallbackTag, value: fallbackTag }, - { label: currentVersion, value: currentVersion, disabled: true }, - ]; - } - - const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(currentVersion); - - if (!match) { - return [ - { label: `${currentVersion}-next`, value: `${currentVersion}-next` }, - { label: currentVersion, value: currentVersion, disabled: true }, - ]; - } - - const major = Number(match[1]); - const minor = Number(match[2]); - const patch = Number(match[3]); - - return [ - { label: `v${major}.${minor}.${patch + 1}`, value: `v${major}.${minor}.${patch + 1}` }, - { label: `v${major}.${minor + 1}.0`, value: `v${major}.${minor + 1}.0` }, - { label: currentVersion, value: currentVersion, disabled: true }, - ]; -} diff --git a/src/features/release-service/model/types.ts b/src/features/release-service/model/types.ts index f7305de..17180f3 100644 --- a/src/features/release-service/model/types.ts +++ b/src/features/release-service/model/types.ts @@ -10,8 +10,10 @@ export type ReleasePrecheck = { export type CreateReleaseRequest = { confirmed?: boolean; environment: string; + fromVersion: string; operator: string; serviceId: string; + serviceName: string; strategy: ReleaseStrategy; targetVersion: string; }; diff --git a/src/features/release-service/ui/ReleaseDrawer.module.css b/src/features/release-service/ui/ReleaseDrawer.module.css index 6c8c510..d7199bd 100644 --- a/src/features/release-service/ui/ReleaseDrawer.module.css +++ b/src/features/release-service/ui/ReleaseDrawer.module.css @@ -207,6 +207,30 @@ line-height: 18px; } +.tagInput { + min-height: 38px; + width: 100%; + min-width: 0; + padding: 0 12px; + color: var(--color-text-primary); + border: 1px solid rgba(255, 255, 255, 0.66); + border-radius: 14px; + outline: none; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.62), rgba(241, 248, 255, 0.32)), + rgba(255, 255, 255, 0.32); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.72), + inset 0 -1px 0 rgba(91, 125, 166, 0.08); + font-size: 13px; + font-weight: 800; +} + +.tagInput:focus { + border-color: rgba(47, 125, 246, 0.42); + box-shadow: var(--shadow-liquid-control-hover); +} + .strategyList, .precheckList, .summary { diff --git a/src/features/release-service/ui/ReleaseDrawer.tsx b/src/features/release-service/ui/ReleaseDrawer.tsx index 5d02ab9..490d468 100644 --- a/src/features/release-service/ui/ReleaseDrawer.tsx +++ b/src/features/release-service/ui/ReleaseDrawer.tsx @@ -1,21 +1,14 @@ import { useEffect, useMemo, useState } from "react"; import { AlertTriangle, CheckCircle2, Loader2, Rocket, X } from "lucide-react"; import { Environment } from "../../../app/store/useAppContextStore"; -import { - getHyappDeploymentHosts, - getHyappDeploymentServiceById, - hyappDeploymentPlan, -} from "../../../entities/deployment-plan"; import { Service } from "../../../entities/service"; import { Button } from "../../../shared/ui/Button"; import { Checkbox } from "../../../shared/ui/Checkbox"; import { IconButton } from "../../../shared/ui/IconButton"; import { Radio } from "../../../shared/ui/Radio"; -import { Select } from "../../../shared/ui/Select"; import { Tag } from "../../../shared/ui/Tag"; import { useCreateReleaseMutation } from "../api/useCreateReleaseMutation"; import { getReleasePrechecks } from "../lib/getReleasePrechecks"; -import { getReleaseVersionOptions } from "../lib/getReleaseVersionOptions"; import { ReleaseStrategy } from "../model/types"; import styles from "./ReleaseDrawer.module.css"; @@ -45,10 +38,7 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user const [confirmed, setConfirmed] = useState(false); const releaseMutation = useCreateReleaseMutation(); - const versionOptions = useMemo(() => (service ? getReleaseVersionOptions(service.version) : []), [service]); const prechecks = useMemo(() => (service ? getReleasePrechecks(service, environment) : []), [environment, service]); - const deployTarget = useMemo(() => (service ? getHyappDeploymentServiceById(service.id) : undefined), [service]); - const deployHosts = useMemo(() => (deployTarget ? getHyappDeploymentHosts(deployTarget) : []), [deployTarget]); useEffect(() => { if (!service) { @@ -56,34 +46,28 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user } setStrategy("rolling"); - setTargetVersion(versionOptions[0]?.value ?? service.version); + setTargetVersion(""); setConfirmed(false); releaseMutation.reset(); - }, [releaseMutation, service, versionOptions]); + }, [releaseMutation, service]); if (!service) { return null; } const isProd = environment === "prod"; - const hasExecutableTarget = !isProd || Boolean(deployTarget?.prod); + const hasExecutableTarget = !isProd || Boolean(service.unit && service.container && service.targetPort); const canSubmit = targetVersion !== "" && targetVersion !== service.version && hasExecutableTarget && (!isProd || confirmed); const hasWarning = prechecks.some((item) => item.result === "warning"); - const deployCommand = - isProd && deployTarget?.prod - ? [ - "python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy", - " --env-file /opt/hy-app-monitor/.env", - " --inventory deploy/tencent-tat/hyapp-services.inventory.prod.json", - ` --services ${service.name}`, - ` --tag ${targetVersion}`, - " --yes", - ].join("\n") - : deployTarget?.local.commands.rebuild ?? `cd ${hyappDeploymentPlan.repositoryPath} && make rebuild SERVICE=${service.name}`; - const targetSummary = - isProd && deployTarget?.prod - ? `${deployTarget.prod.unit} / ${deployTarget.prod.clb.loadBalancerId}` - : deployTarget?.local.composeService ?? service.name; + const deployCommand = [ + "python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy", + " --env-file /opt/hy-app-monitor/.env", + " --inventory deploy/tencent-tat/hyapp-services.inventory.prod.json", + ` --services ${service.name}`, + ` --tag ${targetVersion || ""}`, + isProd ? " --yes" : " --dry-run", + ].join("\n"); + const targetSummary = service.unit && service.container ? `${service.unit} / ${service.container}` : service.name; async function handleSubmit() { if (!service || !canSubmit) { @@ -95,6 +79,8 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user confirmed, operator: userName, serviceId: service.id, + serviceName: service.name, + fromVersion: service.version, strategy, targetVersion, }); @@ -128,7 +114,13 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user 1

选择目标版本

- setTargetVersion(event.target.value.trim())} + placeholder="输入真实镜像 tag" + value={targetVersion} + />

当前版本 {service.version}

@@ -166,9 +158,7 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user 实例 - {deployHosts.length > 0 - ? deployHosts.map((host) => host.id).join("、") - : "local-compose"} + {service.sourceHosts?.length ? service.sourceHosts.join("、") : "TAT 未返回"}
diff --git a/src/pages/audit/AuditPage.tsx b/src/pages/audit/AuditPage.tsx deleted file mode 100644 index 541035f..0000000 --- a/src/pages/audit/AuditPage.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PlaceholderPage } from "../placeholder"; - -export function AuditPage() { - return ; -} diff --git a/src/pages/audit/index.ts b/src/pages/audit/index.ts deleted file mode 100644 index 65e6ef9..0000000 --- a/src/pages/audit/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AuditPage } from "./AuditPage"; diff --git a/src/pages/logs-events/LogsEventsPage.tsx b/src/pages/logs-events/LogsEventsPage.tsx deleted file mode 100644 index 517019f..0000000 --- a/src/pages/logs-events/LogsEventsPage.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PlaceholderPage } from "../placeholder"; - -export function LogsEventsPage() { - return ; -} diff --git a/src/pages/logs-events/index.ts b/src/pages/logs-events/index.ts deleted file mode 100644 index 53ea3b4..0000000 --- a/src/pages/logs-events/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LogsEventsPage } from "./LogsEventsPage"; diff --git a/src/pages/overview/OverviewPage.tsx b/src/pages/overview/OverviewPage.tsx index 0bbe4ef..6ed5762 100644 --- a/src/pages/overview/OverviewPage.tsx +++ b/src/pages/overview/OverviewPage.tsx @@ -1,7 +1,8 @@ -import { AlertTriangle, Bell, Database, Layers3, Rocket } from "lucide-react"; +import { AlertTriangle, Database, Layers3, Server } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { getServiceSummary, useServicesQuery } from "../../entities/service"; import { useAppContextStore } from "../../app/store/useAppContextStore"; +import { useDataResourcesQuery } from "../../entities/data-resource"; import { useReleaseDrawerStore } from "../../features/release-service"; import { Card } from "../../shared/ui/Card"; import { MetricCard } from "../../shared/ui/MetricCard"; @@ -18,8 +19,13 @@ export function OverviewPage() { environment, projectId, }); + const dataResourcesQuery = useDataResourcesQuery(); const services = servicesQuery.data?.items ?? []; const summary = getServiceSummary(services); + const dataResourceSummary = dataResourcesQuery.data?.summary; + const dataResourceHealthyText = dataResourceSummary + ? `${dataResourceSummary.healthy}/${Object.values(dataResourceSummary).reduce((total, value) => total + value, 0)}` + : "-"; return (
@@ -35,8 +41,14 @@ export function OverviewPage() {
} - label="运行中服务数" - meta="Healthy" + label="服务总数" + meta="Inventory" + value={summary.totalCount} + /> + } + label="健康服务数" + meta="TAT" tone="green" value={summary.healthyCount} /> @@ -47,25 +59,12 @@ export function OverviewPage() { tone="orange" value={summary.abnormalCount} /> - } - label="最近发布" - meta={summary.latestRelease.serviceName} - value={summary.latestRelease.version} - /> - } - label="活跃告警" - meta="未恢复" - tone="red" - value={summary.activeAlerts} - /> } - label="数据库状态" - meta="正常资源" + label="数据资源" + meta="健康 / 总数" tone="green" - value={summary.databaseHealthyText} + value={dataResourceHealthyText} />
diff --git a/src/pages/placeholder/PlaceholderPage.tsx b/src/pages/placeholder/PlaceholderPage.tsx deleted file mode 100644 index 26421c5..0000000 --- a/src/pages/placeholder/PlaceholderPage.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Wrench } from "lucide-react"; -import { Card } from "../../shared/ui/Card"; -import { EmptyState } from "../../shared/ui/EmptyState"; - -type PlaceholderPageProps = { - title: string; -}; - -export function PlaceholderPage({ title }: PlaceholderPageProps) { - return ( -
-
-
-

{title}

-

第一阶段保留入口,后续迭代接入完整能力

-
-
- - } title="建设中" /> - -
- ); -} diff --git a/src/pages/placeholder/index.ts b/src/pages/placeholder/index.ts deleted file mode 100644 index 1f2c931..0000000 --- a/src/pages/placeholder/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PlaceholderPage } from "./PlaceholderPage"; diff --git a/src/pages/releases/ReleasesPage.tsx b/src/pages/releases/ReleasesPage.tsx index 3ee82e2..5e5a9ea 100644 --- a/src/pages/releases/ReleasesPage.tsx +++ b/src/pages/releases/ReleasesPage.tsx @@ -1,65 +1,167 @@ +import { CloudCog, Database, FileText, HardDrive, PlayCircle, ServerCog, ShieldAlert } from "lucide-react"; import { - CloudCog, - Database, - FileText, - GitBranch, - HardDrive, - Network, - PlayCircle, - ServerCog, - ShieldAlert, - TerminalSquare, -} from "lucide-react"; -import { - getHyappDeploymentHosts, - hyappDeploymentPlan, - type HyappDeploymentService, + type DeployApiDiscoverResponse, + type DeployApiInventoryResponse, useDeployApiDiscoverQuery, useDeployApiInventoryQuery, useDeployApiPlanQuery, useDeployApiTestboxConfigsQuery, } from "../../entities/deployment-plan"; -import { mockServices } from "../../entities/service"; +import type { Service, ServiceStatus } from "../../entities/service"; import { useReleaseDrawerStore } from "../../features/release-service"; import { Button } from "../../shared/ui/Button"; import { Card } from "../../shared/ui/Card"; +import { EmptyState } from "../../shared/ui/EmptyState"; import { StatusBadge } from "../../shared/ui/StatusBadge"; import { Table, type TableColumn } from "../../shared/ui/Table"; import { Tag } from "../../shared/ui/Tag"; import styles from "./ReleasesPage.module.css"; -const remoteDiscoverServices = hyappDeploymentPlan.services.filter((service) => service.prod).map((service) => service.name); -const testboxConfigServices = [ - "gateway-service", - "room-service", - "wallet-service", - "user-service", - "activity-service", - "cron-service", - "game-service", - "notice-service", - "admin-server", -]; +type RuntimeService = NonNullable[number]["services"]>[number] & { + host: string; + privateIp: string; +}; + +type ServiceRow = { + clb: DeployApiInventoryResponse["services"][string]["clb"]; + configPath: string; + container: string; + hosts: string[]; + image: string; + service: Service; + targetPort: number | null; + unit: string; +}; + +function serviceNameToId(serviceName: string) { + return `svc-${serviceName.replace(/-service$/, "")}`; +} + +function averagePercent(values: Array) { + const numericValues = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + + if (numericValues.length === 0) { + return 0; + } + + return Math.round(numericValues.reduce((total, value) => total + value, 0) / numericValues.length); +} + +function mostFrequent(values: Array) { + const counts = new Map(); + + values.forEach((value) => { + const text = String(value || "").trim(); + + if (text) { + counts.set(text, (counts.get(text) ?? 0) + 1); + } + }); + + return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? ""; +} + +function statusFromRuntime(runtimes: RuntimeService[], desiredInstances: number): ServiceStatus { + const readyCount = runtimes.filter((runtime) => runtime.status === "healthy").length; + + if (desiredInstances > 0 && readyCount >= desiredInstances) { + return "healthy"; + } + + if (readyCount > 0) { + return "warning"; + } + + if (runtimes.some((runtime) => runtime.status === "running")) { + return "running"; + } + + return runtimes.length > 0 ? "critical" : "unknown"; +} + +function runtimeServices(discover: DeployApiDiscoverResponse | undefined): RuntimeService[] { + return (discover?.hosts ?? []).flatMap((host) => + (host.services ?? []).map((service) => ({ + ...service, + host: host.host, + privateIp: host.private_ip, + })), + ); +} + +function buildRows( + inventory: DeployApiInventoryResponse | undefined, + discover: DeployApiDiscoverResponse | undefined, +): ServiceRow[] { + if (!inventory?.services) { + return []; + } + + const runtimes = runtimeServices(discover); + + return Object.entries(inventory.services).map(([serviceName, deployService]) => { + const serviceRuntimes = runtimes.filter((runtime) => runtime.name === serviceName); + const hosts = deployService.hosts ?? []; + const desiredInstances = Math.max(hosts.length, serviceRuntimes.length, 1); + const readyInstances = serviceRuntimes.filter((runtime) => runtime.status === "healthy").length; + const version = mostFrequent(serviceRuntimes.map((runtime) => runtime.imageTag || runtime.image)) || "未采集"; + const runningTime = mostFrequent(serviceRuntimes.map((runtime) => runtime.runningTime)); + const firstRuntime = serviceRuntimes[0]; + const unit = deployService.unit ?? firstRuntime?.unit ?? ""; + const container = deployService.container ?? firstRuntime?.container ?? ""; + const configPath = firstRuntime?.configPath ?? deployService.config_path ?? ""; + const targetPort = deployService.target_port ?? firstRuntime?.targetPort ?? null; + const service: Service = { + configPath, + container, + cpuUsage: averagePercent(serviceRuntimes.map((runtime) => runtime.cpuPercent)), + desiredInstances, + id: serviceNameToId(serviceName), + lastReleaseText: runningTime ? `运行 ${runningTime}` : "未采集", + memoryUsage: averagePercent(serviceRuntimes.map((runtime) => runtime.memoryPercent)), + name: serviceName, + namespace: "prod", + owner: deployService.owner ?? "未配置", + readyInstances, + sourceHosts: serviceRuntimes.map((runtime) => runtime.host), + status: statusFromRuntime(serviceRuntimes, desiredInstances), + targetPort: targetPort ?? undefined, + unit, + version, + }; + + return { + clb: deployService.clb, + configPath, + container, + hosts, + image: mostFrequent(serviceRuntimes.map((runtime) => runtime.image)) || deployService.image_template || "", + service, + targetPort, + unit, + }; + }); +} export function ReleasesPage() { const openRelease = useReleaseDrawerStore((state) => state.openRelease); const apiInventoryQuery = useDeployApiInventoryQuery(); - const apiPlanQuery = useDeployApiPlanQuery(["gateway-service", "room-service", "user-service"]); - const apiDiscoverQuery = useDeployApiDiscoverQuery(remoteDiscoverServices, true); - const testboxConfigQuery = useDeployApiTestboxConfigsQuery(testboxConfigServices); - const apiStatusTone = apiInventoryQuery.isSuccess ? "success" : apiInventoryQuery.isError ? "warning" : "running"; - const apiStatusText = apiInventoryQuery.isSuccess ? "已连接" : apiInventoryQuery.isError ? "未连接" : "查询中"; - const adminProd = hyappDeploymentPlan.adminProduction; - const testbox = hyappDeploymentPlan.testbox; + const serviceNames = Object.keys(apiInventoryQuery.data?.services ?? {}); + const apiPlanQuery = useDeployApiPlanQuery(serviceNames, serviceNames.length > 0); + const apiDiscoverQuery = useDeployApiDiscoverQuery(serviceNames, serviceNames.length > 0); + const testboxConfigQuery = useDeployApiTestboxConfigsQuery(serviceNames, serviceNames.length > 0); + const rows = buildRows(apiInventoryQuery.data, apiDiscoverQuery.data); const remoteHosts = apiDiscoverQuery.data?.hosts ?? []; const remoteClbSnapshots = apiDiscoverQuery.data?.clb ?? []; - const lastRemoteSyncedAt = apiDiscoverQuery.dataUpdatedAt - ? new Date(apiDiscoverQuery.dataUpdatedAt).toLocaleTimeString("zh-CN", { hour12: false }) - : "-"; const remoteHostSuccessCount = remoteHosts.filter((host) => host.status === "SUCCESS").length; const remoteClbSuccessCount = remoteClbSnapshots.filter((snapshot) => !snapshot.error).length; const remoteErrorCount = remoteHosts.filter((host) => host.status !== "SUCCESS").length + remoteClbSnapshots.filter((snapshot) => snapshot.error).length; + const lastRemoteSyncedAt = apiDiscoverQuery.dataUpdatedAt + ? new Date(apiDiscoverQuery.dataUpdatedAt).toLocaleTimeString("zh-CN", { hour12: false }) + : "-"; + const apiStatusTone = apiInventoryQuery.isSuccess ? "success" : apiInventoryQuery.isError ? "warning" : "running"; + const apiStatusText = apiInventoryQuery.isSuccess ? "已连接" : apiInventoryQuery.isError ? "未连接" : "查询中"; const remoteStatusTone = apiDiscoverQuery.isFetching && !apiDiscoverQuery.data ? "running" : apiDiscoverQuery.isError || remoteErrorCount > 0 @@ -76,95 +178,80 @@ export function ReleasesPage() { : apiDiscoverQuery.isSuccess ? "已同步" : "未查询"; - const remoteSummaryText = apiDiscoverQuery.isSuccess - ? `实例 ${remoteHostSuccessCount}/${remoteHosts.length},CLB ${remoteClbSuccessCount}/${remoteClbSnapshots.length},最近刷新 ${lastRemoteSyncedAt}` - : `覆盖 ${remoteDiscoverServices.length} 个生产服务,通过腾讯云 TAT 读取运行态,15 秒自动刷新`; - const handleRemoteDiscover = () => { - void apiDiscoverQuery.refetch(); - }; const testboxConfigTone = testboxConfigQuery.isSuccess ? "success" : testboxConfigQuery.isError ? "warning" : "running"; const testboxConfigText = testboxConfigQuery.isSuccess ? "已读取" : testboxConfigQuery.isError ? "读取失败" : "读取中"; - const handleTestboxConfigRefresh = () => { - void testboxConfigQuery.refetch(); - }; - const serviceColumns: TableColumn[] = [ + const serviceColumns: TableColumn[] = [ { key: "service", title: "服务", - render: (service) => ( + render: (row) => ( - {service.name} - {service.domain} + {row.service.name} + {row.configPath || "TAT 未返回配置路径"} ), }, { key: "status", title: "状态", - render: (service) => , + render: (row) => , }, { - key: "local", - title: "本地 Compose", - render: (service) => ( - - {service.local.composeService} - {service.local.aliases[0]} + key: "version", + title: "镜像", + render: (row) => ( + + {row.service.version} + {row.image || "TAT 未返回镜像"} ), }, { - key: "prod", - title: "生产目标", - render: (service) => - service.prod ? ( - - {getHyappDeploymentHosts(service).map((host) => ( - - {host.id} - - ))} - - ) : ( - 未进 TAT inventory - ), + key: "instances", + title: "实例", + render: (row) => `${row.service.readyInstances}/${row.service.desiredInstances}`, }, { - key: "unit", + key: "target", title: "Unit / 端口", - render: (service) => ( + render: (row) => ( - {service.prod?.unit ?? service.local.composeService} - {service.prod?.targetPort ?? service.local.grpcPort ?? service.local.httpPort} + {row.unit || "TAT 未返回"} + {row.targetPort ?? "TAT 未返回"} + + ), + }, + { + key: "hosts", + title: "机器", + render: (row) => ( + + {row.hosts.length > 0 ? row.hosts.map((host) => {host}) : TAT 未返回} ), }, { key: "clb", title: "CLB", - render: (service) => - service.prod ? ( + render: (row) => + row.clb?.enabled ? ( - {service.prod.clb.loadBalancerId} - {service.prod.clb.entrypoint} + {row.clb.load_balancer_id ?? "TAT 未返回"} + {row.clb.listener_ids?.join("、") || "TAT 未返回 listener"} ) : ( - local only + 未启用 ), }, { + className: styles.actions, key: "action", title: "操作", - className: styles.actions, - render: (service) => { - const serviceEntity = mockServices.find((item) => item.id === service.id); - - return ( - - ); - }, + render: (row) => ( + + ), }, ]; @@ -172,30 +259,18 @@ export function ReleasesPage() {
-

HyApp 部署方案

-

- 接入 {hyappDeploymentPlan.repositoryPath} 的 Compose、systemd 和 Tencent TAT 发布边界 -

+

发布

- - - - -
- 代码仓库 - {hyappDeploymentPlan.repositoryPath} -
-
- 生产区域 - {hyappDeploymentPlan.region} + 区域 + {apiInventoryQuery.data?.region ?? "-"}
@@ -204,7 +279,7 @@ export function ReleasesPage() {
镜像仓库 - {hyappDeploymentPlan.registry} + {apiInventoryQuery.data?.registry ?? "-"}
@@ -212,245 +287,62 @@ export function ReleasesPage() {
- 发布控制面 - - {hyappDeploymentPlan.deployServer.id} / {hyappDeploymentPlan.deployServer.privateIp} - + 服务数 + {serviceNames.length} +
+
+ + + + +
+ 计划步骤 + {apiPlanQuery.data?.steps.length ?? "-"}
-
- {hyappDeploymentPlan.modes.map((mode) => ( - -
- {mode.id === "local-compose" ? : } -

{mode.title}

-
-

{mode.description}

-
- ))} -
- - -
-
-

正式后台部署

- - 前端 {adminProd.frontend.privateIp}:{adminProd.frontend.port} 反代内网后端 {adminProd.backend.listenAddr} - -
- - {adminProd.backend.nodeId} - -
-
-
- 后台前端机器 - - {adminProd.frontend.publicIp}:{adminProd.frontend.port} - - - {adminProd.frontend.instanceId} / {adminProd.frontend.privateIp} - -
-
- 前端部署 - {adminProd.frontend.timer} - - {adminProd.frontend.branch} 分支,{adminProd.frontend.deployScript} - -
-
- admin-server 机器 - {adminProd.backend.listenAddr} - - {adminProd.backend.instanceId} / {adminProd.backend.publicIp} - -
-
- 后端进程 - {adminProd.backend.unit} - {adminProd.backend.wgUnit} -
-
-
-
- 前端 API 转发 - {adminProd.frontend.apiProxy} - {adminProd.frontend.nginxConfig} -
-
- 后端部署文件 - {adminProd.backend.binaryPath} - {adminProd.backend.configPath} -
-
- 旧后台机 - - {adminProd.previousBackend.name} / {adminProd.previousBackend.publicIp} - - {adminProd.previousBackend.status} -
-
-
- {adminProd.endpoints.map((endpoint) => ( - - {endpoint.label} - {endpoint.url} - - ))} -
-
- - -
-
-

测试部署方式

- - {testbox.publicIp} / {testbox.privateIp} 拉取 {testbox.branch} 分支,timer 按提交变化增量重启 - -
- - {testbox.timer} - -
-
-
- 部署脚本 - {testbox.deployScript} - {testbox.root} -
-
- 源码目录 - {testbox.source} - {testbox.composeCommand} -
-
- 自动清理 - {testbox.cleanupTimer} - 构建缓存、旧镜像、退出容器按现有测试机策略清理 -
-
- Admin API - {testbox.adminApiUrl} - 广州 admin-platform /api 通过内网转发 -
-
- 后台前端 - {testbox.adminPlatformUrl} - 广州 172.16.0.10:7001,{testbox.adminPlatformBranch} 分支 -
-
-
- {testbox.endpoints.map((endpoint) => ( - - {endpoint.label} - {endpoint.url} - - ))} -
-
- - -
-
-

测试机 Go 服务 YAML

- - {testboxConfigQuery.data?.target?.root ?? testbox.root},当前提交{" "} - {testboxConfigQuery.data?.deployedCommit?.slice(0, 12) ?? "-"} - -
-
- - {testboxConfigText} - - -
-
- {testboxConfigQuery.isError ? ( -
- {testboxConfigQuery.error instanceof Error ? testboxConfigQuery.error.message : "测试机 YAML 读取失败"} -
- ) : null} -
- {(testboxConfigQuery.data?.files ?? []).map((file) => ( -
-
- - {file.service} - {file.path} - {!file.exists ? 缺失 : null} - {file.truncated ? 已截断 : null} -
-
-                {file.exists ? file.content : "文件不存在"}
-              
-
- ))} -
-
-

Deploy API

- 前端同源调用 `/deploy/api/*` 查询 inventory 和发布计划 + inventory、发布计划、TAT 运行态
{apiStatusText}
-
-
- Inventory 服务数 - - {apiInventoryQuery.data ? Object.keys(apiInventoryQuery.data.services).length : "-"} - -
-
- Plan 步骤数 - {apiPlanQuery.data?.steps.length ?? "-"} -
-
- Registry - {apiInventoryQuery.data?.registry ?? hyappDeploymentPlan.registry} -
-
- 接口错误 - {apiInventoryQuery.error instanceof Error ? apiInventoryQuery.error.message : "无"} -
-
真实运行态 - {remoteSummaryText} + + 实例 {remoteHostSuccessCount}/{remoteHosts.length},CLB {remoteClbSuccessCount}/{remoteClbSnapshots.length},最近刷新{" "} + {lastRemoteSyncedAt} +
{remoteStatusText}
- {apiDiscoverQuery.isError ? ( -
{apiDiscoverQuery.error instanceof Error ? apiDiscoverQuery.error.message : "真实状态查询失败"}
+ {apiInventoryQuery.isError || apiDiscoverQuery.isError ? ( +
+ {apiInventoryQuery.error instanceof Error + ? apiInventoryQuery.error.message + : apiDiscoverQuery.error instanceof Error + ? apiDiscoverQuery.error.message + : "真实状态查询失败"} +
) : null} {apiDiscoverQuery.isSuccess ? (
@@ -489,96 +381,108 @@ export function ReleasesPage() {

服务部署映射

- 服务名、Compose 目标、生产 systemd unit、CLB 绑定和实例分布 + 来自 inventory 和 TAT discover
- {hyappDeploymentPlan.services.length} 个服务 + {rows.length} 个服务
- service.id} /> +
} + getRowKey={(row) => row.service.id} + /> + + + +
+
+

测试机 YAML

+ + {testboxConfigQuery.data?.target?.root ?? "-"},当前提交 {testboxConfigQuery.data?.deployedCommit?.slice(0, 12) ?? "-"} + +
+
+ + {testboxConfigText} + + +
+
+ {testboxConfigQuery.isError ? ( +
+ {testboxConfigQuery.error instanceof Error ? testboxConfigQuery.error.message : "测试机 YAML 读取失败"} +
+ ) : null} +
+ {(testboxConfigQuery.data?.files ?? []).map((file) => ( +
+
+ + {file.service} + {file.path} + {!file.exists ? 缺失 : null} + {file.truncated ? 已截断 : null} +
+
+                {file.exists ? file.content : "文件不存在"}
+              
+
+ ))} +
- -
-
-

生产滚动步骤

- TAT 脚本对单实例固定执行的发布顺序 -
-
-
- {hyappDeploymentPlan.productionSteps.map((step, index) => ( -
- {index + 1} -
- {step.title} - {step.detail} -
-
- ))} -
-
-

托管依赖

- 这些资源只作为连接配置,不进入 TAT 发布动作 + 来自 inventory
- {hyappDeploymentPlan.managedDependencies.map((dependency) => ( -
+ {Object.entries(apiInventoryQuery.data?.managedDependencies ?? {}).map(([id, name]) => ( +
- {dependency.name} - {dependency.provider} - {dependency.boundary} + {name} + {id}
))}
-
- -
- -
-
-

命令入口

- Makefile 与 TAT 脚本入口 -
-
-
- {hyappDeploymentPlan.commands.map((command) => ( -
-
- - {command.title} -
-
-                  {command.command}
-                
-
- ))} -
-
-

发布保护

- 从 hyapp-server 文档同步的禁止项 +

执行保护

+ 真实操作必须显式确认
- {hyappDeploymentPlan.prohibitedActions.map((action) => ( -
- - {action} -
- ))} +
+ + deploy / restart 必须传入 confirmed=true +
+
+ + 联调和验收使用 dryRun=true +
+
+ + 配置读取只走 TAT,密钥字段脱敏 +
diff --git a/src/pages/resources/ResourcesPage.module.css b/src/pages/resources/ResourcesPage.module.css new file mode 100644 index 0000000..36c7d87 --- /dev/null +++ b/src/pages/resources/ResourcesPage.module.css @@ -0,0 +1,290 @@ +.accessPanel { + display: flex; + min-height: 70px; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-4); +} + +.accessPath { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-3); + color: var(--color-text-secondary); + font-size: 13px; + font-weight: 800; +} + +.accessPath span { + display: inline-flex; + min-height: 34px; + align-items: center; + gap: 8px; + padding: 0 12px; + border: 1px solid rgba(255, 255, 255, 0.66); + border-radius: 999px; + background: rgba(255, 255, 255, 0.28); + box-shadow: var(--shadow-liquid-control); +} + +.accessPath i { + width: 28px; + height: 1px; + flex: 0 0 auto; + background: rgba(82, 114, 153, 0.24); +} + +.accessMeta { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--space-3); + color: var(--color-text-tertiary); + font-size: 12px; + font-weight: 700; +} + +.loadingPanel, +.noticePanel, +.panel, +.resourcePanel { + padding: var(--space-4); +} + +.loadingPanel { + display: grid; + gap: var(--space-4); +} + +.loadingHeader { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.loadingHeader > span { + width: 38px; + height: 38px; + flex: 0 0 auto; + border-radius: 15px; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.2)), + rgba(47, 125, 246, 0.14); + background-size: 240% 100%; + animation: skeleton-sheen 1200ms var(--ease-standard) infinite; +} + +.loadingHeader div { + display: grid; + gap: 4px; +} + +.loadingHeader strong { + color: var(--color-text-primary); + font-size: 15px; + line-height: 22px; +} + +.loadingHeader small { + color: var(--color-text-tertiary); + font-size: 12px; + line-height: 18px; +} + +.skeletonGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-3); +} + +.skeletonCard { + display: grid; + min-height: 132px; + align-content: center; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid rgba(255, 255, 255, 0.54); + border-radius: 16px; + background: rgba(255, 255, 255, 0.22); +} + +.skeletonCard span, +.skeletonCard strong, +.skeletonCard small { + display: block; + height: 12px; + border-radius: 999px; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.18)), + rgba(114, 151, 188, 0.12); + background-size: 240% 100%; + animation: skeleton-sheen 1200ms var(--ease-standard) infinite; +} + +.skeletonCard span { + width: 52px; +} + +.skeletonCard strong { + width: 72%; + height: 18px; +} + +.skeletonCard small { + width: 88%; +} + +.metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-4); +} + +.panel { + display: grid; + gap: var(--space-4); +} + +.panelHeader, +.resourceHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.panelHeader h2, +.resourceHeader h2 { + margin: 0; + color: var(--color-text-primary); + font-size: 16px; + font-weight: 800; + line-height: 24px; +} + +.panelHeader span, +.resourceHeader span { + color: var(--color-text-tertiary); + font-size: 12px; + font-weight: 600; +} + +.resourceCell, +.stackText { + display: grid; + min-width: 0; + gap: 3px; +} + +.resourceCell strong, +.stackText strong, +.metricText, +.ownerText { + color: var(--color-text-primary); + font-size: 13px; + font-weight: 800; +} + +.resourceCell small, +.stackText small, +.backupText { + overflow: hidden; + color: var(--color-text-tertiary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.backupText { + display: inline-block; + max-width: 180px; +} + +.detailGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-4); +} + +.resourcePanel { + display: grid; + gap: var(--space-4); + min-height: 260px; +} + +.checkList { + display: grid; + gap: var(--space-2); +} + +.checkItem { + display: grid; + grid-template-columns: 10px minmax(0, 1fr); + align-items: center; + gap: var(--space-3); + min-height: 44px; + padding: 9px 10px; + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 14px; + background: rgba(255, 255, 255, 0.22); +} + +.checkItem > span { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--color-unknown); +} + +.checkItem > span[data-status="success"] { + background: var(--color-success); +} + +.checkItem > span[data-status="failed"] { + background: var(--color-danger); +} + +.checkItem div { + display: grid; + min-width: 0; + gap: 2px; +} + +.checkItem strong { + overflow: hidden; + color: var(--color-text-primary); + font-size: 12px; + font-weight: 800; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.checkItem small { + overflow: hidden; + color: var(--color-text-tertiary); + font-size: 11px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.relatedList { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-self: end; +} + +@keyframes skeleton-sheen { + from { + background-position: 120% 0; + } + + to { + background-position: -120% 0; + } +} diff --git a/src/pages/resources/ResourcesPage.tsx b/src/pages/resources/ResourcesPage.tsx index b062af4..a0b3c03 100644 --- a/src/pages/resources/ResourcesPage.tsx +++ b/src/pages/resources/ResourcesPage.tsx @@ -1,5 +1,292 @@ -import { PlaceholderPage } from "../placeholder"; +import { AlertTriangle, Cable, Database, HardDrive, RefreshCcw, Server, ShieldCheck } from "lucide-react"; +import { useMemo } from "react"; +import { useDataResourcesQuery, type DataResource, type DataResourceStatus, type DataResourceType } from "../../entities/data-resource"; +import { Button } from "../../shared/ui/Button"; +import { Card } from "../../shared/ui/Card"; +import { EmptyState } from "../../shared/ui/EmptyState"; +import { MetricCard } from "../../shared/ui/MetricCard"; +import { ProgressMeter } from "../../shared/ui/ProgressMeter"; +import { StatusBadge, type StatusBadgeStatus } from "../../shared/ui/StatusBadge"; +import { Table, type TableColumn } from "../../shared/ui/Table"; +import { Tag } from "../../shared/ui/Tag"; +import styles from "./ResourcesPage.module.css"; + +const typeLabel: Record = { + kafka: "Kafka", + mongodb: "MongoDB", + mq: "MQ", + mysql: "MySQL", + redis: "Redis", + rocketmq: "RocketMQ", + unknown: "未知", +}; + +const typeTone: Record = { + kafka: "warning", + mongodb: "success", + mq: "brand", + mysql: "brand", + redis: "success", + rocketmq: "brand", + unknown: "neutral", +}; + +const statusTone: Record = { + critical: "critical", + healthy: "healthy", + unknown: "unknown", + warning: "warning", +}; + +function formatNumber(value: number | null | undefined, suffix = "") { + return typeof value === "number" ? `${value}${suffix}` : "未采集"; +} + +function formatTime(value: string | undefined) { + return value ? new Date(value).toLocaleTimeString("zh-CN", { hour12: false }) : "-"; +} + +function metricTone(status: DataResourceStatus) { + if (status === "healthy") { + return "green"; + } + if (status === "critical") { + return "red"; + } + if (status === "warning") { + return "orange"; + } + return "blue"; +} + +function isServerSourceReady(status: string) { + return status === "SUCCESS" || status === "TAT_FALLBACK_SUCCESS"; +} + +function ResourceNameCell({ resource }: { resource: DataResource }) { + return ( + + {resource.name} + {resource.endpoints.length > 0 ? resource.endpoints.join("、") : resource.description || "未采集"} + + ); +} + +function LoadingState() { + return ( + +
+ +
+ 加载中 +
+
+
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ + + +
+ ))} +
+
+ ); +} export function ResourcesPage() { - return ; + const dataResourcesQuery = useDataResourcesQuery(); + const resources = dataResourcesQuery.data?.resources ?? []; + const isInitialLoading = dataResourcesQuery.isLoading || (dataResourcesQuery.isFetching && !dataResourcesQuery.data); + const sourceHosts = dataResourcesQuery.data?.sourceHosts ?? []; + const lastSyncedAt = formatTime(dataResourcesQuery.data?.lastSyncedAt); + const deployTarget = dataResourcesQuery.data?.target ?? dataResourcesQuery.data?.tat?.target; + const sourceOkCount = sourceHosts.filter((host) => isServerSourceReady(host.status)).length; + const checkCount = resources.reduce((total, resource) => total + resource.checks.length, 0); + const healthyCount = resources.filter((resource) => resource.status === "healthy").length; + const abnormalCount = resources.filter((resource) => resource.status === "warning" || resource.status === "critical").length; + const tatStatus = dataResourcesQuery.data?.tat?.status ?? (isInitialLoading ? "CONNECTING" : "UNKNOWN"); + const tatTone = tatStatus === "SUCCESS" ? "success" : dataResourcesQuery.isError ? "danger" : "running"; + + const columns = useMemo[]>( + () => [ + { + key: "resource", + render: (resource) => , + title: "资源", + }, + { + key: "type", + render: (resource) => ( + {typeLabel[resource.type] ?? resource.type} + ), + title: "类型", + }, + { + key: "status", + render: (resource) => , + title: "状态", + }, + { + key: "topology", + render: (resource) => ( + + {resource.topology} + + ), + title: "拓扑", + }, + { + key: "capacity", + render: (resource) => + typeof resource.capacityUsedPercent === "number" ? ( + + ) : ( + 未采集 + ), + title: "容量", + }, + { + key: "connections", + render: (resource) => {formatNumber(resource.connections)}, + title: "连接数", + }, + { + key: "slow", + render: (resource) => {formatNumber(resource.slowQueryCount)}, + title: "慢查询", + }, + { + key: "backup", + render: (resource) => {resource.backupStatus}, + title: "备份", + }, + { + key: "owner", + render: (resource) => {resource.owner}, + title: "负责人", + }, + ], + [], + ); + + const handleRefresh = () => { + void dataResourcesQuery.refetch(); + }; + + return ( +
+
+
+

数据资源

+
+ +
+ + +
+ + + TAT + + + + + {deployTarget ? `${deployTarget.name} / ${deployTarget.privateIp}` : "deploy"} + + + + + 腾讯云 + +
+
+ + {tatStatus === "SUCCESS" ? "TAT 已连接" : dataResourcesQuery.isError ? "TAT 异常" : "TAT 连接中"} + + 最近同步 {lastSyncedAt} +
+
+ + {isInitialLoading ? : null} + + {dataResourcesQuery.isError ? ( + + } + title="数据资源读取失败" + /> + + ) : null} + + {!isInitialLoading && !dataResourcesQuery.isError ? ( + <> +
+ } label="资源健康" meta="正常 / 总数" tone="green" value={`${healthyCount}/${resources.length}`} /> + } label="异常资源" meta="异常" tone={abnormalCount > 0 ? "orange" : "green"} value={abnormalCount} /> + } label="服务器来源" meta="SSH / TAT" value={`${sourceOkCount}/${sourceHosts.length}`} /> + } label="TCP 探测" meta="端点" tone="green" value={checkCount} /> +
+ + +
+
+

数据资源状态

+
+ + {resources.length} 个资源 + +
+
resource.id} /> + + +
+ {resources.map((resource) => ( + +
+
+

{resource.name}

+ {resource.topology} +
+ +
+
+ {resource.checks.length > 0 ? ( + resource.checks.map((check) => ( +
+ +
+ {check.endpoint ?? "未识别端点"} + + {check.detail ?? check.status} + {typeof check.latencyMs === "number" ? ` / ${check.latencyMs}ms` : ""} + +
+
+ )) + ) : ( +
+ +
+ 未采集 +
+
+ )} +
+
+ {resource.relatedServices.slice(0, 8).map((service) => ( + {service} + ))} +
+
+ ))} +
+ + ) : null} + + ); } diff --git a/src/pages/service-detail/ServiceDetailPage.module.css b/src/pages/service-detail/ServiceDetailPage.module.css index 6e11fed..a640f4f 100644 --- a/src/pages/service-detail/ServiceDetailPage.module.css +++ b/src/pages/service-detail/ServiceDetailPage.module.css @@ -695,6 +695,7 @@ .logPanel code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 12px; + white-space: pre-wrap; } .configGrid { @@ -725,6 +726,60 @@ font-size: 15px; } +.configYamlList { + display: grid; + gap: var(--space-3); + margin-top: 16px; +} + +.configYamlItem { + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.54); + border-radius: 16px; + background: rgba(255, 255, 255, 0.22); +} + +.configYamlHeader { + display: grid; + grid-template-columns: auto minmax(120px, max-content) minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); + min-height: 46px; + padding: 0 14px; + color: var(--color-text-secondary); +} + +.configYamlHeader strong { + color: var(--color-text-primary); + font-size: 13px; +} + +.configYamlHeader span { + overflow: hidden; + color: var(--color-text-tertiary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inlineYamlBlock { + max-height: 420px; + margin: 0; + padding: 16px; + overflow: auto; + color: #d9f6ff; + background: + linear-gradient(145deg, rgba(22, 43, 66, 0.94), rgba(19, 32, 49, 0.92)), + rgba(20, 34, 52, 0.94); +} + +.inlineYamlBlock code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + line-height: 1.7; + white-space: pre; +} + .floatingStatus { position: sticky; bottom: 16px; diff --git a/src/pages/service-detail/ServiceDetailPage.tsx b/src/pages/service-detail/ServiceDetailPage.tsx index c581902..7fdd313 100644 --- a/src/pages/service-detail/ServiceDetailPage.tsx +++ b/src/pages/service-detail/ServiceDetailPage.tsx @@ -17,10 +17,6 @@ import { } from "lucide-react"; import { useNavigate, useParams } from "react-router-dom"; import { useAppContextStore } from "../../app/store/useAppContextStore"; -import { - getHyappDeploymentHosts, - getHyappDeploymentServiceByName, -} from "../../entities/deployment-plan"; import { type ServiceEvent, type ServiceInstance, @@ -117,6 +113,10 @@ function formatYamlValue(value: string | number) { } function createInstanceConfigYaml(instance: ServiceInstance) { + if (instance.configYaml) { + return instance.configYaml; + } + const lines = [ "apiVersion: ops.deploy-platform/v1", "kind: ServiceInstanceConfig", @@ -266,10 +266,8 @@ export function ServiceDetailPage() { const [activeTab, setActiveTab] = useState("overview"); const [selectedInstanceId, setSelectedInstanceId] = useState(""); const [configModalInstanceId, setConfigModalInstanceId] = useState(""); - const detailQuery = useServiceDetailQuery(serviceId); + const detailQuery = useServiceDetailQuery(serviceId, environment); const service = detailQuery.data; - const deploymentService = service ? getHyappDeploymentServiceByName(service.name) : undefined; - const deploymentHosts = deploymentService ? getHyappDeploymentHosts(deploymentService) : []; const summaryItems = useMemo(() => { if (!service) { @@ -356,6 +354,19 @@ export function ServiceDetailPage() { ); } + if (detailQuery.isError) { + return ( +
+ + + +
+ ); + } + if (!service) { return (
@@ -373,7 +384,7 @@ export function ServiceDetailPage() {

监控概览

- 最近 1 小时 + 当前采样
{service.metrics.map((metric) => ( @@ -462,14 +473,29 @@ export function ServiceDetailPage() { {service.owner}
- 实例规格 - 2C / 4Gi + Unit + {service.unit ?? "未采集"}
- 发布策略 - 滚动发布 + 端口 + {service.targetPort ?? "未采集"}
+
+ {service.instances.map((instance) => ( +
+
+ + {instance.name} + {instance.configPath ?? "未采集"} + {instance.configYamlTruncated ? 已截断 : null} +
+
+                {instance.configYaml || "未采集"}
+              
+
+ ))} +
), events: ( @@ -591,13 +617,16 @@ export function ServiceDetailPage() {

日志

- 最近发布窗口 + journalctl
- [14:18:02] release {service.version} completed - [14:18:01] readiness probe passed on all pods - [14:17:42] config checksum updated - [14:17:10] rolling update started by backend + {service.instances.map((instance) => ( + + {instance.name} + {"\n"} + {instance.logs || "未采集"} + + ))}
), @@ -617,33 +646,33 @@ export function ServiceDetailPage() {
- 本地命令 - {deploymentService?.local.commands.rebuild ?? "未绑定"} + 服务 + {service.name}
生产 Unit - {deploymentService?.prod?.unit ?? "未进 TAT inventory"} + {service.unit ?? "TAT 未返回"}
- CLB 入口 - {deploymentService?.prod?.clb.entrypoint ?? "local only"} + 容器 + {service.container ?? "TAT 未返回"}
- 配置模板 - {deploymentService?.prod?.configPath ?? deploymentService?.local.configPath ?? "未绑定"} + 配置 + {service.configPath ?? "TAT 未返回"}
实例分布
- {deploymentHosts.length > 0 ? ( - deploymentHosts.map((host) => ( - - {host.id} / {host.privateIp} + {service.instances.length > 0 ? ( + service.instances.map((instance) => ( + + {instance.node} / {instance.podIp} )) ) : ( - local-compose + TAT 未返回 )}
@@ -716,7 +745,7 @@ export function ServiceDetailPage() { 依赖与实例状态已同步 - 刚刚 + TAT {service.dependencies.length} 个依赖 diff --git a/src/pages/services/ServicesPage.module.css b/src/pages/services/ServicesPage.module.css index ac1415a..7c5c520 100644 --- a/src/pages/services/ServicesPage.module.css +++ b/src/pages/services/ServicesPage.module.css @@ -19,3 +19,120 @@ font-weight: 800; white-space: nowrap; } + +.modalOverlay { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 28px; + background: rgba(29, 43, 58, 0.22); + backdrop-filter: blur(18px) saturate(150%); + -webkit-backdrop-filter: blur(18px) saturate(150%); +} + +.yamlModal { + display: flex; + width: min(980px, calc(100vw - 56px)); + max-height: min(780px, calc(100dvh - 56px)); + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.78); + border-radius: 24px; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.66), rgba(230, 242, 255, 0.4)), + rgba(255, 255, 255, 0.5); + box-shadow: var(--shadow-liquid-edge), var(--shadow-glass-lg); + backdrop-filter: blur(38px) saturate(180%) brightness(1.06); + -webkit-backdrop-filter: blur(38px) saturate(180%) brightness(1.06); +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: 18px 18px 14px; + border-bottom: 1px solid rgba(114, 151, 188, 0.14); +} + +.modalHeader h2 { + margin: 0 0 4px; + color: var(--color-text-primary); + font-size: 18px; + font-weight: 800; +} + +.modalHeader span { + color: var(--color-text-tertiary); + font-size: 12px; +} + +.modalState { + padding: 28px; + color: var(--color-text-secondary); + font-size: 13px; + font-weight: 800; +} + +.yamlList { + display: grid; + gap: var(--space-3); + padding: 18px; + overflow: auto; +} + +.yamlItem { + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.54); + border-radius: 16px; + background: rgba(255, 255, 255, 0.22); +} + +.yamlItemHeader { + display: grid; + grid-template-columns: auto minmax(120px, max-content) minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); + min-height: 46px; + padding: 0 14px; + color: var(--color-text-secondary); +} + +.yamlItemHeader strong { + color: var(--color-text-primary); + font-size: 13px; +} + +.yamlItemHeader small { + overflow: hidden; + color: var(--color-text-tertiary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.yamlItemHeader span { + color: var(--color-warning); + font-size: 12px; + font-weight: 800; +} + +.yamlItem pre { + max-height: 420px; + margin: 0; + padding: 16px; + overflow: auto; + color: #d9f6ff; + background: + linear-gradient(145deg, rgba(22, 43, 66, 0.94), rgba(19, 32, 49, 0.92)), + rgba(20, 34, 52, 0.94); +} + +.yamlItem code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + line-height: 1.7; + white-space: pre; +} diff --git a/src/pages/services/ServicesPage.tsx b/src/pages/services/ServicesPage.tsx index db1b60b..ea88e51 100644 --- a/src/pages/services/ServicesPage.tsx +++ b/src/pages/services/ServicesPage.tsx @@ -1,17 +1,81 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { FileText, X } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useAppContextStore } from "../../app/store/useAppContextStore"; -import { useServicesQuery } from "../../entities/service"; -import type { ServiceListParams } from "../../entities/service"; +import { useServiceDetailQuery, useServicesQuery } from "../../entities/service"; +import type { ServiceDetail, ServiceListParams } from "../../entities/service"; import { useReleaseDrawerStore } from "../../features/release-service"; +import { Button } from "../../shared/ui/Button"; import { Card } from "../../shared/ui/Card"; +import { EmptyState } from "../../shared/ui/EmptyState"; +import { IconButton } from "../../shared/ui/IconButton"; import { ServiceTable } from "../../widgets/service-table"; import styles from "./ServicesPage.module.css"; +type ConfigYamlModalProps = { + error: unknown; + isLoading: boolean; + onClose: () => void; + service: ServiceDetail | null | undefined; +}; + +function ConfigYamlModal({ error, isLoading, onClose, service }: ConfigYamlModalProps) { + const instances = service?.instances ?? []; + + return ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + role="presentation" + > +
+
+
+

配置 YAML

+ {service?.name ?? "加载中"} +
+ + + +
+ + {isLoading ? ( +
加载中
+ ) : error ? ( +
{error instanceof Error ? error.message : "配置读取失败"}
+ ) : instances.length === 0 ? ( +
未采集到实例配置
+ ) : ( +
+ {instances.map((instance) => ( +
+
+ + {instance.name} + {instance.configPath ?? "未采集"} + {instance.configYamlTruncated ? 已截断 : null} +
+
+                  {instance.configYaml || "未采集"}
+                
+
+ ))} +
+ )} +
+
+ ); +} + export function ServicesPage() { const navigate = useNavigate(); const { clusterId, environment, projectId } = useAppContextStore(); const openRelease = useReleaseDrawerStore((state) => state.openRelease); + const [configServiceId, setConfigServiceId] = useState(""); const serviceListParams = useMemo(() => { return { clusterId, @@ -21,13 +85,29 @@ export function ServicesPage() { }, [clusterId, environment, projectId]); const servicesQuery = useServicesQuery(serviceListParams); const services = servicesQuery.data?.items ?? []; + const configQuery = useServiceDetailQuery(configServiceId, environment); + + useEffect(() => { + if (!configServiceId) { + return undefined; + } + + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setConfigServiceId(""); + } + }; + + window.addEventListener("keydown", handleKeydown); + + return () => window.removeEventListener("keydown", handleKeydown); + }, [configServiceId]); return (

微服务

-

集中检索和管理当前项目环境下的服务

@@ -36,14 +116,33 @@ export function ServicesPage() { 共 {servicesQuery.data?.total ?? 0} 个服务 +
+ {servicesQuery.isError ? ( + + ) : null} setConfigServiceId(service.id)} onRelease={openRelease} onViewDetails={(service) => navigate(`/services/${service.id}`)} services={services} /> + + {configServiceId ? ( + setConfigServiceId("")} + service={configQuery.data} + /> + ) : null} ); } diff --git a/src/pages/test-services/TestServicesPage.module.css b/src/pages/test-services/TestServicesPage.module.css new file mode 100644 index 0000000..22159fb --- /dev/null +++ b/src/pages/test-services/TestServicesPage.module.css @@ -0,0 +1,302 @@ +.heroGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-4); +} + +.heroCard { + display: grid; + min-height: 112px; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: var(--space-3); + padding: var(--space-4); +} + +.heroIcon { + display: grid; + width: 44px; + height: 44px; + place-items: center; + color: var(--color-brand); + border: 1px solid rgba(255, 255, 255, 0.68); + border-radius: 14px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.64), rgba(224, 240, 255, 0.38)), + rgba(47, 125, 246, 0.1); + box-shadow: var(--shadow-liquid-control); +} + +.heroCard div, +.serviceCell, +.stackText { + display: grid; + min-width: 0; + gap: 4px; +} + +.heroCard span, +.heroCard small, +.panelHeader span, +.serviceCell small, +.stackText small, +.databaseLine, +.notice { + color: var(--color-text-tertiary); + font-size: 12px; + font-weight: 700; + line-height: 18px; +} + +.heroCard strong, +.serviceCell strong, +.stackText strong { + overflow: hidden; + color: var(--color-text-primary); + font-size: 13px; + font-weight: 900; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.heroCard strong { + font-size: 16px; + line-height: 24px; +} + +.heroCard small, +.serviceCell small, +.stackText small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.panel { + display: grid; + gap: var(--space-4); + padding: var(--space-4); +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.panelHeader > div:first-child { + display: grid; + min-width: 0; + gap: 4px; +} + +.panelHeader h2 { + margin: 0; + color: var(--color-text-primary); + font-size: 16px; + font-weight: 900; + line-height: 24px; +} + +.panelActions, +.actionGroup, +.databaseLine { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.panelActions { + flex: 0 0 auto; +} + +.actionGroup { + justify-content: flex-end; +} + +.serviceActions { + width: 312px; +} + +.migrationActions { + width: 104px; +} + +.mono { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; +} + +.databaseLine { + min-height: 42px; + padding: 0 12px; + border: 1px solid rgba(255, 255, 255, 0.54); + border-radius: 14px; + background: rgba(255, 255, 255, 0.22); +} + +.databaseLine svg { + color: var(--color-brand); +} + +.notice { + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, 0.56); + border-radius: 14px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.58), rgba(255, 246, 225, 0.28)), + var(--color-warning-bg); +} + +.output { + max-height: 320px; + margin: 0; + overflow: auto; + padding: 14px; + color: #d9f6ff; + border: 1px solid rgba(255, 255, 255, 0.34); + border-radius: 14px; + background: + linear-gradient(145deg, rgba(22, 43, 66, 0.94), rgba(19, 32, 49, 0.92)), + rgba(20, 34, 52, 0.94); +} + +.output code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + line-height: 1.65; + white-space: pre-wrap; +} + +.modalOverlay { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 28px; + background: rgba(29, 43, 58, 0.22); + backdrop-filter: blur(18px) saturate(150%); + -webkit-backdrop-filter: blur(18px) saturate(150%); +} + +.configModal { + display: flex; + width: min(1120px, calc(100vw - 56px)); + max-height: min(820px, calc(100dvh - 56px)); + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.78); + border-radius: 24px; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.66), rgba(230, 242, 255, 0.4)), + rgba(255, 255, 255, 0.5); + box-shadow: var(--shadow-liquid-edge), var(--shadow-glass-lg); + backdrop-filter: blur(38px) saturate(180%) brightness(1.06); + -webkit-backdrop-filter: blur(38px) saturate(180%) brightness(1.06); +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: 18px 18px 14px; + border-bottom: 1px solid rgba(114, 151, 188, 0.14); +} + +.modalHeader > div { + display: grid; + min-width: 0; + gap: 4px; +} + +.modalHeader h2 { + margin: 0; + color: var(--color-text-primary); + font-size: 18px; + font-weight: 900; +} + +.modalHeader span { + overflow: hidden; + color: var(--color-text-tertiary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.configEditorBody { + display: grid; + min-height: 0; + grid-template-columns: minmax(0, 1fr) 260px; + gap: var(--space-4); + padding: 18px; + overflow: hidden; +} + +.configTextarea { + min-height: 520px; + resize: vertical; + padding: 14px; + color: #d9f6ff; + border: 1px solid rgba(255, 255, 255, 0.34); + border-radius: 14px; + outline: none; + background: + linear-gradient(145deg, rgba(22, 43, 66, 0.94), rgba(19, 32, 49, 0.92)), + rgba(20, 34, 52, 0.94); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + line-height: 1.65; +} + +.editorAside { + display: grid; + align-content: start; + gap: var(--space-3); + padding: 14px; + color: var(--color-text-secondary); + border: 1px solid rgba(255, 255, 255, 0.54); + border-radius: 16px; + background: rgba(255, 255, 255, 0.24); + font-size: 12px; + font-weight: 700; + line-height: 20px; +} + +.editorAside strong { + color: var(--color-text-primary); + font-size: 13px; + font-weight: 900; +} + +.modalActions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding: 0 18px 18px; +} + +@media (max-width: 1320px) { + .heroGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + .configEditorBody { + grid-template-columns: 1fr; + } + + .panelHeader { + align-items: stretch; + flex-direction: column; + } + + .panelActions { + justify-content: flex-start; + } +} diff --git a/src/pages/test-services/TestServicesPage.tsx b/src/pages/test-services/TestServicesPage.tsx new file mode 100644 index 0000000..03a1b57 --- /dev/null +++ b/src/pages/test-services/TestServicesPage.tsx @@ -0,0 +1,557 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Database, + GitBranch, + Play, + Power, + RefreshCw, + RotateCw, + ServerCog, + Square, + Timer, + X, +} from "lucide-react"; +import { + applyDeployApiTestboxMigrations, + runDeployApiTestboxServiceAction, + runDeployApiTestboxTimerAction, + saveDeployApiTestboxConfig, + type DeployApiTestboxCommand, + type DeployApiTestboxConfigResponse, + type DeployApiTestboxMigration, + type DeployApiTestboxStatusResponse, + useDeployApiTestboxConfigsQuery, + useDeployApiTestboxMigrationsQuery, + useDeployApiTestboxStatusQuery, +} from "../../entities/deployment-plan"; +import { queryKeys } from "../../shared/api/queryKeys"; +import { Button } from "../../shared/ui/Button"; +import { Card } from "../../shared/ui/Card"; +import { EmptyState } from "../../shared/ui/EmptyState"; +import { IconButton } from "../../shared/ui/IconButton"; +import { StatusBadge } from "../../shared/ui/StatusBadge"; +import { Table, type TableColumn } from "../../shared/ui/Table"; +import { Tag } from "../../shared/ui/Tag"; +import styles from "./TestServicesPage.module.css"; + +const TESTBOX_SERVICES = [ + "gateway-service", + "room-service", + "wallet-service", + "user-service", + "activity-service", + "cron-service", + "game-service", + "notice-service", + "admin-server", +] as const; + +type TestboxService = DeployApiTestboxStatusResponse["services"][number]; +type ConfigFile = DeployApiTestboxConfigResponse["files"][number]; +type ServiceOperation = "restart" | "start" | "stop" | "update"; + +function shortCommit(value: string | undefined) { + return value ? value.slice(0, 12) : "-"; +} + +function timerTone(value: string | undefined) { + if (value === "active") { + return "success" as const; + } + if (!value || value === "inactive") { + return "warning" as const; + } + return "neutral" as const; +} + +function migrationTone(status: DeployApiTestboxMigration["status"]) { + if (status === "applied") { + return "success" as const; + } + if (status === "pending") { + return "warning" as const; + } + return "danger" as const; +} + +function migrationLabel(status: DeployApiTestboxMigration["status"]) { + if (status === "applied") { + return "已执行"; + } + if (status === "pending") { + return "待执行"; + } + if (status === "dirty") { + return "dirty"; + } + return "checksum 变更"; +} + +function commandText(commands: DeployApiTestboxCommand[] | undefined) { + if (!commands?.length) { + return ""; + } + + return commands + .map((command, index) => { + const args = command.args?.join(" ") ?? ""; + + return [`#${index + 1} exit=${command.code}`, args, command.output].filter(Boolean).join("\n"); + }) + .join("\n\n"); +} + +type ConfigEditorProps = { + file: ConfigFile; + isSaving: boolean; + onClose: () => void; + onSave: (content: string, restart: boolean) => void; +}; + +function ConfigEditor({ file, isSaving, onClose, onSave }: ConfigEditorProps) { + const [content, setContent] = useState(file.content); + const containsRedactedSecret = content.includes("***"); + + useEffect(() => { + setContent(file.content); + }, [file.content, file.path]); + + return ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + role="presentation" + > +
+
+
+

编辑 config.yaml

+ + {file.service} · {file.path} + +
+ + + +
+
+