部署
This commit is contained in:
parent
29dcce8ca0
commit
fb1a75a2a4
26
AGENTS.md
Normal file
26
AGENTS.md
Normal file
@ -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`,密钥字段保持脱敏。
|
||||
19
README.md
19
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 入口:列表右侧三点、详情配置区
|
||||
- 禁止:静态假数据兜底展示运行态
|
||||
|
||||
@ -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`。
|
||||
|
||||
详细命令见:
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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/<service>/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 <name> --content-base64 <yaml>`:写配置前备份旧文件,可选 `--restart`。
|
||||
- `migrations` / `migrations-apply`:只列出或执行 `source/server/admin/migrations/*.sql`,不接收页面手写 SQL。
|
||||
|
||||
@ -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",
|
||||
|
||||
918
deploy/tencent-tat/hyapp_data_resources.py
Normal file
918
deploy/tencent-tat/hyapp_data_resources.py
Normal file
@ -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())
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: <OverviewPage /> },
|
||||
{ path: "services", element: <ServicesPage /> },
|
||||
{ path: "services/:serviceId", element: <ServiceDetailPage /> },
|
||||
{ path: "test-services", element: <TestServicesPage /> },
|
||||
{ path: "releases", element: <ReleasesPage /> },
|
||||
{ path: "resources", element: <ResourcesPage /> },
|
||||
{ path: "logs-events", element: <LogsEventsPage /> },
|
||||
{ path: "audit", element: <AuditPage /> },
|
||||
{ path: "settings", element: <PlaceholderPage title="配置中心" /> },
|
||||
{ path: "alerts", element: <PlaceholderPage title="监控告警" /> },
|
||||
{ path: "*", element: <Navigate to="/overview" replace /> },
|
||||
],
|
||||
},
|
||||
|
||||
30
src/entities/data-resource/api/getDataResources.ts
Normal file
30
src/entities/data-resource/api/getDataResources.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { DataResourcesResponse } from "../model/types";
|
||||
|
||||
async function readJsonResponse<T>(response: Response): Promise<T> {
|
||||
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<DataResourcesResponse> {
|
||||
const response = await fetch("/deploy/api/hyapp/resources?remote=1", {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return readJsonResponse<DataResourcesResponse>(response);
|
||||
}
|
||||
14
src/entities/data-resource/api/useDataResourcesQuery.ts
Normal file
14
src/entities/data-resource/api/useDataResourcesQuery.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
11
src/entities/data-resource/index.ts
Normal file
11
src/entities/data-resource/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export { useDataResourcesQuery } from "./api/useDataResourcesQuery";
|
||||
export type {
|
||||
DataResource,
|
||||
DataResourceCheck,
|
||||
DataResourceCheckStatus,
|
||||
DataResourceSourceHost,
|
||||
DataResourceStatus,
|
||||
DataResourceSummary,
|
||||
DataResourceType,
|
||||
DataResourcesResponse,
|
||||
} from "./model/types";
|
||||
74
src/entities/data-resource/model/types.ts
Normal file
74
src/entities/data-resource/model/types.ts
Normal file
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -1,16 +1,45 @@
|
||||
export type DeployApiInventoryResponse = {
|
||||
hosts: Record<string, unknown>;
|
||||
hosts: Record<
|
||||
string,
|
||||
{
|
||||
instance_id?: string;
|
||||
private_ip?: string;
|
||||
}
|
||||
>;
|
||||
managedDependencies: Record<string, string>;
|
||||
ok: boolean;
|
||||
region: string;
|
||||
registry: string;
|
||||
services: Record<string, unknown>;
|
||||
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<T>(response: Response): Promise<T> {
|
||||
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<T>(response: Response): Promise<T> {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function postJson<T>(path: string, body: Record<string, unknown>): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
return readJsonResponse<T>(response);
|
||||
}
|
||||
|
||||
export async function getDeployApiInventory(): Promise<DeployApiInventoryResponse> {
|
||||
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<DeployApiTestboxConfigResponse> {
|
||||
export async function getDeployApiTestboxConfigs(services: string[], raw = false): Promise<DeployApiTestboxConfigResponse> {
|
||||
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<De
|
||||
|
||||
return readJsonResponse<DeployApiTestboxConfigResponse>(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<DeployApiTestboxStatusResponse> {
|
||||
const params = new URLSearchParams({
|
||||
services: services.join(","),
|
||||
});
|
||||
const response = await fetch(`/deploy/api/hyapp/testbox/status?${params.toString()}`, {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return readJsonResponse<DeployApiTestboxStatusResponse>(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<DeployApiTestboxServiceActionResponse> {
|
||||
return postJson<DeployApiTestboxServiceActionResponse>("/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<DeployApiTestboxTimerActionResponse> {
|
||||
return postJson<DeployApiTestboxTimerActionResponse>("/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<DeployApiTestboxConfigWriteResponse> {
|
||||
return postJson<DeployApiTestboxConfigWriteResponse>("/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<DeployApiTestboxMigrationsResponse> {
|
||||
return fetch("/deploy/api/hyapp/testbox/migrations", {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
}).then((response) => readJsonResponse<DeployApiTestboxMigrationsResponse>(response));
|
||||
}
|
||||
|
||||
export function applyDeployApiTestboxMigrations(files: string[]): Promise<DeployApiTestboxMigrationsResponse> {
|
||||
return postJson<DeployApiTestboxMigrationsResponse>("/deploy/api/hyapp/testbox/migrations/apply", {
|
||||
confirmed: true,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<typeof host> => Boolean(host));
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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<string, DeploymentHost>;
|
||||
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;
|
||||
};
|
||||
};
|
||||
206
src/entities/service/api/deployApi.ts
Normal file
206
src/entities/service/api/deployApi.ts
Normal file
@ -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<string, DeployInventoryHost>;
|
||||
managedDependencies?: Record<string, unknown>;
|
||||
ok?: boolean;
|
||||
region?: string;
|
||||
registry?: string;
|
||||
services?: Record<string, DeployInventoryService>;
|
||||
};
|
||||
|
||||
export type DeployRuntimeService = {
|
||||
configExists?: boolean;
|
||||
configPath?: string;
|
||||
configTruncated?: boolean;
|
||||
configYaml?: string;
|
||||
container?: string;
|
||||
containerId?: string;
|
||||
containerStatus?: string;
|
||||
cpuPercent?: number | null;
|
||||
createdAt?: string;
|
||||
env?: Record<string, string>;
|
||||
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<Omit<DeployRuntimeService, "host" | "instanceId" | "privateIp">>;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeployDiscoverResponse = {
|
||||
clb?: unknown[];
|
||||
hosts?: DeployDiscoverHost[];
|
||||
ok?: boolean;
|
||||
};
|
||||
|
||||
async function readJsonResponse<T>(response: Response): Promise<T> {
|
||||
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<DeployInventoryResponse> {
|
||||
const response = await fetch("/deploy/api/hyapp/inventory", {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await readJsonResponse<DeployInventoryResponse>(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<DeployDiscoverResponse> {
|
||||
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<DeployDiscoverResponse>(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<number | null | undefined>) {
|
||||
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<string | undefined>) {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
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] ?? "";
|
||||
}
|
||||
@ -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<string, ServiceDependency["type"]> = {
|
||||
mq: "message-queue",
|
||||
@ -48,320 +28,236 @@ const dependencyTypeMap: Record<string, ServiceDependency["type"]> = {
|
||||
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<ServiceInstanceConfigItem>(([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<ServiceDetail | null> {
|
||||
await new Promise((resolve) =>
|
||||
globalThis.setTimeout(resolve, MOCK_LATENCY_MS),
|
||||
);
|
||||
export async function getServiceDetail(serviceId: string, environment = "prod"): Promise<ServiceDetail | null> {
|
||||
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)) || "未采集",
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<Awaited<ReturnType<typeof fetchDeployInventory>>["services"]>[string],
|
||||
runtimes: ReturnType<typeof getRuntimeServices>,
|
||||
): 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<string, DeployInventoryService>;
|
||||
};
|
||||
|
||||
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<ServiceListResponse> {
|
||||
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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
@ -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;
|
||||
|
||||
@ -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<CreateReleaseResult> {
|
||||
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<Crea
|
||||
throw new Error(result.error || `部署接口返回 HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
service.version = request.targetVersion;
|
||||
service.status = "healthy";
|
||||
service.readyInstances = service.desiredInstances;
|
||||
service.lastReleaseText = "刚刚";
|
||||
|
||||
const releaseResult: CreateReleaseResult = {
|
||||
id: `release-${Date.now()}`,
|
||||
completedAt: new Date().toISOString(),
|
||||
fromVersion,
|
||||
serviceId: service.id,
|
||||
fromVersion: request.fromVersion,
|
||||
id: `release-${Date.now()}`,
|
||||
serviceId: request.serviceId,
|
||||
status: "success",
|
||||
targetVersion: request.targetVersion,
|
||||
};
|
||||
|
||||
@ -1,45 +1,34 @@
|
||||
import { Service } from "../../../entities/service";
|
||||
import { getHyappDeploymentServiceById, hyappDeploymentPlan } from "../../../entities/deployment-plan";
|
||||
import { ReleasePrecheck } from "../model/types";
|
||||
import type { Service } from "../../../entities/service";
|
||||
import type { ReleasePrecheck } from "../model/types";
|
||||
|
||||
export function getReleasePrechecks(service: Service, environment: string): ReleasePrecheck[] {
|
||||
const deploymentService = getHyappDeploymentServiceById(service.id);
|
||||
const isProd = environment === "prod";
|
||||
const hasProdTarget = Boolean(deploymentService?.prod);
|
||||
const hasRuntimeTarget = Boolean(service.unit && service.container && service.targetPort);
|
||||
|
||||
return [
|
||||
{
|
||||
detail: `${service.readyInstances}/${service.desiredInstances} 可用`,
|
||||
id: "instances",
|
||||
label: "实例状态",
|
||||
result: service.readyInstances === service.desiredInstances ? "passed" : "warning",
|
||||
detail: `${service.readyInstances}/${service.desiredInstances} 可用`,
|
||||
},
|
||||
{
|
||||
detail: hasRuntimeTarget ? `${service.unit} / ${service.container}` : "TAT 未返回部署目标",
|
||||
id: "deploy-target",
|
||||
label: "部署目标",
|
||||
result: !isProd || hasProdTarget ? "passed" : "warning",
|
||||
detail:
|
||||
isProd && !hasProdTarget
|
||||
? "生产 inventory 未绑定该服务"
|
||||
: `systemd unit ${deploymentService?.prod?.unit ?? deploymentService?.local.composeService ?? service.name}`,
|
||||
},
|
||||
{
|
||||
id: "dependencies",
|
||||
label: "托管依赖",
|
||||
result: "passed",
|
||||
detail: hyappDeploymentPlan.managedDependencies.map((item) => 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",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 || "<tag>"}`,
|
||||
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
|
||||
<span className={styles.step}>1</span>
|
||||
<div className={styles.sectionContent}>
|
||||
<h3>选择目标版本</h3>
|
||||
<Select aria-label="选择目标版本" onValueChange={setTargetVersion} options={versionOptions} value={targetVersion} />
|
||||
<input
|
||||
aria-label="目标镜像 tag"
|
||||
className={styles.tagInput}
|
||||
onChange={(event) => setTargetVersion(event.target.value.trim())}
|
||||
placeholder="输入真实镜像 tag"
|
||||
value={targetVersion}
|
||||
/>
|
||||
<p>
|
||||
当前版本 <Tag tone="brand">{service.version}</Tag>
|
||||
</p>
|
||||
@ -166,9 +158,7 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user
|
||||
<span>
|
||||
<strong>实例</strong>
|
||||
<small>
|
||||
{deployHosts.length > 0
|
||||
? deployHosts.map((host) => host.id).join("、")
|
||||
: "local-compose"}
|
||||
{service.sourceHosts?.length ? service.sourceHosts.join("、") : "TAT 未返回"}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
import { PlaceholderPage } from "../placeholder";
|
||||
|
||||
export function AuditPage() {
|
||||
return <PlaceholderPage title="权限与审计" />;
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { AuditPage } from "./AuditPage";
|
||||
@ -1,5 +0,0 @@
|
||||
import { PlaceholderPage } from "../placeholder";
|
||||
|
||||
export function LogsEventsPage() {
|
||||
return <PlaceholderPage title="日志与事件" />;
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { LogsEventsPage } from "./LogsEventsPage";
|
||||
@ -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 (
|
||||
<div className="page">
|
||||
@ -35,8 +41,14 @@ export function OverviewPage() {
|
||||
<section className={styles.metrics}>
|
||||
<MetricCard
|
||||
icon={<Layers3 size={24} />}
|
||||
label="运行中服务数"
|
||||
meta="Healthy"
|
||||
label="服务总数"
|
||||
meta="Inventory"
|
||||
value={summary.totalCount}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<Server size={24} />}
|
||||
label="健康服务数"
|
||||
meta="TAT"
|
||||
tone="green"
|
||||
value={summary.healthyCount}
|
||||
/>
|
||||
@ -47,25 +59,12 @@ export function OverviewPage() {
|
||||
tone="orange"
|
||||
value={summary.abnormalCount}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<Rocket size={24} />}
|
||||
label="最近发布"
|
||||
meta={summary.latestRelease.serviceName}
|
||||
value={summary.latestRelease.version}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<Bell size={24} />}
|
||||
label="活跃告警"
|
||||
meta="未恢复"
|
||||
tone="red"
|
||||
value={summary.activeAlerts}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<Database size={24} />}
|
||||
label="数据库状态"
|
||||
meta="正常资源"
|
||||
label="数据资源"
|
||||
meta="健康 / 总数"
|
||||
tone="green"
|
||||
value={summary.databaseHealthyText}
|
||||
value={dataResourceHealthyText}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
<p className="page-subtitle">第一阶段保留入口,后续迭代接入完整能力</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<EmptyState description="该模块已预留路由和统一空状态,等待业务组件接入。" icon={<Wrench size={22} />} title="建设中" />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { PlaceholderPage } from "./PlaceholderPage";
|
||||
@ -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<NonNullable<DeployApiDiscoverResponse["hosts"]>[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<number | null | undefined>) {
|
||||
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<string | undefined>) {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
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<HyappDeploymentService>[] = [
|
||||
const serviceColumns: TableColumn<ServiceRow>[] = [
|
||||
{
|
||||
key: "service",
|
||||
title: "服务",
|
||||
render: (service) => (
|
||||
render: (row) => (
|
||||
<span className={styles.serviceCell}>
|
||||
<strong>{service.name}</strong>
|
||||
<small>{service.domain}</small>
|
||||
<strong>{row.service.name}</strong>
|
||||
<small>{row.configPath || "TAT 未返回配置路径"}</small>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
title: "状态",
|
||||
render: (service) => <StatusBadge status={service.status} />,
|
||||
render: (row) => <StatusBadge status={row.service.status} />,
|
||||
},
|
||||
{
|
||||
key: "local",
|
||||
title: "本地 Compose",
|
||||
render: (service) => (
|
||||
<span className={styles.inlineTags}>
|
||||
<Tag tone="brand">{service.local.composeService}</Tag>
|
||||
<Tag>{service.local.aliases[0]}</Tag>
|
||||
key: "version",
|
||||
title: "镜像",
|
||||
render: (row) => (
|
||||
<span className={styles.stackText}>
|
||||
<strong>{row.service.version}</strong>
|
||||
<small>{row.image || "TAT 未返回镜像"}</small>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "prod",
|
||||
title: "生产目标",
|
||||
render: (service) =>
|
||||
service.prod ? (
|
||||
<span className={styles.hostList}>
|
||||
{getHyappDeploymentHosts(service).map((host) => (
|
||||
<Tag key={host.id} tone="success">
|
||||
{host.id}
|
||||
</Tag>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<Tag tone="warning">未进 TAT inventory</Tag>
|
||||
),
|
||||
key: "instances",
|
||||
title: "实例",
|
||||
render: (row) => `${row.service.readyInstances}/${row.service.desiredInstances}`,
|
||||
},
|
||||
{
|
||||
key: "unit",
|
||||
key: "target",
|
||||
title: "Unit / 端口",
|
||||
render: (service) => (
|
||||
render: (row) => (
|
||||
<span className={styles.stackText}>
|
||||
<strong>{service.prod?.unit ?? service.local.composeService}</strong>
|
||||
<small>{service.prod?.targetPort ?? service.local.grpcPort ?? service.local.httpPort}</small>
|
||||
<strong>{row.unit || "TAT 未返回"}</strong>
|
||||
<small>{row.targetPort ?? "TAT 未返回"}</small>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "hosts",
|
||||
title: "机器",
|
||||
render: (row) => (
|
||||
<span className={styles.hostList}>
|
||||
{row.hosts.length > 0 ? row.hosts.map((host) => <Tag key={host}>{host}</Tag>) : <Tag tone="warning">TAT 未返回</Tag>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "clb",
|
||||
title: "CLB",
|
||||
render: (service) =>
|
||||
service.prod ? (
|
||||
render: (row) =>
|
||||
row.clb?.enabled ? (
|
||||
<span className={styles.stackText}>
|
||||
<strong>{service.prod.clb.loadBalancerId}</strong>
|
||||
<small>{service.prod.clb.entrypoint}</small>
|
||||
<strong>{row.clb.load_balancer_id ?? "TAT 未返回"}</strong>
|
||||
<small>{row.clb.listener_ids?.join("、") || "TAT 未返回 listener"}</small>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted">local only</span>
|
||||
<span className="text-muted">未启用</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
className: styles.actions,
|
||||
key: "action",
|
||||
title: "操作",
|
||||
className: styles.actions,
|
||||
render: (service) => {
|
||||
const serviceEntity = mockServices.find((item) => item.id === service.id);
|
||||
|
||||
return (
|
||||
<Button disabled={!serviceEntity} onClick={() => serviceEntity && openRelease(serviceEntity)} size="sm" variant="secondary">
|
||||
部署
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
render: (row) => (
|
||||
<Button disabled={row.service.status === "unknown"} onClick={() => openRelease(row.service)} size="sm" variant="secondary">
|
||||
部署
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@ -172,30 +259,18 @@ export function ReleasesPage() {
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">HyApp 部署方案</h1>
|
||||
<p className="page-subtitle">
|
||||
接入 {hyappDeploymentPlan.repositoryPath} 的 Compose、systemd 和 Tencent TAT 发布边界
|
||||
</p>
|
||||
<h1 className="page-title">发布</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className={styles.heroGrid}>
|
||||
<Card className={styles.heroCard} padded={false}>
|
||||
<span className={styles.heroIcon}>
|
||||
<GitBranch size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<span>代码仓库</span>
|
||||
<strong>{hyappDeploymentPlan.repositoryPath}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.heroCard} padded={false}>
|
||||
<span className={styles.heroIcon}>
|
||||
<CloudCog size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<span>生产区域</span>
|
||||
<strong>{hyappDeploymentPlan.region}</strong>
|
||||
<span>区域</span>
|
||||
<strong>{apiInventoryQuery.data?.region ?? "-"}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.heroCard} padded={false}>
|
||||
@ -204,7 +279,7 @@ export function ReleasesPage() {
|
||||
</span>
|
||||
<div>
|
||||
<span>镜像仓库</span>
|
||||
<strong>{hyappDeploymentPlan.registry}</strong>
|
||||
<strong>{apiInventoryQuery.data?.registry ?? "-"}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.heroCard} padded={false}>
|
||||
@ -212,245 +287,62 @@ export function ReleasesPage() {
|
||||
<ServerCog size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<span>发布控制面</span>
|
||||
<strong>
|
||||
{hyappDeploymentPlan.deployServer.id} / {hyappDeploymentPlan.deployServer.privateIp}
|
||||
</strong>
|
||||
<span>服务数</span>
|
||||
<strong>{serviceNames.length}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.heroCard} padded={false}>
|
||||
<span className={styles.heroIcon}>
|
||||
<PlayCircle size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<span>计划步骤</span>
|
||||
<strong>{apiPlanQuery.data?.steps.length ?? "-"}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className={styles.modeGrid}>
|
||||
{hyappDeploymentPlan.modes.map((mode) => (
|
||||
<Card className={styles.modeCard} key={mode.id} padded={false}>
|
||||
<div className={styles.modeHeader}>
|
||||
{mode.id === "local-compose" ? <TerminalSquare size={20} /> : <Network size={20} />}
|
||||
<h2>{mode.title}</h2>
|
||||
</div>
|
||||
<p>{mode.description}</p>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Card className={styles.adminProdPanel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>正式后台部署</h2>
|
||||
<span>
|
||||
前端 {adminProd.frontend.privateIp}:{adminProd.frontend.port} 反代内网后端 {adminProd.backend.listenAddr}
|
||||
</span>
|
||||
</div>
|
||||
<Tag tone="success" size="md">
|
||||
{adminProd.backend.nodeId}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.adminProdGrid}>
|
||||
<div>
|
||||
<span>后台前端机器</span>
|
||||
<strong>
|
||||
{adminProd.frontend.publicIp}:{adminProd.frontend.port}
|
||||
</strong>
|
||||
<small>
|
||||
{adminProd.frontend.instanceId} / {adminProd.frontend.privateIp}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>前端部署</span>
|
||||
<strong>{adminProd.frontend.timer}</strong>
|
||||
<small>
|
||||
{adminProd.frontend.branch} 分支,{adminProd.frontend.deployScript}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>admin-server 机器</span>
|
||||
<strong>{adminProd.backend.listenAddr}</strong>
|
||||
<small>
|
||||
{adminProd.backend.instanceId} / {adminProd.backend.publicIp}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>后端进程</span>
|
||||
<strong>{adminProd.backend.unit}</strong>
|
||||
<small>{adminProd.backend.wgUnit}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.adminRouteList}>
|
||||
<div>
|
||||
<strong>前端 API 转发</strong>
|
||||
<span>{adminProd.frontend.apiProxy}</span>
|
||||
<small>{adminProd.frontend.nginxConfig}</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>后端部署文件</strong>
|
||||
<span>{adminProd.backend.binaryPath}</span>
|
||||
<small>{adminProd.backend.configPath}</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>旧后台机</strong>
|
||||
<span>
|
||||
{adminProd.previousBackend.name} / {adminProd.previousBackend.publicIp}
|
||||
</span>
|
||||
<small>{adminProd.previousBackend.status}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.endpointList}>
|
||||
{adminProd.endpoints.map((endpoint) => (
|
||||
<a href={endpoint.url} key={endpoint.url} rel="noreferrer" target="_blank">
|
||||
<strong>{endpoint.label}</strong>
|
||||
<span>{endpoint.url}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.testboxPanel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>测试部署方式</h2>
|
||||
<span>
|
||||
{testbox.publicIp} / {testbox.privateIp} 拉取 {testbox.branch} 分支,timer 按提交变化增量重启
|
||||
</span>
|
||||
</div>
|
||||
<Tag tone="success" size="md">
|
||||
{testbox.timer}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.testboxGrid}>
|
||||
<div>
|
||||
<span>部署脚本</span>
|
||||
<strong>{testbox.deployScript}</strong>
|
||||
<small>{testbox.root}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>源码目录</span>
|
||||
<strong>{testbox.source}</strong>
|
||||
<small>{testbox.composeCommand}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>自动清理</span>
|
||||
<strong>{testbox.cleanupTimer}</strong>
|
||||
<small>构建缓存、旧镜像、退出容器按现有测试机策略清理</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Admin API</span>
|
||||
<strong>{testbox.adminApiUrl}</strong>
|
||||
<small>广州 admin-platform /api 通过内网转发</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>后台前端</span>
|
||||
<strong>{testbox.adminPlatformUrl}</strong>
|
||||
<small>广州 172.16.0.10:7001,{testbox.adminPlatformBranch} 分支</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.endpointList}>
|
||||
{testbox.endpoints.map((endpoint) => (
|
||||
<a href={endpoint.url} key={endpoint.url} rel="noreferrer" target="_blank">
|
||||
<strong>{endpoint.label}</strong>
|
||||
<span>{endpoint.url}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.yamlPanel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>测试机 Go 服务 YAML</h2>
|
||||
<span>
|
||||
{testboxConfigQuery.data?.target?.root ?? testbox.root},当前提交{" "}
|
||||
{testboxConfigQuery.data?.deployedCommit?.slice(0, 12) ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.panelActions}>
|
||||
<Tag tone={testboxConfigTone} size="md">
|
||||
{testboxConfigText}
|
||||
</Tag>
|
||||
<Button
|
||||
disabled={testboxConfigQuery.isFetching}
|
||||
isLoading={testboxConfigQuery.isLoading}
|
||||
onClick={handleTestboxConfigRefresh}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
刷新 YAML
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{testboxConfigQuery.isError ? (
|
||||
<div className={styles.apiNotice}>
|
||||
{testboxConfigQuery.error instanceof Error ? testboxConfigQuery.error.message : "测试机 YAML 读取失败"}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.yamlList}>
|
||||
{(testboxConfigQuery.data?.files ?? []).map((file) => (
|
||||
<div className={styles.yamlItem} key={file.path}>
|
||||
<div>
|
||||
<FileText size={16} />
|
||||
<strong>{file.service}</strong>
|
||||
<small>{file.path}</small>
|
||||
{!file.exists ? <Tag tone="warning">缺失</Tag> : null}
|
||||
{file.truncated ? <Tag tone="warning">已截断</Tag> : null}
|
||||
</div>
|
||||
<pre>
|
||||
<code>{file.exists ? file.content : "文件不存在"}</code>
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.apiPanel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>Deploy API</h2>
|
||||
<span>前端同源调用 `/deploy/api/*` 查询 inventory 和发布计划</span>
|
||||
<span>inventory、发布计划、TAT 运行态</span>
|
||||
</div>
|
||||
<Tag tone={apiStatusTone} size="md">
|
||||
{apiStatusText}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.apiGrid}>
|
||||
<div>
|
||||
<span>Inventory 服务数</span>
|
||||
<strong>
|
||||
{apiInventoryQuery.data ? Object.keys(apiInventoryQuery.data.services).length : "-"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Plan 步骤数</span>
|
||||
<strong>{apiPlanQuery.data?.steps.length ?? "-"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Registry</span>
|
||||
<strong>{apiInventoryQuery.data?.registry ?? hyappDeploymentPlan.registry}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>接口错误</span>
|
||||
<strong>{apiInventoryQuery.error instanceof Error ? apiInventoryQuery.error.message : "无"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.apiActions}>
|
||||
<div>
|
||||
<strong>真实运行态</strong>
|
||||
<span>{remoteSummaryText}</span>
|
||||
<span>
|
||||
实例 {remoteHostSuccessCount}/{remoteHosts.length},CLB {remoteClbSuccessCount}/{remoteClbSnapshots.length},最近刷新{" "}
|
||||
{lastRemoteSyncedAt}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Tag tone={remoteStatusTone} size="md">
|
||||
{remoteStatusText}
|
||||
</Tag>
|
||||
<Button
|
||||
disabled={apiDiscoverQuery.isFetching}
|
||||
disabled={apiDiscoverQuery.isFetching || serviceNames.length === 0}
|
||||
isLoading={apiDiscoverQuery.isLoading}
|
||||
onClick={handleRemoteDiscover}
|
||||
onClick={() => void apiDiscoverQuery.refetch()}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
立即刷新
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{apiDiscoverQuery.isError ? (
|
||||
<div className={styles.apiNotice}>{apiDiscoverQuery.error instanceof Error ? apiDiscoverQuery.error.message : "真实状态查询失败"}</div>
|
||||
{apiInventoryQuery.isError || apiDiscoverQuery.isError ? (
|
||||
<div className={styles.apiNotice}>
|
||||
{apiInventoryQuery.error instanceof Error
|
||||
? apiInventoryQuery.error.message
|
||||
: apiDiscoverQuery.error instanceof Error
|
||||
? apiDiscoverQuery.error.message
|
||||
: "真实状态查询失败"}
|
||||
</div>
|
||||
) : null}
|
||||
{apiDiscoverQuery.isSuccess ? (
|
||||
<div className={styles.discoveryGrid}>
|
||||
@ -489,96 +381,108 @@ export function ReleasesPage() {
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>服务部署映射</h2>
|
||||
<span>服务名、Compose 目标、生产 systemd unit、CLB 绑定和实例分布</span>
|
||||
<span>来自 inventory 和 TAT discover</span>
|
||||
</div>
|
||||
<Tag tone="brand" size="md">
|
||||
{hyappDeploymentPlan.services.length} 个服务
|
||||
{rows.length} 个服务
|
||||
</Tag>
|
||||
</div>
|
||||
<Table columns={serviceColumns} data={hyappDeploymentPlan.services} getRowKey={(service) => service.id} />
|
||||
<Table
|
||||
columns={serviceColumns}
|
||||
data={rows}
|
||||
empty={<EmptyState title="暂无服务" description="deploy API 未返回服务 inventory。" />}
|
||||
getRowKey={(row) => row.service.id}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.yamlPanel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>测试机 YAML</h2>
|
||||
<span>
|
||||
{testboxConfigQuery.data?.target?.root ?? "-"},当前提交 {testboxConfigQuery.data?.deployedCommit?.slice(0, 12) ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.panelActions}>
|
||||
<Tag tone={testboxConfigTone} size="md">
|
||||
{testboxConfigText}
|
||||
</Tag>
|
||||
<Button
|
||||
disabled={testboxConfigQuery.isFetching || serviceNames.length === 0}
|
||||
isLoading={testboxConfigQuery.isLoading}
|
||||
onClick={() => void testboxConfigQuery.refetch()}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
刷新 YAML
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{testboxConfigQuery.isError ? (
|
||||
<div className={styles.apiNotice}>
|
||||
{testboxConfigQuery.error instanceof Error ? testboxConfigQuery.error.message : "测试机 YAML 读取失败"}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.yamlList}>
|
||||
{(testboxConfigQuery.data?.files ?? []).map((file) => (
|
||||
<div className={styles.yamlItem} key={file.path}>
|
||||
<div>
|
||||
<FileText size={16} />
|
||||
<strong>{file.service}</strong>
|
||||
<small>{file.path}</small>
|
||||
{!file.exists ? <Tag tone="warning">缺失</Tag> : null}
|
||||
{file.truncated ? <Tag tone="warning">已截断</Tag> : null}
|
||||
</div>
|
||||
<pre>
|
||||
<code>{file.exists ? file.content : "文件不存在"}</code>
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className={styles.contentGrid}>
|
||||
<Card className={styles.panel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>生产滚动步骤</h2>
|
||||
<span>TAT 脚本对单实例固定执行的发布顺序</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.stepList}>
|
||||
{hyappDeploymentPlan.productionSteps.map((step, index) => (
|
||||
<div className={styles.stepItem} key={step.id}>
|
||||
<span>{index + 1}</span>
|
||||
<div>
|
||||
<strong>{step.title}</strong>
|
||||
<small>{step.detail}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.panel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>托管依赖</h2>
|
||||
<span>这些资源只作为连接配置,不进入 TAT 发布动作</span>
|
||||
<span>来自 inventory</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.dependencyList}>
|
||||
{hyappDeploymentPlan.managedDependencies.map((dependency) => (
|
||||
<div className={styles.dependencyItem} key={dependency.id}>
|
||||
{Object.entries(apiInventoryQuery.data?.managedDependencies ?? {}).map(([id, name]) => (
|
||||
<div className={styles.dependencyItem} key={id}>
|
||||
<Database size={18} />
|
||||
<div>
|
||||
<strong>{dependency.name}</strong>
|
||||
<span>{dependency.provider}</span>
|
||||
<small>{dependency.boundary}</small>
|
||||
<strong>{name}</strong>
|
||||
<span>{id}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className={styles.contentGrid}>
|
||||
<Card className={styles.panel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>命令入口</h2>
|
||||
<span>Makefile 与 TAT 脚本入口</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.commandList}>
|
||||
{hyappDeploymentPlan.commands.map((command) => (
|
||||
<div className={styles.commandItem} key={command.id}>
|
||||
<div>
|
||||
<PlayCircle size={17} />
|
||||
<strong>{command.title}</strong>
|
||||
</div>
|
||||
<pre>
|
||||
<code>{command.command}</code>
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.panel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>发布保护</h2>
|
||||
<span>从 hyapp-server 文档同步的禁止项</span>
|
||||
<h2>执行保护</h2>
|
||||
<span>真实操作必须显式确认</span>
|
||||
</div>
|
||||
<ShieldAlert size={20} />
|
||||
</div>
|
||||
<div className={styles.guardList}>
|
||||
{hyappDeploymentPlan.prohibitedActions.map((action) => (
|
||||
<div className={styles.guardItem} key={action}>
|
||||
<span />
|
||||
<strong>{action}</strong>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.guardItem}>
|
||||
<span />
|
||||
<strong>deploy / restart 必须传入 confirmed=true</strong>
|
||||
</div>
|
||||
<div className={styles.guardItem}>
|
||||
<span />
|
||||
<strong>联调和验收使用 dryRun=true</strong>
|
||||
</div>
|
||||
<div className={styles.guardItem}>
|
||||
<span />
|
||||
<strong>配置读取只走 TAT,密钥字段脱敏</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
290
src/pages/resources/ResourcesPage.module.css
Normal file
290
src/pages/resources/ResourcesPage.module.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<DataResourceType, string> = {
|
||||
kafka: "Kafka",
|
||||
mongodb: "MongoDB",
|
||||
mq: "MQ",
|
||||
mysql: "MySQL",
|
||||
redis: "Redis",
|
||||
rocketmq: "RocketMQ",
|
||||
unknown: "未知",
|
||||
};
|
||||
|
||||
const typeTone: Record<DataResourceType, "brand" | "neutral" | "success" | "warning"> = {
|
||||
kafka: "warning",
|
||||
mongodb: "success",
|
||||
mq: "brand",
|
||||
mysql: "brand",
|
||||
redis: "success",
|
||||
rocketmq: "brand",
|
||||
unknown: "neutral",
|
||||
};
|
||||
|
||||
const statusTone: Record<DataResourceStatus, StatusBadgeStatus> = {
|
||||
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 (
|
||||
<span className={styles.resourceCell}>
|
||||
<strong>{resource.name}</strong>
|
||||
<small>{resource.endpoints.length > 0 ? resource.endpoints.join("、") : resource.description || "未采集"}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<Card className={styles.loadingPanel} padded={false}>
|
||||
<div className={styles.loadingHeader}>
|
||||
<span />
|
||||
<div>
|
||||
<strong>加载中</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.skeletonGrid}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div className={styles.skeletonCard} key={index}>
|
||||
<span />
|
||||
<strong />
|
||||
<small />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResourcesPage() {
|
||||
return <PlaceholderPage title="数据漫游" />;
|
||||
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<TableColumn<DataResource>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "resource",
|
||||
render: (resource) => <ResourceNameCell resource={resource} />,
|
||||
title: "资源",
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
render: (resource) => (
|
||||
<Tag tone={typeTone[resource.type] ?? "neutral"}>{typeLabel[resource.type] ?? resource.type}</Tag>
|
||||
),
|
||||
title: "类型",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
render: (resource) => <StatusBadge status={statusTone[resource.status]} />,
|
||||
title: "状态",
|
||||
},
|
||||
{
|
||||
key: "topology",
|
||||
render: (resource) => (
|
||||
<span className={styles.stackText}>
|
||||
<strong>{resource.topology}</strong>
|
||||
</span>
|
||||
),
|
||||
title: "拓扑",
|
||||
},
|
||||
{
|
||||
key: "capacity",
|
||||
render: (resource) =>
|
||||
typeof resource.capacityUsedPercent === "number" ? (
|
||||
<ProgressMeter label={`${resource.name} 容量`} tone={metricTone(resource.status)} value={resource.capacityUsedPercent} />
|
||||
) : (
|
||||
<span className="text-muted">未采集</span>
|
||||
),
|
||||
title: "容量",
|
||||
},
|
||||
{
|
||||
key: "connections",
|
||||
render: (resource) => <strong className={styles.metricText}>{formatNumber(resource.connections)}</strong>,
|
||||
title: "连接数",
|
||||
},
|
||||
{
|
||||
key: "slow",
|
||||
render: (resource) => <strong className={styles.metricText}>{formatNumber(resource.slowQueryCount)}</strong>,
|
||||
title: "慢查询",
|
||||
},
|
||||
{
|
||||
key: "backup",
|
||||
render: (resource) => <span className={styles.backupText}>{resource.backupStatus}</span>,
|
||||
title: "备份",
|
||||
},
|
||||
{
|
||||
key: "owner",
|
||||
render: (resource) => <span className={styles.ownerText}>{resource.owner}</span>,
|
||||
title: "负责人",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
void dataResourcesQuery.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">数据资源</h1>
|
||||
</div>
|
||||
<Button disabled={dataResourcesQuery.isFetching} isLoading={dataResourcesQuery.isFetching} onClick={handleRefresh} size="sm">
|
||||
<RefreshCcw size={15} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className={styles.accessPanel} padded={false}>
|
||||
<div className={styles.accessPath}>
|
||||
<span>
|
||||
<Cable size={18} />
|
||||
TAT
|
||||
</span>
|
||||
<i />
|
||||
<span>
|
||||
<Server size={18} />
|
||||
{deployTarget ? `${deployTarget.name} / ${deployTarget.privateIp}` : "deploy"}
|
||||
</span>
|
||||
<i />
|
||||
<span>
|
||||
<HardDrive size={18} />
|
||||
腾讯云
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.accessMeta}>
|
||||
<Tag tone={tatTone} size="md">
|
||||
{tatStatus === "SUCCESS" ? "TAT 已连接" : dataResourcesQuery.isError ? "TAT 异常" : "TAT 连接中"}
|
||||
</Tag>
|
||||
<span>最近同步 {lastSyncedAt}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isInitialLoading ? <LoadingState /> : null}
|
||||
|
||||
{dataResourcesQuery.isError ? (
|
||||
<Card className={styles.noticePanel} padded={false}>
|
||||
<EmptyState
|
||||
description={dataResourcesQuery.error instanceof Error ? dataResourcesQuery.error.message : "deploy 机器未返回数据"}
|
||||
icon={<AlertTriangle size={22} />}
|
||||
title="数据资源读取失败"
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!isInitialLoading && !dataResourcesQuery.isError ? (
|
||||
<>
|
||||
<section className={styles.metrics}>
|
||||
<MetricCard icon={<Database size={24} />} label="资源健康" meta="正常 / 总数" tone="green" value={`${healthyCount}/${resources.length}`} />
|
||||
<MetricCard icon={<AlertTriangle size={24} />} label="异常资源" meta="异常" tone={abnormalCount > 0 ? "orange" : "green"} value={abnormalCount} />
|
||||
<MetricCard icon={<Server size={24} />} label="服务器来源" meta="SSH / TAT" value={`${sourceOkCount}/${sourceHosts.length}`} />
|
||||
<MetricCard icon={<ShieldCheck size={24} />} label="TCP 探测" meta="端点" tone="green" value={checkCount} />
|
||||
</section>
|
||||
|
||||
<Card className={styles.panel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>数据资源状态</h2>
|
||||
</div>
|
||||
<Tag tone="brand" size="md">
|
||||
{resources.length} 个资源
|
||||
</Tag>
|
||||
</div>
|
||||
<Table columns={columns} data={resources} getRowKey={(resource) => resource.id} />
|
||||
</Card>
|
||||
|
||||
<div className={styles.detailGrid}>
|
||||
{resources.map((resource) => (
|
||||
<Card className={styles.resourcePanel} key={resource.id} padded={false}>
|
||||
<div className={styles.resourceHeader}>
|
||||
<div>
|
||||
<h2>{resource.name}</h2>
|
||||
<span>{resource.topology}</span>
|
||||
</div>
|
||||
<StatusBadge status={statusTone[resource.status]} />
|
||||
</div>
|
||||
<div className={styles.checkList}>
|
||||
{resource.checks.length > 0 ? (
|
||||
resource.checks.map((check) => (
|
||||
<div className={styles.checkItem} key={`${resource.id}-${check.endpoint ?? check.detail}`}>
|
||||
<span data-status={check.status} />
|
||||
<div>
|
||||
<strong>{check.endpoint ?? "未识别端点"}</strong>
|
||||
<small>
|
||||
{check.detail ?? check.status}
|
||||
{typeof check.latencyMs === "number" ? ` / ${check.latencyMs}ms` : ""}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.checkItem}>
|
||||
<span data-status="unknown" />
|
||||
<div>
|
||||
<strong>未采集</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.relatedList}>
|
||||
{resource.relatedServices.slice(0, 8).map((service) => (
|
||||
<Tag key={service}>{service}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<DetailTab>("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 (
|
||||
<div className="page">
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="服务读取失败"
|
||||
description={detailQuery.error instanceof Error ? detailQuery.error.message : "deploy 机器未返回数据"}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!service) {
|
||||
return (
|
||||
<div className="page">
|
||||
@ -373,7 +384,7 @@ export function ServiceDetailPage() {
|
||||
<Card className={styles.section} padded={false}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2>监控概览</h2>
|
||||
<span>最近 1 小时</span>
|
||||
<span>当前采样</span>
|
||||
</div>
|
||||
<div className={styles.metricGrid}>
|
||||
{service.metrics.map((metric) => (
|
||||
@ -462,14 +473,29 @@ export function ServiceDetailPage() {
|
||||
<strong>{service.owner}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>实例规格</span>
|
||||
<strong>2C / 4Gi</strong>
|
||||
<span>Unit</span>
|
||||
<strong>{service.unit ?? "未采集"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发布策略</span>
|
||||
<strong>滚动发布</strong>
|
||||
<span>端口</span>
|
||||
<strong>{service.targetPort ?? "未采集"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.configYamlList}>
|
||||
{service.instances.map((instance) => (
|
||||
<div className={styles.configYamlItem} key={instance.id}>
|
||||
<div className={styles.configYamlHeader}>
|
||||
<FileText size={16} />
|
||||
<strong>{instance.name}</strong>
|
||||
<span>{instance.configPath ?? "未采集"}</span>
|
||||
{instance.configYamlTruncated ? <Tag tone="warning">已截断</Tag> : null}
|
||||
</div>
|
||||
<pre className={styles.inlineYamlBlock}>
|
||||
<code>{instance.configYaml || "未采集"}</code>
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
),
|
||||
events: (
|
||||
@ -591,13 +617,16 @@ export function ServiceDetailPage() {
|
||||
<Card className={styles.section} padded={false}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2>日志</h2>
|
||||
<span>最近发布窗口</span>
|
||||
<span>journalctl</span>
|
||||
</div>
|
||||
<div className={styles.logPanel}>
|
||||
<code>[14:18:02] release {service.version} completed</code>
|
||||
<code>[14:18:01] readiness probe passed on all pods</code>
|
||||
<code>[14:17:42] config checksum updated</code>
|
||||
<code>[14:17:10] rolling update started by backend</code>
|
||||
{service.instances.map((instance) => (
|
||||
<code key={instance.id}>
|
||||
{instance.name}
|
||||
{"\n"}
|
||||
{instance.logs || "未采集"}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
),
|
||||
@ -617,33 +646,33 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
<div className={styles.releaseGrid}>
|
||||
<div>
|
||||
<span>本地命令</span>
|
||||
<code>{deploymentService?.local.commands.rebuild ?? "未绑定"}</code>
|
||||
<span>服务</span>
|
||||
<code>{service.name}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span>生产 Unit</span>
|
||||
<strong>{deploymentService?.prod?.unit ?? "未进 TAT inventory"}</strong>
|
||||
<strong>{service.unit ?? "TAT 未返回"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>CLB 入口</span>
|
||||
<strong>{deploymentService?.prod?.clb.entrypoint ?? "local only"}</strong>
|
||||
<span>容器</span>
|
||||
<strong>{service.container ?? "TAT 未返回"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>配置模板</span>
|
||||
<code>{deploymentService?.prod?.configPath ?? deploymentService?.local.configPath ?? "未绑定"}</code>
|
||||
<span>配置</span>
|
||||
<code>{service.configPath ?? "TAT 未返回"}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.releaseHosts}>
|
||||
<span>实例分布</span>
|
||||
<div>
|
||||
{deploymentHosts.length > 0 ? (
|
||||
deploymentHosts.map((host) => (
|
||||
<Tag key={host.id} tone="success">
|
||||
{host.id} / {host.privateIp}
|
||||
{service.instances.length > 0 ? (
|
||||
service.instances.map((instance) => (
|
||||
<Tag key={instance.id} tone="success">
|
||||
{instance.node} / {instance.podIp}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Tag tone="warning">local-compose</Tag>
|
||||
<Tag tone="warning">TAT 未返回</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -716,7 +745,7 @@ export function ServiceDetailPage() {
|
||||
<CheckCircle2 size={15} />
|
||||
<span>依赖与实例状态已同步</span>
|
||||
<Clock3 size={14} />
|
||||
<span>刚刚</span>
|
||||
<span>TAT</span>
|
||||
<Database size={15} />
|
||||
<span>{service.dependencies.length} 个依赖</span>
|
||||
<Box size={15} />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
className={styles.modalOverlay}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<section aria-labelledby="service-config-yaml-title" aria-modal="true" className={styles.yamlModal} role="dialog">
|
||||
<div className={styles.modalHeader}>
|
||||
<div>
|
||||
<h2 id="service-config-yaml-title">配置 YAML</h2>
|
||||
<span>{service?.name ?? "加载中"}</span>
|
||||
</div>
|
||||
<IconButton label="关闭配置 YAML" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.modalState}>加载中</div>
|
||||
) : error ? (
|
||||
<div className={styles.modalState}>{error instanceof Error ? error.message : "配置读取失败"}</div>
|
||||
) : instances.length === 0 ? (
|
||||
<div className={styles.modalState}>未采集到实例配置</div>
|
||||
) : (
|
||||
<div className={styles.yamlList}>
|
||||
{instances.map((instance) => (
|
||||
<div className={styles.yamlItem} key={instance.id}>
|
||||
<div className={styles.yamlItemHeader}>
|
||||
<FileText size={16} />
|
||||
<strong>{instance.name}</strong>
|
||||
<small>{instance.configPath ?? "未采集"}</small>
|
||||
{instance.configYamlTruncated ? <span>已截断</span> : null}
|
||||
</div>
|
||||
<pre>
|
||||
<code>{instance.configYaml || "未采集"}</code>
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { clusterId, environment, projectId } = useAppContextStore();
|
||||
const openRelease = useReleaseDrawerStore((state) => state.openRelease);
|
||||
const [configServiceId, setConfigServiceId] = useState("");
|
||||
const serviceListParams = useMemo<ServiceListParams>(() => {
|
||||
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 (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">微服务</h1>
|
||||
<p className="page-subtitle">集中检索和管理当前项目环境下的服务</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -36,14 +116,33 @@ export function ServicesPage() {
|
||||
<span className={styles.count}>
|
||||
共 {servicesQuery.data?.total ?? 0} 个服务
|
||||
</span>
|
||||
<Button disabled={servicesQuery.isFetching} isLoading={servicesQuery.isFetching} onClick={() => void servicesQuery.refetch()} size="sm" variant="secondary">
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
{servicesQuery.isError ? (
|
||||
<EmptyState
|
||||
title="微服务读取失败"
|
||||
description={servicesQuery.error instanceof Error ? servicesQuery.error.message : "deploy 机器未返回数据"}
|
||||
/>
|
||||
) : null}
|
||||
<ServiceTable
|
||||
isLoading={servicesQuery.isLoading}
|
||||
isLoading={servicesQuery.isLoading || (servicesQuery.isFetching && !servicesQuery.data)}
|
||||
onViewConfig={(service) => setConfigServiceId(service.id)}
|
||||
onRelease={openRelease}
|
||||
onViewDetails={(service) => navigate(`/services/${service.id}`)}
|
||||
services={services}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{configServiceId ? (
|
||||
<ConfigYamlModal
|
||||
error={configQuery.error}
|
||||
isLoading={configQuery.isLoading || (configQuery.isFetching && !configQuery.data)}
|
||||
onClose={() => setConfigServiceId("")}
|
||||
service={configQuery.data}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
302
src/pages/test-services/TestServicesPage.module.css
Normal file
302
src/pages/test-services/TestServicesPage.module.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
557
src/pages/test-services/TestServicesPage.tsx
Normal file
557
src/pages/test-services/TestServicesPage.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className={styles.modalOverlay}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<section aria-labelledby="testbox-config-title" aria-modal="true" className={styles.configModal} role="dialog">
|
||||
<div className={styles.modalHeader}>
|
||||
<div>
|
||||
<h2 id="testbox-config-title">编辑 config.yaml</h2>
|
||||
<span>
|
||||
{file.service} · {file.path}
|
||||
</span>
|
||||
</div>
|
||||
<IconButton label="关闭配置编辑" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={styles.configEditorBody}>
|
||||
<textarea
|
||||
className={styles.configTextarea}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
spellCheck={false}
|
||||
value={content}
|
||||
/>
|
||||
<div className={styles.editorAside}>
|
||||
<strong>保存规则</strong>
|
||||
<span>保存前会在测试机备份当前文件。</span>
|
||||
<span>内容包含脱敏占位符时禁止保存,避免覆盖真实密钥。</span>
|
||||
<span>保存后可选择立即重启当前服务。</span>
|
||||
</div>
|
||||
</div>
|
||||
{containsRedactedSecret ? <div className={styles.notice}>当前内容包含 ***,需要补齐真实值后才能保存。</div> : null}
|
||||
<div className={styles.modalActions}>
|
||||
<Button onClick={onClose} size="sm" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!content.trim() || containsRedactedSecret}
|
||||
isLoading={isSaving}
|
||||
onClick={() => onSave(content, false)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!content.trim() || containsRedactedSecret}
|
||||
isLoading={isSaving}
|
||||
onClick={() => onSave(content, true)}
|
||||
size="sm"
|
||||
variant="primary"
|
||||
>
|
||||
保存并重启
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestServicesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const services = useMemo(() => [...TESTBOX_SERVICES], []);
|
||||
const statusQuery = useDeployApiTestboxStatusQuery(services);
|
||||
const configQuery = useDeployApiTestboxConfigsQuery(services);
|
||||
const migrationsQuery = useDeployApiTestboxMigrationsQuery();
|
||||
const [editingService, setEditingService] = useState("");
|
||||
const [lastOutput, setLastOutput] = useState("");
|
||||
const status = statusQuery.data;
|
||||
const configFiles = configQuery.data?.files ?? [];
|
||||
const editingFile = configFiles.find((file) => file.service === editingService);
|
||||
const pendingMigrations = migrationsQuery.data?.migrations.filter((migration) => migration.status === "pending") ?? [];
|
||||
const healthyCount = status?.services.filter((service) => service.status === "healthy").length ?? 0;
|
||||
|
||||
const invalidateTestbox = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.deployApiTestboxStatus(services) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.deployApiTestboxConfigs(services) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.deployApiTestboxMigrations() });
|
||||
};
|
||||
|
||||
const serviceMutation = useMutation({
|
||||
mutationFn: runDeployApiTestboxServiceAction,
|
||||
onSuccess: (data) => {
|
||||
setLastOutput(commandText(data.commands) || commandText(data.status ? [data.status] : []));
|
||||
invalidateTestbox();
|
||||
},
|
||||
});
|
||||
const timerMutation = useMutation({
|
||||
mutationFn: runDeployApiTestboxTimerAction,
|
||||
onSuccess: (data) => {
|
||||
setLastOutput(commandText(data.commands));
|
||||
invalidateTestbox();
|
||||
},
|
||||
});
|
||||
const configMutation = useMutation({
|
||||
mutationFn: saveDeployApiTestboxConfig,
|
||||
onSuccess: (data) => {
|
||||
setLastOutput([`backup: ${data.backupPath || "无"}`, commandText(data.commands)].filter(Boolean).join("\n"));
|
||||
setEditingService("");
|
||||
invalidateTestbox();
|
||||
},
|
||||
});
|
||||
const migrationMutation = useMutation({
|
||||
mutationFn: applyDeployApiTestboxMigrations,
|
||||
onSuccess: (data) => {
|
||||
setLastOutput(commandText(data.commands));
|
||||
invalidateTestbox();
|
||||
},
|
||||
});
|
||||
|
||||
const runServiceOperation = (operation: ServiceOperation, selectedServices: string[]) => {
|
||||
const label = {
|
||||
restart: "重启",
|
||||
start: "启动",
|
||||
stop: "停止",
|
||||
update: "更新",
|
||||
}[operation];
|
||||
|
||||
if (!window.confirm(`确认${label} ${selectedServices.join("、")}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
serviceMutation.mutate({ operation, services: selectedServices });
|
||||
};
|
||||
|
||||
const runTimerOperation = (operation: "start" | "stop" | "restart") => {
|
||||
const label = operation === "start" ? "恢复自动更新" : operation === "stop" ? "暂停自动更新" : "重启自动更新 timer";
|
||||
|
||||
if (!window.confirm(`确认${label}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
timerMutation.mutate(operation);
|
||||
};
|
||||
|
||||
const runMigrations = (files: string[]) => {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`确认在测试库执行 ${files.length} 个 migration?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
migrationMutation.mutate(files);
|
||||
};
|
||||
|
||||
const serviceColumns: TableColumn<TestboxService>[] = [
|
||||
{
|
||||
key: "service",
|
||||
title: "服务",
|
||||
render: (service) => (
|
||||
<span className={styles.serviceCell}>
|
||||
<strong>{service.service}</strong>
|
||||
<small>{service.configPath}</small>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
title: "状态",
|
||||
render: (service) => <StatusBadge status={service.status} />,
|
||||
},
|
||||
{
|
||||
key: "runtime",
|
||||
title: "运行态",
|
||||
render: (service) => (
|
||||
<span className={styles.stackText}>
|
||||
<strong>{service.state}</strong>
|
||||
<small>{service.health}</small>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
title: "镜像 / 进程",
|
||||
render: (service) => (
|
||||
<span className={styles.stackText}>
|
||||
<strong>{service.image || service.composeService}</strong>
|
||||
<small>{service.containerId || service.name}</small>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "restart",
|
||||
title: "重启次数",
|
||||
render: (service) => service.restartCount,
|
||||
},
|
||||
{
|
||||
className: styles.serviceActions,
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
render: (service) => (
|
||||
<span className={styles.actionGroup}>
|
||||
<Button isLoading={serviceMutation.isPending} onClick={() => runServiceOperation("update", [service.service])} size="sm" variant="secondary">
|
||||
更新
|
||||
</Button>
|
||||
<IconButton disabled={serviceMutation.isPending} label="重启服务" onClick={() => runServiceOperation("restart", [service.service])}>
|
||||
<RotateCw size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled={serviceMutation.isPending} label="启动服务" onClick={() => runServiceOperation("start", [service.service])}>
|
||||
<Play size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled={serviceMutation.isPending} label="停止服务" onClick={() => runServiceOperation("stop", [service.service])}>
|
||||
<Square size={15} />
|
||||
</IconButton>
|
||||
<Button onClick={() => setEditingService(service.service)} size="sm" variant="ghost">
|
||||
配置
|
||||
</Button>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const migrationColumns: TableColumn<DeployApiTestboxMigration>[] = [
|
||||
{
|
||||
key: "version",
|
||||
title: "文件",
|
||||
render: (migration) => (
|
||||
<span className={styles.serviceCell}>
|
||||
<strong>{migration.version}</strong>
|
||||
<small>{migration.path}</small>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
title: "状态",
|
||||
render: (migration) => <Tag tone={migrationTone(migration.status)}>{migrationLabel(migration.status)}</Tag>,
|
||||
},
|
||||
{
|
||||
key: "checksum",
|
||||
title: "Checksum",
|
||||
render: (migration) => <span className={styles.mono}>{migration.checksum.slice(0, 16)}</span>,
|
||||
},
|
||||
{
|
||||
key: "appliedAt",
|
||||
title: "执行时间",
|
||||
render: (migration) =>
|
||||
migration.appliedAtMs > 0 ? new Date(migration.appliedAtMs).toLocaleString("zh-CN", { hour12: false }) : "-",
|
||||
},
|
||||
{
|
||||
className: styles.migrationActions,
|
||||
key: "action",
|
||||
title: "操作",
|
||||
render: (migration) => (
|
||||
<Button
|
||||
disabled={migration.status !== "pending" || !migrationsQuery.data?.mysqlAvailable || migrationMutation.isPending}
|
||||
isLoading={migrationMutation.isPending}
|
||||
onClick={() => runMigrations([migration.version])}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
执行
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">测试服务</h1>
|
||||
<p className="page-subtitle">测试机 Go 服务、自动更新、config.yaml 和 admin SQL migration</p>
|
||||
</div>
|
||||
<Button disabled={statusQuery.isFetching} isLoading={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} size="sm" variant="secondary">
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className={styles.heroGrid}>
|
||||
<Card className={styles.heroCard} padded={false}>
|
||||
<span className={styles.heroIcon}>
|
||||
<GitBranch size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<span>test 分支</span>
|
||||
<strong>{status?.git?.branch ?? "-"}</strong>
|
||||
<small>{shortCommit(status?.git?.localCommit)}</small>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.heroCard} padded={false}>
|
||||
<span className={styles.heroIcon}>
|
||||
<ServerCog size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<span>已部署 commit</span>
|
||||
<strong>{shortCommit(status?.deployedCommit)}</strong>
|
||||
<small>远端 {shortCommit(status?.git?.remoteTestCommit)}</small>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.heroCard} padded={false}>
|
||||
<span className={styles.heroIcon}>
|
||||
<Timer size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<span>自动更新</span>
|
||||
<strong>{status?.timerState ?? "-"}</strong>
|
||||
<small>{status?.target?.timer ?? "hyapp-server-test-deploy.timer"}</small>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.heroCard} padded={false}>
|
||||
<span className={styles.heroIcon}>
|
||||
<Power size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<span>服务健康</span>
|
||||
<strong>
|
||||
{healthyCount}/{status?.services.length ?? services.length}
|
||||
</strong>
|
||||
<small>{status?.target?.publicIp ?? "测试机 TAT"}</small>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card className={styles.panel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>自动更新重启</h2>
|
||||
<span>控制测试机每分钟拉取 test 分支的 systemd timer</span>
|
||||
</div>
|
||||
<div className={styles.panelActions}>
|
||||
<Tag tone={timerTone(status?.timerState)} size="md">
|
||||
{status?.timerState ?? "查询中"}
|
||||
</Tag>
|
||||
<Button isLoading={timerMutation.isPending} onClick={() => runTimerOperation("stop")} size="sm" variant="secondary">
|
||||
暂停
|
||||
</Button>
|
||||
<Button isLoading={timerMutation.isPending} onClick={() => runTimerOperation("start")} size="sm" variant="primary">
|
||||
恢复
|
||||
</Button>
|
||||
<IconButton disabled={timerMutation.isPending} label="重启 timer" onClick={() => runTimerOperation("restart")}>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.panel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>Go 微服务</h2>
|
||||
<span>服务操作固定走测试机 Docker Compose / systemd,不执行任意命令</span>
|
||||
</div>
|
||||
<div className={styles.panelActions}>
|
||||
<Button isLoading={serviceMutation.isPending} onClick={() => runServiceOperation("update", services)} size="sm" variant="primary">
|
||||
更新全部
|
||||
</Button>
|
||||
<Button isLoading={serviceMutation.isPending} onClick={() => runServiceOperation("restart", services)} size="sm" variant="secondary">
|
||||
重启全部
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{statusQuery.isError ? (
|
||||
<div className={styles.notice}>{statusQuery.error instanceof Error ? statusQuery.error.message : "测试机状态读取失败"}</div>
|
||||
) : null}
|
||||
<Table
|
||||
columns={serviceColumns}
|
||||
data={status?.services ?? []}
|
||||
empty={<EmptyState title="等待测试机状态" description="TAT 未返回前不展示运行态。" />}
|
||||
getRowKey={(service) => service.service}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.panel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>SQL migration</h2>
|
||||
<span>{migrationsQuery.data?.migrationDir ?? "server/admin/migrations"}</span>
|
||||
</div>
|
||||
<div className={styles.panelActions}>
|
||||
<Tag tone={migrationsQuery.data?.mysqlAvailable ? "success" : "warning"} size="md">
|
||||
mysql client {migrationsQuery.data?.mysqlAvailable ? "可用" : "不可用"}
|
||||
</Tag>
|
||||
<Button
|
||||
disabled={pendingMigrations.length === 0 || !migrationsQuery.data?.mysqlAvailable || migrationMutation.isPending}
|
||||
isLoading={migrationMutation.isPending}
|
||||
onClick={() => runMigrations(pendingMigrations.map((migration) => migration.version))}
|
||||
size="sm"
|
||||
variant="primary"
|
||||
>
|
||||
执行待执行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{migrationsQuery.data?.database ? (
|
||||
<div className={styles.databaseLine}>
|
||||
<Database size={16} />
|
||||
<span>
|
||||
{migrationsQuery.data.database.user}@{migrationsQuery.data.database.host}:{migrationsQuery.data.database.port}/
|
||||
{migrationsQuery.data.database.database}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.notice}>未从测试机 admin-server/config.yaml 解析到 mysql_dsn。</div>
|
||||
)}
|
||||
{migrationsQuery.isError || migrationsQuery.data?.error ? (
|
||||
<div className={styles.notice}>
|
||||
{migrationsQuery.error instanceof Error ? migrationsQuery.error.message : migrationsQuery.data?.error ?? "migration 查询失败"}
|
||||
</div>
|
||||
) : null}
|
||||
<Table
|
||||
columns={migrationColumns}
|
||||
data={migrationsQuery.data?.migrations ?? []}
|
||||
empty={<EmptyState title="暂无 migration" description="测试机仓库没有返回 server/admin/migrations/*.sql。" />}
|
||||
getRowKey={(migration) => migration.version}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.panel} padded={false}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>最近执行输出</h2>
|
||||
<span>只展示本页面触发的操作返回</span>
|
||||
</div>
|
||||
</div>
|
||||
<pre className={styles.output}>
|
||||
<code>{lastOutput || "暂无操作输出"}</code>
|
||||
</pre>
|
||||
</Card>
|
||||
|
||||
{editingFile ? (
|
||||
<ConfigEditor
|
||||
file={editingFile}
|
||||
isSaving={configMutation.isPending}
|
||||
onClose={() => setEditingService("")}
|
||||
onSave={(content, restart) => configMutation.mutate({ content, restart, service: editingFile.service })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/pages/test-services/index.ts
Normal file
1
src/pages/test-services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { TestServicesPage } from "./TestServicesPage";
|
||||
@ -1,10 +1,13 @@
|
||||
import type { ServiceListParams } from "../../entities/service";
|
||||
|
||||
export const queryKeys = {
|
||||
dataResources: () => ["data-resources"] as const,
|
||||
deployApiInventory: () => ["deploy-api-inventory"] as const,
|
||||
deployApiDiscover: (services: string[]) => ["deploy-api-discover", services] as const,
|
||||
deployApiPlan: (services: string[]) => ["deploy-api-plan", services] as const,
|
||||
deployApiTestboxConfigs: (services: string[]) => ["deploy-api-testbox-configs", services] as const,
|
||||
serviceDetail: (serviceId: string) => ["service-detail", serviceId] as const,
|
||||
deployApiTestboxMigrations: () => ["deploy-api-testbox-migrations"] as const,
|
||||
deployApiTestboxStatus: (services: string[]) => ["deploy-api-testbox-status", services] as const,
|
||||
serviceDetail: (serviceId: string, environment: string) => ["service-detail", serviceId, environment] as const,
|
||||
services: (params: ServiceListParams) => ["services", params] as const,
|
||||
};
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
import { ReactNode } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Database,
|
||||
FileClock,
|
||||
Gauge,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Shield,
|
||||
ServerCog,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
@ -23,12 +20,9 @@ type AppShellProps = {
|
||||
const navItems = [
|
||||
{ icon: LayoutDashboard, label: "总览", to: "/overview" },
|
||||
{ icon: Workflow, label: "微服务", to: "/services" },
|
||||
{ icon: ServerCog, label: "测试服务", to: "/test-services" },
|
||||
{ icon: FileClock, label: "部署方案", to: "/releases" },
|
||||
{ icon: Database, label: "数据漫游", to: "/resources" },
|
||||
{ icon: Settings, label: "配置中心", to: "/settings" },
|
||||
{ icon: Activity, label: "日志与事件", to: "/logs-events" },
|
||||
{ icon: Bell, label: "监控告警", to: "/alerts" },
|
||||
{ icon: Shield, label: "权限与审计", to: "/audit" },
|
||||
{ icon: Database, label: "数据资源", to: "/resources" },
|
||||
] as const;
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
|
||||
@ -10,12 +10,14 @@ import styles from "./ServiceTable.module.css";
|
||||
|
||||
type ServiceTableProps = {
|
||||
isLoading?: boolean;
|
||||
onViewConfig?: (service: Service) => void;
|
||||
onRelease?: (service: Service) => void;
|
||||
onViewDetails?: (service: Service) => void;
|
||||
services: Service[];
|
||||
};
|
||||
|
||||
type ServiceTableActions = {
|
||||
onViewConfig: ((service: Service) => void) | undefined;
|
||||
onRelease: ((service: Service) => void) | undefined;
|
||||
onViewDetails: ((service: Service) => void) | undefined;
|
||||
};
|
||||
@ -33,6 +35,7 @@ function usageTone(value: number) {
|
||||
}
|
||||
|
||||
function createColumns({
|
||||
onViewConfig,
|
||||
onRelease,
|
||||
onViewDetails,
|
||||
}: ServiceTableActions): TableColumn<Service>[] {
|
||||
@ -115,7 +118,11 @@ function createColumns({
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<IconButton label="更多操作">
|
||||
<IconButton
|
||||
disabled={!onViewConfig}
|
||||
label="查看配置 YAML"
|
||||
onClick={() => onViewConfig?.(service)}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</IconButton>
|
||||
</span>
|
||||
@ -140,11 +147,12 @@ const skeletonServices: Service[] = Array.from({ length: 5 }, (_, index) => ({
|
||||
|
||||
export function ServiceTable({
|
||||
isLoading = false,
|
||||
onViewConfig,
|
||||
onRelease,
|
||||
onViewDetails,
|
||||
services,
|
||||
}: ServiceTableProps) {
|
||||
const columns = createColumns({ onRelease, onViewDetails });
|
||||
const columns = createColumns({ onRelease, onViewConfig, onViewDetails });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user