部署
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
|
# Deploy Platform
|
||||||
|
|
||||||
液态玻璃风格的项目运维管理平台。
|
项目运维平台。
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
@ -43,3 +43,20 @@ make up
|
|||||||
```
|
```
|
||||||
|
|
||||||
部署模板和 TAT 脚本见 `deploy/README.md`。
|
部署模板和 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/health`:只返回凭据是否存在,不返回密钥明文。
|
||||||
- `GET /deploy/api/hyapp/inventory`:读取 HyApp 生产 inventory。
|
- `GET /deploy/api/hyapp/inventory`:读取 HyApp 生产 inventory。
|
||||||
- `GET /deploy/api/hyapp/plan?services=gateway-service,room-service`:生成发布计划,不执行远端动作。
|
- `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/deploy`:真实发布,必须带 `confirmed=true`;测试时使用 `dryRun=true`。
|
||||||
- `POST /deploy/api/hyapp/restart`:真实重启,必须带 `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) => {
|
request.on("data", (chunk) => {
|
||||||
raw += chunk;
|
raw += chunk;
|
||||||
|
|
||||||
if (raw.length > 64 * 1024) {
|
if (raw.length > 256 * 1024) {
|
||||||
reject(new Error("request body too large"));
|
reject(new Error("request body too large"));
|
||||||
request.destroy();
|
request.destroy();
|
||||||
}
|
}
|
||||||
@ -226,6 +226,44 @@ function buildEnvArgs() {
|
|||||||
return tencentEnvFile ? ["--env-file", tencentEnvFile] : [];
|
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) {
|
function buildActionArgs(action, body) {
|
||||||
const services = parseServices(body.services, "");
|
const services = parseServices(body.services, "");
|
||||||
const hosts = parseHosts(body.hosts);
|
const hosts = parseHosts(body.hosts);
|
||||||
@ -358,6 +396,8 @@ async function handleApi(request, response, url) {
|
|||||||
if (url.pathname === "/deploy/api/hyapp/discover") {
|
if (url.pathname === "/deploy/api/hyapp/discover") {
|
||||||
const services = parseServices(url.searchParams.get("services"), "gateway-service,room-service,user-service");
|
const services = parseServices(url.searchParams.get("services"), "gateway-service,room-service,user-service");
|
||||||
const executeRemote = url.searchParams.get("remote") === "1";
|
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 = [
|
const args = [
|
||||||
"discover",
|
"discover",
|
||||||
...buildEnvArgs(),
|
...buildEnvArgs(),
|
||||||
@ -370,27 +410,32 @@ async function handleApi(request, response, url) {
|
|||||||
if (!executeRemote) {
|
if (!executeRemote) {
|
||||||
args.push("--dry-run");
|
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);
|
const result = await runDeployScript("hyapp_tat_deploy.py", args);
|
||||||
sendJson(response, result.ok ? 200 : 500, result.payload);
|
sendJson(response, result.ok ? 200 : 500, result.payload);
|
||||||
return;
|
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") {
|
if (url.pathname === "/deploy/api/hyapp/testbox/configs") {
|
||||||
const services = parseServices(url.searchParams.get("services"), hyappTestboxConfigServices);
|
const services = parseServices(url.searchParams.get("services"), hyappTestboxConfigServices);
|
||||||
const raw = url.searchParams.get("raw") === "1" || url.searchParams.get("raw") === "true";
|
const raw = url.searchParams.get("raw") === "1" || url.searchParams.get("raw") === "true";
|
||||||
const args = [
|
const args = [...buildTestboxBaseArgs("configs"), "--services", services.join(",")];
|
||||||
"configs",
|
|
||||||
...buildEnvArgs(),
|
|
||||||
"--region",
|
|
||||||
hyappTestbox.region,
|
|
||||||
"--instance-id",
|
|
||||||
hyappTestbox.instanceId,
|
|
||||||
"--root",
|
|
||||||
hyappTestbox.root,
|
|
||||||
"--services",
|
|
||||||
services.join(","),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (raw && process.env.HYAPP_TESTBOX_CONFIG_ALLOW_RAW === "1") {
|
if (raw && process.env.HYAPP_TESTBOX_CONFIG_ALLOW_RAW === "1") {
|
||||||
args.push("--raw");
|
args.push("--raw");
|
||||||
@ -401,6 +446,130 @@ async function handleApi(request, response, url) {
|
|||||||
return;
|
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");
|
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/plan?services=gateway-service,room-service`
|
||||||
- `GET /deploy/api/hyapp/discover?services=gateway-service&remote=0`
|
- `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`
|
||||||
|
- `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/deploy`
|
||||||
- `POST /deploy/api/hyapp/restart`
|
- `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
|
## Build Image
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
本目录包含两类 TAT 发布入口:
|
本目录包含两类 TAT 发布入口:
|
||||||
|
|
||||||
- `deploy_platform_tat_deploy.py`:发布当前 `deploy-platform` 前端项目。
|
- `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,健康恢复后恢复权重。
|
两者使用同一套 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
|
--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:
|
生产使用时复制成真实 inventory:
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
## HyApp Testbox
|
||||||
|
|
||||||
测试机部署不走生产镜像仓库,也不摘 CLB。当前模式是测试机自己拉取远端 `test` 分支:
|
测试机部署不走生产镜像仓库,也不摘 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`。
|
默认返回会遮罩 `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,
|
"service_health_timeout_seconds": 150,
|
||||||
"stabilize_seconds": 5,
|
"stabilize_seconds": 5,
|
||||||
"managed_dependencies": {
|
"managed_dependencies": {
|
||||||
"mysql": "TencentDB for MySQL private endpoint; this deployment never starts local MySQL.",
|
"mysql": "TencentDB MySQL",
|
||||||
"redis": "TencentDB for Redis private endpoint; room owner route and login/risk cache use this managed Redis.",
|
"redis": "TencentDB Redis",
|
||||||
"mq": "Tencent Cloud managed MQ; future consumers only receive connection/topic config and are not deployed as local infra."
|
"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": {
|
"deploy_server": {
|
||||||
"name": "deploy",
|
"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"]),
|
"target_port": int(service["target_port"]),
|
||||||
"unit": service["unit"],
|
"unit": service["unit"],
|
||||||
"container": service["container"],
|
"container": service["container"],
|
||||||
|
"env_file": service["env_file"],
|
||||||
|
"config_path": service.get("config_path") or f"/etc/hyapp/{service_name}/config.yaml",
|
||||||
"image": image,
|
"image": image,
|
||||||
"clb_enabled": bool(clb.get("enabled")) and not no_clb,
|
"clb_enabled": bool(clb.get("enabled")) and not no_clb,
|
||||||
"clb_load_balancer_id": str(clb.get("load_balancer_id") or ""),
|
"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"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def remote_discover_script() -> str:
|
def remote_discover_script(*, host_services: list[dict[str, Any]], include_config: bool, max_config_bytes: int) -> str:
|
||||||
# 只读取 hyapp 命名空间内的 unit/env/container,不扫描或修改 Java 项目。
|
# 只读取 inventory 明确声明的 hyapp unit/env/container/config,不扫描或修改 Java 项目。
|
||||||
return r"""set -euo pipefail
|
payload = {
|
||||||
echo "== hyapp units =="
|
"includeConfig": include_config,
|
||||||
systemctl list-units 'hyapp-*.service' --no-pager --all || true
|
"maxConfigBytes": max_config_bytes,
|
||||||
echo "== hyapp env files =="
|
"services": [
|
||||||
if [ -d /etc/hyapp ]; then
|
{
|
||||||
find /etc/hyapp -maxdepth 2 -name docker.env -print 2>/dev/null | sort | while read -r env_file; do
|
"configPath": service.get("config_path") or "",
|
||||||
echo "-- $env_file"
|
"container": service.get("container") or "",
|
||||||
sed -n 's/^\(IMAGE\|CONTAINER_NAME\|CONFIG_PATH\|STOP_TIMEOUT_SEC\)=/\1=/p' "$env_file" || true
|
"envFile": service.get("env_file") or "",
|
||||||
done
|
"name": service.get("service") or "",
|
||||||
else
|
"targetPort": service.get("target_port") or 0,
|
||||||
echo "/etc/hyapp missing"
|
"unit": service.get("unit") or "",
|
||||||
fi
|
}
|
||||||
echo "== hyapp containers =="
|
for service in host_services
|
||||||
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
|
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:
|
if dry_run:
|
||||||
return {"ok": True, "dry_run": True, "steps": plan}
|
return {"ok": True, "dry_run": True, "steps": plan}
|
||||||
clb_client = build_clb_client(inventory)
|
clb_client = build_clb_client(inventory)
|
||||||
seen_hosts = list(dict.fromkeys(step["host"] for step in plan))
|
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}
|
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]:
|
def discover_host(host_name: str, seed: dict[str, Any]) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
@ -541,17 +757,28 @@ def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *,
|
|||||||
result = run_tat_shell(
|
result = run_tat_shell(
|
||||||
tat_client,
|
tat_client,
|
||||||
instance_id=seed["instance_id"],
|
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}",
|
command_name=f"hyapp-discover-{host_name}",
|
||||||
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
|
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
|
||||||
poll_seconds=int(inventory.get("tat_poll_seconds") or 2),
|
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 {
|
return {
|
||||||
"host": host_name,
|
"host": host_name,
|
||||||
"instance_id": seed["instance_id"],
|
"instance_id": seed["instance_id"],
|
||||||
"private_ip": seed["private_ip"],
|
"private_ip": seed["private_ip"],
|
||||||
"status": result["status"],
|
"status": result["status"],
|
||||||
"output": str(result.get("output") or "")[-5000:],
|
"output": "" if services else output[-5000:],
|
||||||
|
"services": services,
|
||||||
}
|
}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
return {
|
return {
|
||||||
@ -560,6 +787,7 @@ def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *,
|
|||||||
"private_ip": seed["private_ip"],
|
"private_ip": seed["private_ip"],
|
||||||
"status": "ERROR",
|
"status": "ERROR",
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
|
"services": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
host_results: dict[str, dict[str, Any]] = {}
|
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("--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("--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("--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.")
|
parser.add_argument("--yes", action="store_true", help="Required for real restart/deploy execution.")
|
||||||
return parser.parse_args(argv)
|
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))
|
print(json.dumps({"ok": True, "steps": plan}, ensure_ascii=False, indent=2))
|
||||||
return 0
|
return 0
|
||||||
if args.action == "discover":
|
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))
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
return 0 if result.get("ok") else 1
|
return 0 if result.get("ok") else 1
|
||||||
result = execute_plan(inventory, plan, dry_run=args.dry_run, assume_yes=args.yes)
|
result = execute_plan(inventory, plan, dry_run=args.dry_run, assume_yes=args.yes)
|
||||||
|
|||||||
@ -27,6 +27,9 @@ DEFAULT_SERVICES = (
|
|||||||
"admin-server",
|
"admin-server",
|
||||||
)
|
)
|
||||||
SERVICE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
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:
|
def load_env_file(path: str) -> None:
|
||||||
@ -79,6 +82,21 @@ def parse_services(raw_services: str) -> list[str]:
|
|||||||
return list(dict.fromkeys(services))
|
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:
|
def build_tat_client(region: str) -> Any:
|
||||||
sid, skey = require_credentials()
|
sid, skey = require_credentials()
|
||||||
try:
|
try:
|
||||||
@ -191,7 +209,7 @@ root = payload["root"]
|
|||||||
services = payload["services"]
|
services = payload["services"]
|
||||||
redact = bool(payload["redact"])
|
redact = bool(payload["redact"])
|
||||||
max_bytes = int(payload["maxBytes"])
|
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):
|
def service_path(service):
|
||||||
if service == "admin-server":
|
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]:
|
def read_configs(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
services = parse_services(args.services)
|
services = parse_services(args.services)
|
||||||
client = build_tat_client(args.region)
|
client = build_tat_client(args.region)
|
||||||
@ -272,17 +791,111 @@ def read_configs(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
return payload
|
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:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Read HYApp testbox deployment config via Tencent TAT.")
|
parser = argparse.ArgumentParser(description="Manage HYApp testbox through Tencent TAT.")
|
||||||
parser.add_argument("action", choices=("configs",))
|
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("--env-file", default="", help="optional env file with Tencent Cloud credentials")
|
||||||
parser.add_argument("--region", default=DEFAULT_REGION)
|
parser.add_argument("--region", default=DEFAULT_REGION)
|
||||||
parser.add_argument("--instance-id", default=DEFAULT_INSTANCE_ID)
|
parser.add_argument("--instance-id", default=DEFAULT_INSTANCE_ID)
|
||||||
parser.add_argument("--root", default=DEFAULT_ROOT)
|
parser.add_argument("--root", default=DEFAULT_ROOT)
|
||||||
parser.add_argument("--services", default=",".join(DEFAULT_SERVICES))
|
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("--raw", action="store_true", help="return unredacted YAML")
|
||||||
parser.add_argument("--max-bytes", type=int, default=20_000)
|
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)
|
parser.add_argument("--poll-seconds", type=int, default=2)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
@ -292,7 +905,20 @@ def main() -> int:
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
load_env_file(args.env_file)
|
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))
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
return 0 if payload.get("ok") else 1
|
return 0 if payload.get("ok") else 1
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { AuditPage } from "../pages/audit";
|
|
||||||
import { LogsEventsPage } from "../pages/logs-events";
|
|
||||||
import { OverviewPage } from "../pages/overview";
|
import { OverviewPage } from "../pages/overview";
|
||||||
import { PlaceholderPage } from "../pages/placeholder";
|
|
||||||
import { ReleasesPage } from "../pages/releases";
|
import { ReleasesPage } from "../pages/releases";
|
||||||
import { ResourcesPage } from "../pages/resources";
|
import { ResourcesPage } from "../pages/resources";
|
||||||
import { ServiceDetailPage } from "../pages/service-detail";
|
import { ServiceDetailPage } from "../pages/service-detail";
|
||||||
import { ServicesPage } from "../pages/services";
|
import { ServicesPage } from "../pages/services";
|
||||||
|
import { TestServicesPage } from "../pages/test-services";
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -18,12 +16,9 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "overview", element: <OverviewPage /> },
|
{ path: "overview", element: <OverviewPage /> },
|
||||||
{ path: "services", element: <ServicesPage /> },
|
{ path: "services", element: <ServicesPage /> },
|
||||||
{ path: "services/:serviceId", element: <ServiceDetailPage /> },
|
{ path: "services/:serviceId", element: <ServiceDetailPage /> },
|
||||||
|
{ path: "test-services", element: <TestServicesPage /> },
|
||||||
{ path: "releases", element: <ReleasesPage /> },
|
{ path: "releases", element: <ReleasesPage /> },
|
||||||
{ path: "resources", element: <ResourcesPage /> },
|
{ 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 /> },
|
{ 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 = {
|
export type DeployApiInventoryResponse = {
|
||||||
hosts: Record<string, unknown>;
|
hosts: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
instance_id?: string;
|
||||||
|
private_ip?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
managedDependencies: Record<string, string>;
|
managedDependencies: Record<string, string>;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
region: string;
|
region: string;
|
||||||
registry: 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> {
|
async function readJsonResponse<T>(response: Response): Promise<T> {
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
if (!response.ok) {
|
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}`);
|
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;
|
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> {
|
export async function getDeployApiInventory(): Promise<DeployApiInventoryResponse> {
|
||||||
const response = await fetch("/deploy/api/hyapp/inventory", {
|
const response = await fetch("/deploy/api/hyapp/inventory", {
|
||||||
headers: {
|
headers: {
|
||||||
@ -74,6 +116,24 @@ export type DeployApiDiscoverResponse = {
|
|||||||
host: string;
|
host: string;
|
||||||
output?: string;
|
output?: string;
|
||||||
private_ip: 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;
|
status: string;
|
||||||
}>;
|
}>;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@ -114,8 +174,9 @@ export type DeployApiTestboxConfigResponse = {
|
|||||||
timerState?: string;
|
timerState?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getDeployApiTestboxConfigs(services: string[]): Promise<DeployApiTestboxConfigResponse> {
|
export async function getDeployApiTestboxConfigs(services: string[], raw = false): Promise<DeployApiTestboxConfigResponse> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
raw: raw ? "1" : "0",
|
||||||
services: services.join(","),
|
services: services.join(","),
|
||||||
});
|
});
|
||||||
const response = await fetch(`/deploy/api/hyapp/testbox/configs?${params.toString()}`, {
|
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);
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "../../../shared/api/queryKeys";
|
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;
|
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({
|
return useQuery({
|
||||||
|
enabled: enabled && services.length > 0,
|
||||||
queryFn: () => getDeployApiPlan(services),
|
queryFn: () => getDeployApiPlan(services),
|
||||||
queryKey: queryKeys.deployApiPlan(services),
|
queryKey: queryKeys.deployApiPlan(services),
|
||||||
retry: 0,
|
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({
|
return useQuery({
|
||||||
|
enabled,
|
||||||
queryFn: () => getDeployApiTestboxConfigs(services),
|
queryFn: () => getDeployApiTestboxConfigs(services),
|
||||||
queryKey: queryKeys.deployApiTestboxConfigs(services),
|
queryKey: queryKeys.deployApiTestboxConfigs(services),
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
retry: 0,
|
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 {
|
export {
|
||||||
useDeployApiDiscoverQuery,
|
useDeployApiDiscoverQuery,
|
||||||
useDeployApiInventoryQuery,
|
useDeployApiInventoryQuery,
|
||||||
useDeployApiPlanQuery,
|
useDeployApiPlanQuery,
|
||||||
useDeployApiTestboxConfigsQuery,
|
useDeployApiTestboxConfigsQuery,
|
||||||
|
useDeployApiTestboxMigrationsQuery,
|
||||||
|
useDeployApiTestboxStatusQuery,
|
||||||
} from "./api/useDeployApiQueries";
|
} from "./api/useDeployApiQueries";
|
||||||
|
export {
|
||||||
|
applyDeployApiTestboxMigrations,
|
||||||
|
runDeployApiTestboxServiceAction,
|
||||||
|
runDeployApiTestboxTimerAction,
|
||||||
|
saveDeployApiTestboxConfig,
|
||||||
|
} from "./api/getDeployApiInventory";
|
||||||
export type {
|
export type {
|
||||||
DeployApiDiscoverResponse,
|
DeployApiDiscoverResponse,
|
||||||
DeployApiInventoryResponse,
|
DeployApiInventoryResponse,
|
||||||
DeployApiPlanResponse,
|
DeployApiPlanResponse,
|
||||||
|
DeployApiTestboxCommand,
|
||||||
DeployApiTestboxConfigResponse,
|
DeployApiTestboxConfigResponse,
|
||||||
|
DeployApiTestboxConfigWriteResponse,
|
||||||
|
DeployApiTestboxMigration,
|
||||||
|
DeployApiTestboxMigrationsResponse,
|
||||||
|
DeployApiTestboxServiceActionResponse,
|
||||||
|
DeployApiTestboxStatusResponse,
|
||||||
|
DeployApiTestboxTimerActionResponse,
|
||||||
} from "./api/getDeployApiInventory";
|
} 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 {
|
import type {
|
||||||
Service,
|
Service,
|
||||||
ServiceDependency,
|
ServiceDependency,
|
||||||
@ -14,33 +8,19 @@ import type {
|
|||||||
ServiceMetric,
|
ServiceMetric,
|
||||||
ServiceMetricPoint,
|
ServiceMetricPoint,
|
||||||
} from "../model/types";
|
} from "../model/types";
|
||||||
|
import {
|
||||||
const MOCK_LATENCY_MS = 180;
|
averagePercent,
|
||||||
|
type DeployInventoryResponse,
|
||||||
const baseTimes = [
|
type DeployInventoryService,
|
||||||
"14:00",
|
type DeployRuntimeService,
|
||||||
"14:05",
|
fetchDeployDiscover,
|
||||||
"14:10",
|
fetchDeployInventory,
|
||||||
"14:15",
|
getRuntimeServices,
|
||||||
"14:20",
|
mostFrequent,
|
||||||
"14:25",
|
serviceIdToName,
|
||||||
"14:30",
|
serviceNameToId,
|
||||||
"14:35",
|
statusFromRuntimes,
|
||||||
"14:40",
|
} from "./deployApi";
|
||||||
"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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const dependencyTypeMap: Record<string, ServiceDependency["type"]> = {
|
const dependencyTypeMap: Record<string, ServiceDependency["type"]> = {
|
||||||
mq: "message-queue",
|
mq: "message-queue",
|
||||||
@ -48,320 +28,236 @@ const dependencyTypeMap: Record<string, ServiceDependency["type"]> = {
|
|||||||
redis: "cache",
|
redis: "cache",
|
||||||
};
|
};
|
||||||
|
|
||||||
const dependencies: ServiceDependency[] = hyappDeploymentPlan.managedDependencies.map((dependency) => ({
|
function createCurrentSeries(value: number): ServiceMetricPoint[] {
|
||||||
id: `dep-${dependency.id}`,
|
return [
|
||||||
name: dependency.name,
|
{ time: "当前", value },
|
||||||
status: "healthy",
|
{ time: "当前", value },
|
||||||
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 createMetrics(cpuUsage: number, memoryUsage: number): ServiceMetric[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
color: "blue",
|
color: "blue",
|
||||||
id: "cpu",
|
id: "cpu",
|
||||||
label: "CPU 使用率",
|
label: "CPU 使用率",
|
||||||
max: 100,
|
max: 100,
|
||||||
points: createSeries([
|
points: createCurrentSeries(cpuUsage),
|
||||||
47,
|
|
||||||
41,
|
|
||||||
52,
|
|
||||||
44,
|
|
||||||
49,
|
|
||||||
45,
|
|
||||||
58,
|
|
||||||
42,
|
|
||||||
46,
|
|
||||||
43,
|
|
||||||
48,
|
|
||||||
39,
|
|
||||||
service.cpuUsage,
|
|
||||||
]),
|
|
||||||
unit: "%",
|
unit: "%",
|
||||||
value: `${service.cpuUsage}%`,
|
value: `${cpuUsage}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: "green",
|
color: "green",
|
||||||
id: "memory",
|
id: "memory",
|
||||||
label: "内存使用率",
|
label: "内存使用率",
|
||||||
max: 100,
|
max: 100,
|
||||||
points: createSeries([
|
points: createCurrentSeries(memoryUsage),
|
||||||
55,
|
|
||||||
52,
|
|
||||||
56,
|
|
||||||
51,
|
|
||||||
53,
|
|
||||||
58,
|
|
||||||
59,
|
|
||||||
61,
|
|
||||||
57,
|
|
||||||
55,
|
|
||||||
58,
|
|
||||||
54,
|
|
||||||
service.memoryUsage,
|
|
||||||
]),
|
|
||||||
unit: "%",
|
unit: "%",
|
||||||
value: `${service.memoryUsage}%`,
|
value: `${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",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInstanceConfigItems(
|
function formatRemoteTime(value: string | undefined) {
|
||||||
service: Service,
|
if (!value) {
|
||||||
index: number,
|
return "-";
|
||||||
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}`;
|
|
||||||
|
|
||||||
return [
|
const date = new Date(value);
|
||||||
{
|
|
||||||
description: "当前运行环境",
|
if (Number.isNaN(date.getTime())) {
|
||||||
id: `${service.id}-instance-${instanceIndex}-cfg-env`,
|
return "-";
|
||||||
key: "APP_ENV",
|
}
|
||||||
source: "env",
|
|
||||||
updatedAt: "14:25",
|
return date.toLocaleString("zh-CN", { hour12: false });
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInstances(service: Service): ServiceInstance[] {
|
function eventTime(value: string | undefined) {
|
||||||
const deploymentService = getHyappDeploymentServiceByName(service.name);
|
if (!value) {
|
||||||
const hosts = deploymentService ? getHyappDeploymentHosts(deploymentService) : [];
|
return "-";
|
||||||
const instanceCount = Math.max(hosts.length, service.desiredInstances);
|
}
|
||||||
|
|
||||||
return Array.from({ length: instanceCount }, (_, index) => {
|
const date = new Date(value);
|
||||||
const isReady = index < service.readyInstances;
|
|
||||||
const host = hosts[index];
|
if (Number.isNaN(date.getTime())) {
|
||||||
const node = host?.id ?? "local-compose";
|
return "-";
|
||||||
const privateIp = host?.privateIp ?? `127.0.0.${index + 1}`;
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
configItems: createInstanceConfigItems(service, index, host),
|
id: `dep-${id}`,
|
||||||
id: `${service.id}-instance-${index + 1}`,
|
name,
|
||||||
image: `${hyappDeploymentPlan.registry}/${service.name}:${service.version}`,
|
status: "unknown",
|
||||||
name: `${service.name}@${node}`,
|
type: dependencyTypeMap[id] ?? "storage",
|
||||||
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",
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEvents(service: Service): ServiceEvent[] {
|
function createConfigItems(service: Service, runtime: DeployRuntimeService): ServiceInstanceConfigItem[] {
|
||||||
return [
|
const systemItems: ServiceInstanceConfigItem[] = [
|
||||||
{
|
{
|
||||||
id: `${service.id}-event-1`,
|
description: "生产配置文件",
|
||||||
message: `${service.readyInstances} 个实例保持健康`,
|
id: `${service.id}-${runtime.host}-config-path`,
|
||||||
time: "14:39",
|
key: "CONFIG_PATH",
|
||||||
title: "健康检查",
|
source: "system",
|
||||||
|
updatedAt: eventTime(runtime.startedAt),
|
||||||
|
value: runtime.configPath ?? "未采集",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `${service.id}-event-2`,
|
description: "当前运行镜像",
|
||||||
message: "CLB 权重已恢复",
|
id: `${service.id}-${runtime.host}-image`,
|
||||||
time: "14:25",
|
key: "IMAGE",
|
||||||
title: "恢复接流",
|
source: "system",
|
||||||
|
updatedAt: eventTime(runtime.startedAt),
|
||||||
|
value: runtime.image || "未采集",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `${service.id}-event-3`,
|
description: "systemd unit",
|
||||||
message: `${service.version} 已发布成功`,
|
id: `${service.id}-${runtime.host}-unit`,
|
||||||
time: "14:18",
|
key: "UNIT",
|
||||||
title: "发布成功",
|
source: "system",
|
||||||
|
updatedAt: eventTime(runtime.startedAt),
|
||||||
|
value: runtime.unit || "未采集",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `${service.id}-event-4`,
|
description: "服务端口",
|
||||||
message: `${service.version} 正在发布`,
|
id: `${service.id}-${runtime.host}-target-port`,
|
||||||
time: "14:15",
|
key: "TARGET_PORT",
|
||||||
title: "开始发布",
|
source: "system",
|
||||||
},
|
updatedAt: eventTime(runtime.startedAt),
|
||||||
{
|
value: String(runtime.targetPort ?? service.targetPort ?? "未采集"),
|
||||||
id: `${service.id}-event-5`,
|
|
||||||
message: `${service.name} systemd unit 已启动`,
|
|
||||||
time: "14:00",
|
|
||||||
title: "实例启动",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
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 {
|
return {
|
||||||
...service,
|
configPath: firstRuntime?.configPath ?? deployService.config_path,
|
||||||
createdAt: "2024-08-12 10:30",
|
configYaml: firstRuntime?.configYaml,
|
||||||
dependencies,
|
configYamlTruncated: firstRuntime?.configTruncated,
|
||||||
errorRate:
|
container: deployService.container ?? firstRuntime?.container,
|
||||||
service.status === "critical"
|
cpuUsage: averagePercent(runtimes.map((runtime) => runtime.cpuPercent)),
|
||||||
? "3.8%"
|
desiredInstances,
|
||||||
: service.status === "warning"
|
id: serviceNameToId(serviceName),
|
||||||
? "1.2%"
|
lastReleaseText: mostFrequent(runtimes.map((runtime) => runtime.runningTime))
|
||||||
: "0.23%",
|
? `运行 ${mostFrequent(runtimes.map((runtime) => runtime.runningTime))}`
|
||||||
events: createEvents(service),
|
: "未采集",
|
||||||
instances: createInstances(service),
|
memoryUsage: averagePercent(runtimes.map((runtime) => runtime.memoryPercent)),
|
||||||
metrics: createMetrics(service),
|
name: serviceName,
|
||||||
qps:
|
namespace: params.environment,
|
||||||
service.status === "critical"
|
owner: deployService.owner ?? "未配置",
|
||||||
? "760"
|
readyInstances,
|
||||||
: service.status === "warning"
|
sourceHosts: runtimes.map((runtime) => runtime.host),
|
||||||
? "480"
|
status: statusFromRuntimes(runtimes, desiredInstances),
|
||||||
: "326.8",
|
targetPort: deployService.target_port ?? firstRuntime?.targetPort,
|
||||||
runningTime: "7 天 12 小时",
|
unit: deployService.unit ?? firstRuntime?.unit,
|
||||||
|
version: mostFrequent(runtimes.map((runtime) => runtime.imageTag || runtime.image)) || "未采集",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServiceDetail(
|
export async function getServiceDetail(serviceId: string, environment = "prod"): Promise<ServiceDetail | null> {
|
||||||
serviceId: string,
|
const inventory = await fetchDeployInventory();
|
||||||
): Promise<ServiceDetail | null> {
|
const serviceName = serviceIdToName(serviceId, inventory);
|
||||||
await new Promise((resolve) =>
|
|
||||||
globalThis.setTimeout(resolve, MOCK_LATENCY_MS),
|
|
||||||
);
|
|
||||||
|
|
||||||
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 type { Service, ServiceListParams, ServiceListResponse } from "../model/types";
|
||||||
import { 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 = {
|
return {
|
||||||
hosts?: string[];
|
configPath: firstRuntime?.configPath ?? deployService.config_path,
|
||||||
target_port?: number;
|
configYaml: firstRuntime?.configYaml,
|
||||||
};
|
configYamlTruncated: firstRuntime?.configTruncated,
|
||||||
|
container: deployService.container ?? firstRuntime?.container,
|
||||||
type DeployInventoryResponse = {
|
cpuUsage: averagePercent(runtimes.map((runtime) => runtime.cpuPercent)),
|
||||||
ok?: boolean;
|
desiredInstances,
|
||||||
services?: Record<string, DeployInventoryService>;
|
id: serviceNameToId(serviceName),
|
||||||
};
|
lastReleaseText: runningTime ? `运行 ${runningTime}` : "未采集",
|
||||||
|
memoryUsage: averagePercent(runtimes.map((runtime) => runtime.memoryPercent)),
|
||||||
async function getServicesFromDeployApi(params: ServiceListParams) {
|
name: serviceName,
|
||||||
const response = await fetch("/deploy/api/hyapp/inventory", {
|
namespace: params.environment,
|
||||||
headers: {
|
owner: deployService.owner ?? "未配置",
|
||||||
accept: "application/json",
|
readyInstances,
|
||||||
},
|
sourceHosts: runtimes.map((runtime) => runtime.host),
|
||||||
});
|
status: statusFromRuntimes(runtimes, desiredInstances),
|
||||||
|
targetPort: deployService.target_port ?? firstRuntime?.targetPort,
|
||||||
if (!response.ok) {
|
unit: deployService.unit ?? firstRuntime?.unit,
|
||||||
throw new Error(`Deploy API HTTP ${response.status}`);
|
version,
|
||||||
}
|
};
|
||||||
|
|
||||||
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",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServices(params: ServiceListParams): Promise<ServiceListResponse> {
|
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;
|
return createServiceFromRuntime(params, serviceName, deployService, serviceRuntimes);
|
||||||
|
});
|
||||||
try {
|
|
||||||
items = await getServicesFromDeployApi(params);
|
|
||||||
} catch {
|
|
||||||
items = mockServices.filter((service) => service.namespace === params.environment);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
|
|||||||
@ -4,11 +4,11 @@ import { getServiceDetail } from "./getServiceDetail";
|
|||||||
|
|
||||||
const STATUS_REFRESH_INTERVAL_MS = 15_000;
|
const STATUS_REFRESH_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
export function useServiceDetailQuery(serviceId: string) {
|
export function useServiceDetailQuery(serviceId: string, environment = "prod") {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
enabled: serviceId.length > 0,
|
enabled: serviceId.length > 0,
|
||||||
queryFn: () => getServiceDetail(serviceId),
|
queryFn: () => getServiceDetail(serviceId, environment),
|
||||||
queryKey: queryKeys.serviceDetail(serviceId),
|
queryKey: queryKeys.serviceDetail(serviceId, environment),
|
||||||
refetchInterval: serviceId.length > 0 ? STATUS_REFRESH_INTERVAL_MS : false,
|
refetchInterval: serviceId.length > 0 ? STATUS_REFRESH_INTERVAL_MS : false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ export { getServices } from "./api/getServices";
|
|||||||
export { useServiceDetailQuery } from "./api/useServiceDetailQuery";
|
export { useServiceDetailQuery } from "./api/useServiceDetailQuery";
|
||||||
export { useServicesQuery } from "./api/useServicesQuery";
|
export { useServicesQuery } from "./api/useServicesQuery";
|
||||||
export { getServiceSummary } from "./lib/getServiceSummary";
|
export { getServiceSummary } from "./lib/getServiceSummary";
|
||||||
export { mockServices } from "./mock/services";
|
|
||||||
export type {
|
export type {
|
||||||
Service,
|
Service,
|
||||||
ServiceDependency,
|
ServiceDependency,
|
||||||
|
|||||||
@ -2,30 +2,17 @@ import { Service } from "../model/types";
|
|||||||
|
|
||||||
export type ServiceSummary = {
|
export type ServiceSummary = {
|
||||||
abnormalCount: number;
|
abnormalCount: number;
|
||||||
activeAlerts: number;
|
|
||||||
databaseHealthyText: string;
|
|
||||||
healthyCount: number;
|
healthyCount: number;
|
||||||
latestRelease: {
|
totalCount: number;
|
||||||
serviceName: string;
|
|
||||||
version: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getServiceSummary(services: Service[]): ServiceSummary {
|
export function getServiceSummary(services: Service[]): ServiceSummary {
|
||||||
const healthyCount = services.filter((service) => service.status === "healthy").length;
|
const healthyCount = services.filter((service) => service.status === "healthy").length;
|
||||||
const abnormalCount = services.filter((service) => service.status === "warning" || service.status === "critical").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 {
|
return {
|
||||||
abnormalCount,
|
abnormalCount,
|
||||||
activeAlerts: criticalCount * 3 + warningCount,
|
|
||||||
databaseHealthyText: "3/3",
|
|
||||||
healthyCount,
|
healthyCount,
|
||||||
latestRelease: {
|
totalCount: services.length,
|
||||||
serviceName: latestRelease?.name ?? "-",
|
|
||||||
version: latestRelease?.version ?? "-",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ServiceStatus = StatusBadgeStatus;
|
||||||
|
|
||||||
export type Service = {
|
export type Service = {
|
||||||
|
configPath?: string | undefined;
|
||||||
|
configYaml?: string | undefined;
|
||||||
|
configYamlTruncated?: boolean | undefined;
|
||||||
|
container?: string | undefined;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
@ -14,6 +18,9 @@ export type Service = {
|
|||||||
memoryUsage: number;
|
memoryUsage: number;
|
||||||
lastReleaseText: string;
|
lastReleaseText: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
|
sourceHosts?: string[] | undefined;
|
||||||
|
targetPort?: number | undefined;
|
||||||
|
unit?: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceDependency = {
|
export type ServiceDependency = {
|
||||||
@ -49,8 +56,18 @@ export type ServiceInstanceConfigItem = {
|
|||||||
|
|
||||||
export type ServiceInstance = {
|
export type ServiceInstance = {
|
||||||
configItems: ServiceInstanceConfigItem[];
|
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;
|
id: string;
|
||||||
image: string;
|
image: string;
|
||||||
|
logs?: string | undefined;
|
||||||
|
memoryUsage?: number | null | undefined;
|
||||||
|
memoryUsageText?: string | undefined;
|
||||||
name: string;
|
name: string;
|
||||||
podIp: string;
|
podIp: string;
|
||||||
node: string;
|
node: string;
|
||||||
|
|||||||
@ -1,26 +1,18 @@
|
|||||||
import { mockServices } from "../../../entities/service";
|
import type { CreateReleaseRequest, CreateReleaseResult } from "../model/types";
|
||||||
import { CreateReleaseRequest, CreateReleaseResult } from "../model/types";
|
|
||||||
|
|
||||||
type DeployApiResult = {
|
type DeployApiResult = {
|
||||||
|
error?: string;
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
steps?: unknown[];
|
steps?: unknown[];
|
||||||
error?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createRelease(request: CreateReleaseRequest): Promise<CreateReleaseResult> {
|
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 shouldExecute = request.environment === "prod";
|
||||||
const response = await fetch("/deploy/api/hyapp/deploy", {
|
const response = await fetch("/deploy/api/hyapp/deploy", {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
confirmed: shouldExecute ? request.confirmed === true : true,
|
confirmed: shouldExecute ? request.confirmed === true : true,
|
||||||
dryRun: !shouldExecute,
|
dryRun: !shouldExecute,
|
||||||
services: [service.name],
|
services: [request.serviceName],
|
||||||
tag: request.targetVersion,
|
tag: request.targetVersion,
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
@ -34,16 +26,11 @@ export async function createRelease(request: CreateReleaseRequest): Promise<Crea
|
|||||||
throw new Error(result.error || `部署接口返回 HTTP ${response.status}`);
|
throw new Error(result.error || `部署接口返回 HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
service.version = request.targetVersion;
|
|
||||||
service.status = "healthy";
|
|
||||||
service.readyInstances = service.desiredInstances;
|
|
||||||
service.lastReleaseText = "刚刚";
|
|
||||||
|
|
||||||
const releaseResult: CreateReleaseResult = {
|
const releaseResult: CreateReleaseResult = {
|
||||||
id: `release-${Date.now()}`,
|
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
fromVersion,
|
fromVersion: request.fromVersion,
|
||||||
serviceId: service.id,
|
id: `release-${Date.now()}`,
|
||||||
|
serviceId: request.serviceId,
|
||||||
status: "success",
|
status: "success",
|
||||||
targetVersion: request.targetVersion,
|
targetVersion: request.targetVersion,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,45 +1,34 @@
|
|||||||
import { Service } from "../../../entities/service";
|
import type { Service } from "../../../entities/service";
|
||||||
import { getHyappDeploymentServiceById, hyappDeploymentPlan } from "../../../entities/deployment-plan";
|
import type { ReleasePrecheck } from "../model/types";
|
||||||
import { ReleasePrecheck } from "../model/types";
|
|
||||||
|
|
||||||
export function getReleasePrechecks(service: Service, environment: string): ReleasePrecheck[] {
|
export function getReleasePrechecks(service: Service, environment: string): ReleasePrecheck[] {
|
||||||
const deploymentService = getHyappDeploymentServiceById(service.id);
|
|
||||||
const isProd = environment === "prod";
|
const isProd = environment === "prod";
|
||||||
const hasProdTarget = Boolean(deploymentService?.prod);
|
const hasRuntimeTarget = Boolean(service.unit && service.container && service.targetPort);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
detail: `${service.readyInstances}/${service.desiredInstances} 可用`,
|
||||||
id: "instances",
|
id: "instances",
|
||||||
label: "实例状态",
|
label: "实例状态",
|
||||||
result: service.readyInstances === service.desiredInstances ? "passed" : "warning",
|
result: service.readyInstances === service.desiredInstances ? "passed" : "warning",
|
||||||
detail: `${service.readyInstances}/${service.desiredInstances} 可用`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
detail: hasRuntimeTarget ? `${service.unit} / ${service.container}` : "TAT 未返回部署目标",
|
||||||
id: "deploy-target",
|
id: "deploy-target",
|
||||||
label: "部署目标",
|
label: "部署目标",
|
||||||
result: !isProd || hasProdTarget ? "passed" : "warning",
|
result: !isProd || hasRuntimeTarget ? "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("、") + " 不随发布重启",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
detail: service.configPath ?? "TAT 未返回配置路径",
|
||||||
id: "config",
|
id: "config",
|
||||||
label: "配置变更",
|
label: "配置",
|
||||||
result: "passed",
|
result: service.configPath ? "passed" : "warning",
|
||||||
detail: deploymentService?.prod?.configPath ?? deploymentService?.local.configPath ?? "按服务配置模板校验",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
detail: `${service.name}:${service.version}`,
|
||||||
id: "image",
|
id: "image",
|
||||||
label: "镜像拉取",
|
label: "镜像",
|
||||||
result: "passed",
|
result: service.version && service.version !== "未采集" ? "passed" : "warning",
|
||||||
detail: `${hyappDeploymentPlan.registry}/${service.name}:${service.version}`,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = {
|
export type CreateReleaseRequest = {
|
||||||
confirmed?: boolean;
|
confirmed?: boolean;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
fromVersion: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
strategy: ReleaseStrategy;
|
strategy: ReleaseStrategy;
|
||||||
targetVersion: string;
|
targetVersion: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -207,6 +207,30 @@
|
|||||||
line-height: 18px;
|
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,
|
.strategyList,
|
||||||
.precheckList,
|
.precheckList,
|
||||||
.summary {
|
.summary {
|
||||||
|
|||||||
@ -1,21 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { AlertTriangle, CheckCircle2, Loader2, Rocket, X } from "lucide-react";
|
import { AlertTriangle, CheckCircle2, Loader2, Rocket, X } from "lucide-react";
|
||||||
import { Environment } from "../../../app/store/useAppContextStore";
|
import { Environment } from "../../../app/store/useAppContextStore";
|
||||||
import {
|
|
||||||
getHyappDeploymentHosts,
|
|
||||||
getHyappDeploymentServiceById,
|
|
||||||
hyappDeploymentPlan,
|
|
||||||
} from "../../../entities/deployment-plan";
|
|
||||||
import { Service } from "../../../entities/service";
|
import { Service } from "../../../entities/service";
|
||||||
import { Button } from "../../../shared/ui/Button";
|
import { Button } from "../../../shared/ui/Button";
|
||||||
import { Checkbox } from "../../../shared/ui/Checkbox";
|
import { Checkbox } from "../../../shared/ui/Checkbox";
|
||||||
import { IconButton } from "../../../shared/ui/IconButton";
|
import { IconButton } from "../../../shared/ui/IconButton";
|
||||||
import { Radio } from "../../../shared/ui/Radio";
|
import { Radio } from "../../../shared/ui/Radio";
|
||||||
import { Select } from "../../../shared/ui/Select";
|
|
||||||
import { Tag } from "../../../shared/ui/Tag";
|
import { Tag } from "../../../shared/ui/Tag";
|
||||||
import { useCreateReleaseMutation } from "../api/useCreateReleaseMutation";
|
import { useCreateReleaseMutation } from "../api/useCreateReleaseMutation";
|
||||||
import { getReleasePrechecks } from "../lib/getReleasePrechecks";
|
import { getReleasePrechecks } from "../lib/getReleasePrechecks";
|
||||||
import { getReleaseVersionOptions } from "../lib/getReleaseVersionOptions";
|
|
||||||
import { ReleaseStrategy } from "../model/types";
|
import { ReleaseStrategy } from "../model/types";
|
||||||
import styles from "./ReleaseDrawer.module.css";
|
import styles from "./ReleaseDrawer.module.css";
|
||||||
|
|
||||||
@ -45,10 +38,7 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user
|
|||||||
const [confirmed, setConfirmed] = useState(false);
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
const releaseMutation = useCreateReleaseMutation();
|
const releaseMutation = useCreateReleaseMutation();
|
||||||
|
|
||||||
const versionOptions = useMemo(() => (service ? getReleaseVersionOptions(service.version) : []), [service]);
|
|
||||||
const prechecks = useMemo(() => (service ? getReleasePrechecks(service, environment) : []), [environment, 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(() => {
|
useEffect(() => {
|
||||||
if (!service) {
|
if (!service) {
|
||||||
@ -56,34 +46,28 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
setStrategy("rolling");
|
setStrategy("rolling");
|
||||||
setTargetVersion(versionOptions[0]?.value ?? service.version);
|
setTargetVersion("");
|
||||||
setConfirmed(false);
|
setConfirmed(false);
|
||||||
releaseMutation.reset();
|
releaseMutation.reset();
|
||||||
}, [releaseMutation, service, versionOptions]);
|
}, [releaseMutation, service]);
|
||||||
|
|
||||||
if (!service) {
|
if (!service) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isProd = environment === "prod";
|
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 canSubmit = targetVersion !== "" && targetVersion !== service.version && hasExecutableTarget && (!isProd || confirmed);
|
||||||
const hasWarning = prechecks.some((item) => item.result === "warning");
|
const hasWarning = prechecks.some((item) => item.result === "warning");
|
||||||
const deployCommand =
|
const deployCommand = [
|
||||||
isProd && deployTarget?.prod
|
"python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy",
|
||||||
? [
|
" --env-file /opt/hy-app-monitor/.env",
|
||||||
"python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy",
|
" --inventory deploy/tencent-tat/hyapp-services.inventory.prod.json",
|
||||||
" --env-file /opt/hy-app-monitor/.env",
|
` --services ${service.name}`,
|
||||||
" --inventory deploy/tencent-tat/hyapp-services.inventory.prod.json",
|
` --tag ${targetVersion || "<tag>"}`,
|
||||||
` --services ${service.name}`,
|
isProd ? " --yes" : " --dry-run",
|
||||||
` --tag ${targetVersion}`,
|
].join("\n");
|
||||||
" --yes",
|
const targetSummary = service.unit && service.container ? `${service.unit} / ${service.container}` : service.name;
|
||||||
].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;
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!service || !canSubmit) {
|
if (!service || !canSubmit) {
|
||||||
@ -95,6 +79,8 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user
|
|||||||
confirmed,
|
confirmed,
|
||||||
operator: userName,
|
operator: userName,
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
|
serviceName: service.name,
|
||||||
|
fromVersion: service.version,
|
||||||
strategy,
|
strategy,
|
||||||
targetVersion,
|
targetVersion,
|
||||||
});
|
});
|
||||||
@ -128,7 +114,13 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user
|
|||||||
<span className={styles.step}>1</span>
|
<span className={styles.step}>1</span>
|
||||||
<div className={styles.sectionContent}>
|
<div className={styles.sectionContent}>
|
||||||
<h3>选择目标版本</h3>
|
<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>
|
<p>
|
||||||
当前版本 <Tag tone="brand">{service.version}</Tag>
|
当前版本 <Tag tone="brand">{service.version}</Tag>
|
||||||
</p>
|
</p>
|
||||||
@ -166,9 +158,7 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user
|
|||||||
<span>
|
<span>
|
||||||
<strong>实例</strong>
|
<strong>实例</strong>
|
||||||
<small>
|
<small>
|
||||||
{deployHosts.length > 0
|
{service.sourceHosts?.length ? service.sourceHosts.join("、") : "TAT 未返回"}
|
||||||
? deployHosts.map((host) => host.id).join("、")
|
|
||||||
: "local-compose"}
|
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 { useNavigate } from "react-router-dom";
|
||||||
import { getServiceSummary, useServicesQuery } from "../../entities/service";
|
import { getServiceSummary, useServicesQuery } from "../../entities/service";
|
||||||
import { useAppContextStore } from "../../app/store/useAppContextStore";
|
import { useAppContextStore } from "../../app/store/useAppContextStore";
|
||||||
|
import { useDataResourcesQuery } from "../../entities/data-resource";
|
||||||
import { useReleaseDrawerStore } from "../../features/release-service";
|
import { useReleaseDrawerStore } from "../../features/release-service";
|
||||||
import { Card } from "../../shared/ui/Card";
|
import { Card } from "../../shared/ui/Card";
|
||||||
import { MetricCard } from "../../shared/ui/MetricCard";
|
import { MetricCard } from "../../shared/ui/MetricCard";
|
||||||
@ -18,8 +19,13 @@ export function OverviewPage() {
|
|||||||
environment,
|
environment,
|
||||||
projectId,
|
projectId,
|
||||||
});
|
});
|
||||||
|
const dataResourcesQuery = useDataResourcesQuery();
|
||||||
const services = servicesQuery.data?.items ?? [];
|
const services = servicesQuery.data?.items ?? [];
|
||||||
const summary = getServiceSummary(services);
|
const summary = getServiceSummary(services);
|
||||||
|
const dataResourceSummary = dataResourcesQuery.data?.summary;
|
||||||
|
const dataResourceHealthyText = dataResourceSummary
|
||||||
|
? `${dataResourceSummary.healthy}/${Object.values(dataResourceSummary).reduce((total, value) => total + value, 0)}`
|
||||||
|
: "-";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@ -35,8 +41,14 @@ export function OverviewPage() {
|
|||||||
<section className={styles.metrics}>
|
<section className={styles.metrics}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
icon={<Layers3 size={24} />}
|
icon={<Layers3 size={24} />}
|
||||||
label="运行中服务数"
|
label="服务总数"
|
||||||
meta="Healthy"
|
meta="Inventory"
|
||||||
|
value={summary.totalCount}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
icon={<Server size={24} />}
|
||||||
|
label="健康服务数"
|
||||||
|
meta="TAT"
|
||||||
tone="green"
|
tone="green"
|
||||||
value={summary.healthyCount}
|
value={summary.healthyCount}
|
||||||
/>
|
/>
|
||||||
@ -47,25 +59,12 @@ export function OverviewPage() {
|
|||||||
tone="orange"
|
tone="orange"
|
||||||
value={summary.abnormalCount}
|
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
|
<MetricCard
|
||||||
icon={<Database size={24} />}
|
icon={<Database size={24} />}
|
||||||
label="数据库状态"
|
label="数据资源"
|
||||||
meta="正常资源"
|
meta="健康 / 总数"
|
||||||
tone="green"
|
tone="green"
|
||||||
value={summary.databaseHealthyText}
|
value={dataResourceHealthyText}
|
||||||
/>
|
/>
|
||||||
</section>
|
</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 {
|
import {
|
||||||
CloudCog,
|
type DeployApiDiscoverResponse,
|
||||||
Database,
|
type DeployApiInventoryResponse,
|
||||||
FileText,
|
|
||||||
GitBranch,
|
|
||||||
HardDrive,
|
|
||||||
Network,
|
|
||||||
PlayCircle,
|
|
||||||
ServerCog,
|
|
||||||
ShieldAlert,
|
|
||||||
TerminalSquare,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
getHyappDeploymentHosts,
|
|
||||||
hyappDeploymentPlan,
|
|
||||||
type HyappDeploymentService,
|
|
||||||
useDeployApiDiscoverQuery,
|
useDeployApiDiscoverQuery,
|
||||||
useDeployApiInventoryQuery,
|
useDeployApiInventoryQuery,
|
||||||
useDeployApiPlanQuery,
|
useDeployApiPlanQuery,
|
||||||
useDeployApiTestboxConfigsQuery,
|
useDeployApiTestboxConfigsQuery,
|
||||||
} from "../../entities/deployment-plan";
|
} from "../../entities/deployment-plan";
|
||||||
import { mockServices } from "../../entities/service";
|
import type { Service, ServiceStatus } from "../../entities/service";
|
||||||
import { useReleaseDrawerStore } from "../../features/release-service";
|
import { useReleaseDrawerStore } from "../../features/release-service";
|
||||||
import { Button } from "../../shared/ui/Button";
|
import { Button } from "../../shared/ui/Button";
|
||||||
import { Card } from "../../shared/ui/Card";
|
import { Card } from "../../shared/ui/Card";
|
||||||
|
import { EmptyState } from "../../shared/ui/EmptyState";
|
||||||
import { StatusBadge } from "../../shared/ui/StatusBadge";
|
import { StatusBadge } from "../../shared/ui/StatusBadge";
|
||||||
import { Table, type TableColumn } from "../../shared/ui/Table";
|
import { Table, type TableColumn } from "../../shared/ui/Table";
|
||||||
import { Tag } from "../../shared/ui/Tag";
|
import { Tag } from "../../shared/ui/Tag";
|
||||||
import styles from "./ReleasesPage.module.css";
|
import styles from "./ReleasesPage.module.css";
|
||||||
|
|
||||||
const remoteDiscoverServices = hyappDeploymentPlan.services.filter((service) => service.prod).map((service) => service.name);
|
type RuntimeService = NonNullable<NonNullable<DeployApiDiscoverResponse["hosts"]>[number]["services"]>[number] & {
|
||||||
const testboxConfigServices = [
|
host: string;
|
||||||
"gateway-service",
|
privateIp: string;
|
||||||
"room-service",
|
};
|
||||||
"wallet-service",
|
|
||||||
"user-service",
|
type ServiceRow = {
|
||||||
"activity-service",
|
clb: DeployApiInventoryResponse["services"][string]["clb"];
|
||||||
"cron-service",
|
configPath: string;
|
||||||
"game-service",
|
container: string;
|
||||||
"notice-service",
|
hosts: string[];
|
||||||
"admin-server",
|
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() {
|
export function ReleasesPage() {
|
||||||
const openRelease = useReleaseDrawerStore((state) => state.openRelease);
|
const openRelease = useReleaseDrawerStore((state) => state.openRelease);
|
||||||
const apiInventoryQuery = useDeployApiInventoryQuery();
|
const apiInventoryQuery = useDeployApiInventoryQuery();
|
||||||
const apiPlanQuery = useDeployApiPlanQuery(["gateway-service", "room-service", "user-service"]);
|
const serviceNames = Object.keys(apiInventoryQuery.data?.services ?? {});
|
||||||
const apiDiscoverQuery = useDeployApiDiscoverQuery(remoteDiscoverServices, true);
|
const apiPlanQuery = useDeployApiPlanQuery(serviceNames, serviceNames.length > 0);
|
||||||
const testboxConfigQuery = useDeployApiTestboxConfigsQuery(testboxConfigServices);
|
const apiDiscoverQuery = useDeployApiDiscoverQuery(serviceNames, serviceNames.length > 0);
|
||||||
const apiStatusTone = apiInventoryQuery.isSuccess ? "success" : apiInventoryQuery.isError ? "warning" : "running";
|
const testboxConfigQuery = useDeployApiTestboxConfigsQuery(serviceNames, serviceNames.length > 0);
|
||||||
const apiStatusText = apiInventoryQuery.isSuccess ? "已连接" : apiInventoryQuery.isError ? "未连接" : "查询中";
|
const rows = buildRows(apiInventoryQuery.data, apiDiscoverQuery.data);
|
||||||
const adminProd = hyappDeploymentPlan.adminProduction;
|
|
||||||
const testbox = hyappDeploymentPlan.testbox;
|
|
||||||
const remoteHosts = apiDiscoverQuery.data?.hosts ?? [];
|
const remoteHosts = apiDiscoverQuery.data?.hosts ?? [];
|
||||||
const remoteClbSnapshots = apiDiscoverQuery.data?.clb ?? [];
|
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 remoteHostSuccessCount = remoteHosts.filter((host) => host.status === "SUCCESS").length;
|
||||||
const remoteClbSuccessCount = remoteClbSnapshots.filter((snapshot) => !snapshot.error).length;
|
const remoteClbSuccessCount = remoteClbSnapshots.filter((snapshot) => !snapshot.error).length;
|
||||||
const remoteErrorCount =
|
const remoteErrorCount =
|
||||||
remoteHosts.filter((host) => host.status !== "SUCCESS").length + remoteClbSnapshots.filter((snapshot) => snapshot.error).length;
|
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
|
const remoteStatusTone = apiDiscoverQuery.isFetching && !apiDiscoverQuery.data
|
||||||
? "running"
|
? "running"
|
||||||
: apiDiscoverQuery.isError || remoteErrorCount > 0
|
: apiDiscoverQuery.isError || remoteErrorCount > 0
|
||||||
@ -76,95 +178,80 @@ export function ReleasesPage() {
|
|||||||
: apiDiscoverQuery.isSuccess
|
: 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 testboxConfigTone = testboxConfigQuery.isSuccess ? "success" : testboxConfigQuery.isError ? "warning" : "running";
|
||||||
const testboxConfigText = testboxConfigQuery.isSuccess ? "已读取" : testboxConfigQuery.isError ? "读取失败" : "读取中";
|
const testboxConfigText = testboxConfigQuery.isSuccess ? "已读取" : testboxConfigQuery.isError ? "读取失败" : "读取中";
|
||||||
const handleTestboxConfigRefresh = () => {
|
const serviceColumns: TableColumn<ServiceRow>[] = [
|
||||||
void testboxConfigQuery.refetch();
|
|
||||||
};
|
|
||||||
const serviceColumns: TableColumn<HyappDeploymentService>[] = [
|
|
||||||
{
|
{
|
||||||
key: "service",
|
key: "service",
|
||||||
title: "服务",
|
title: "服务",
|
||||||
render: (service) => (
|
render: (row) => (
|
||||||
<span className={styles.serviceCell}>
|
<span className={styles.serviceCell}>
|
||||||
<strong>{service.name}</strong>
|
<strong>{row.service.name}</strong>
|
||||||
<small>{service.domain}</small>
|
<small>{row.configPath || "TAT 未返回配置路径"}</small>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
title: "状态",
|
title: "状态",
|
||||||
render: (service) => <StatusBadge status={service.status} />,
|
render: (row) => <StatusBadge status={row.service.status} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "local",
|
key: "version",
|
||||||
title: "本地 Compose",
|
title: "镜像",
|
||||||
render: (service) => (
|
render: (row) => (
|
||||||
<span className={styles.inlineTags}>
|
<span className={styles.stackText}>
|
||||||
<Tag tone="brand">{service.local.composeService}</Tag>
|
<strong>{row.service.version}</strong>
|
||||||
<Tag>{service.local.aliases[0]}</Tag>
|
<small>{row.image || "TAT 未返回镜像"}</small>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "prod",
|
key: "instances",
|
||||||
title: "生产目标",
|
title: "实例",
|
||||||
render: (service) =>
|
render: (row) => `${row.service.readyInstances}/${row.service.desiredInstances}`,
|
||||||
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: "unit",
|
key: "target",
|
||||||
title: "Unit / 端口",
|
title: "Unit / 端口",
|
||||||
render: (service) => (
|
render: (row) => (
|
||||||
<span className={styles.stackText}>
|
<span className={styles.stackText}>
|
||||||
<strong>{service.prod?.unit ?? service.local.composeService}</strong>
|
<strong>{row.unit || "TAT 未返回"}</strong>
|
||||||
<small>{service.prod?.targetPort ?? service.local.grpcPort ?? service.local.httpPort}</small>
|
<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>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "clb",
|
key: "clb",
|
||||||
title: "CLB",
|
title: "CLB",
|
||||||
render: (service) =>
|
render: (row) =>
|
||||||
service.prod ? (
|
row.clb?.enabled ? (
|
||||||
<span className={styles.stackText}>
|
<span className={styles.stackText}>
|
||||||
<strong>{service.prod.clb.loadBalancerId}</strong>
|
<strong>{row.clb.load_balancer_id ?? "TAT 未返回"}</strong>
|
||||||
<small>{service.prod.clb.entrypoint}</small>
|
<small>{row.clb.listener_ids?.join("、") || "TAT 未返回 listener"}</small>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted">local only</span>
|
<span className="text-muted">未启用</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
className: styles.actions,
|
||||||
key: "action",
|
key: "action",
|
||||||
title: "操作",
|
title: "操作",
|
||||||
className: styles.actions,
|
render: (row) => (
|
||||||
render: (service) => {
|
<Button disabled={row.service.status === "unknown"} onClick={() => openRelease(row.service)} size="sm" variant="secondary">
|
||||||
const serviceEntity = mockServices.find((item) => item.id === service.id);
|
部署
|
||||||
|
</Button>
|
||||||
return (
|
),
|
||||||
<Button disabled={!serviceEntity} onClick={() => serviceEntity && openRelease(serviceEntity)} size="sm" variant="secondary">
|
|
||||||
部署
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -172,30 +259,18 @@ export function ReleasesPage() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">HyApp 部署方案</h1>
|
<h1 className="page-title">发布</h1>
|
||||||
<p className="page-subtitle">
|
|
||||||
接入 {hyappDeploymentPlan.repositoryPath} 的 Compose、systemd 和 Tencent TAT 发布边界
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className={styles.heroGrid}>
|
<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}>
|
<Card className={styles.heroCard} padded={false}>
|
||||||
<span className={styles.heroIcon}>
|
<span className={styles.heroIcon}>
|
||||||
<CloudCog size={22} />
|
<CloudCog size={22} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span>生产区域</span>
|
<span>区域</span>
|
||||||
<strong>{hyappDeploymentPlan.region}</strong>
|
<strong>{apiInventoryQuery.data?.region ?? "-"}</strong>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className={styles.heroCard} padded={false}>
|
<Card className={styles.heroCard} padded={false}>
|
||||||
@ -204,7 +279,7 @@ export function ReleasesPage() {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span>镜像仓库</span>
|
<span>镜像仓库</span>
|
||||||
<strong>{hyappDeploymentPlan.registry}</strong>
|
<strong>{apiInventoryQuery.data?.registry ?? "-"}</strong>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className={styles.heroCard} padded={false}>
|
<Card className={styles.heroCard} padded={false}>
|
||||||
@ -212,245 +287,62 @@ export function ReleasesPage() {
|
|||||||
<ServerCog size={22} />
|
<ServerCog size={22} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span>发布控制面</span>
|
<span>服务数</span>
|
||||||
<strong>
|
<strong>{serviceNames.length}</strong>
|
||||||
{hyappDeploymentPlan.deployServer.id} / {hyappDeploymentPlan.deployServer.privateIp}
|
</div>
|
||||||
</strong>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</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}>
|
<Card className={styles.apiPanel} padded={false}>
|
||||||
<div className={styles.panelHeader}>
|
<div className={styles.panelHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h2>Deploy API</h2>
|
<h2>Deploy API</h2>
|
||||||
<span>前端同源调用 `/deploy/api/*` 查询 inventory 和发布计划</span>
|
<span>inventory、发布计划、TAT 运行态</span>
|
||||||
</div>
|
</div>
|
||||||
<Tag tone={apiStatusTone} size="md">
|
<Tag tone={apiStatusTone} size="md">
|
||||||
{apiStatusText}
|
{apiStatusText}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</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 className={styles.apiActions}>
|
||||||
<div>
|
<div>
|
||||||
<strong>真实运行态</strong>
|
<strong>真实运行态</strong>
|
||||||
<span>{remoteSummaryText}</span>
|
<span>
|
||||||
|
实例 {remoteHostSuccessCount}/{remoteHosts.length},CLB {remoteClbSuccessCount}/{remoteClbSnapshots.length},最近刷新{" "}
|
||||||
|
{lastRemoteSyncedAt}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Tag tone={remoteStatusTone} size="md">
|
<Tag tone={remoteStatusTone} size="md">
|
||||||
{remoteStatusText}
|
{remoteStatusText}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Button
|
<Button
|
||||||
disabled={apiDiscoverQuery.isFetching}
|
disabled={apiDiscoverQuery.isFetching || serviceNames.length === 0}
|
||||||
isLoading={apiDiscoverQuery.isLoading}
|
isLoading={apiDiscoverQuery.isLoading}
|
||||||
onClick={handleRemoteDiscover}
|
onClick={() => void apiDiscoverQuery.refetch()}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
立即刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{apiDiscoverQuery.isError ? (
|
{apiInventoryQuery.isError || apiDiscoverQuery.isError ? (
|
||||||
<div className={styles.apiNotice}>{apiDiscoverQuery.error instanceof Error ? apiDiscoverQuery.error.message : "真实状态查询失败"}</div>
|
<div className={styles.apiNotice}>
|
||||||
|
{apiInventoryQuery.error instanceof Error
|
||||||
|
? apiInventoryQuery.error.message
|
||||||
|
: apiDiscoverQuery.error instanceof Error
|
||||||
|
? apiDiscoverQuery.error.message
|
||||||
|
: "真实状态查询失败"}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{apiDiscoverQuery.isSuccess ? (
|
{apiDiscoverQuery.isSuccess ? (
|
||||||
<div className={styles.discoveryGrid}>
|
<div className={styles.discoveryGrid}>
|
||||||
@ -489,96 +381,108 @@ export function ReleasesPage() {
|
|||||||
<div className={styles.panelHeader}>
|
<div className={styles.panelHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h2>服务部署映射</h2>
|
<h2>服务部署映射</h2>
|
||||||
<span>服务名、Compose 目标、生产 systemd unit、CLB 绑定和实例分布</span>
|
<span>来自 inventory 和 TAT discover</span>
|
||||||
</div>
|
</div>
|
||||||
<Tag tone="brand" size="md">
|
<Tag tone="brand" size="md">
|
||||||
{hyappDeploymentPlan.services.length} 个服务
|
{rows.length} 个服务
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
|
|
||||||
<div className={styles.contentGrid}>
|
<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}>
|
<Card className={styles.panel} padded={false}>
|
||||||
<div className={styles.panelHeader}>
|
<div className={styles.panelHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h2>托管依赖</h2>
|
<h2>托管依赖</h2>
|
||||||
<span>这些资源只作为连接配置,不进入 TAT 发布动作</span>
|
<span>来自 inventory</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.dependencyList}>
|
<div className={styles.dependencyList}>
|
||||||
{hyappDeploymentPlan.managedDependencies.map((dependency) => (
|
{Object.entries(apiInventoryQuery.data?.managedDependencies ?? {}).map(([id, name]) => (
|
||||||
<div className={styles.dependencyItem} key={dependency.id}>
|
<div className={styles.dependencyItem} key={id}>
|
||||||
<Database size={18} />
|
<Database size={18} />
|
||||||
<div>
|
<div>
|
||||||
<strong>{dependency.name}</strong>
|
<strong>{name}</strong>
|
||||||
<span>{dependency.provider}</span>
|
<span>{id}</span>
|
||||||
<small>{dependency.boundary}</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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}>
|
<Card className={styles.panel} padded={false}>
|
||||||
<div className={styles.panelHeader}>
|
<div className={styles.panelHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h2>发布保护</h2>
|
<h2>执行保护</h2>
|
||||||
<span>从 hyapp-server 文档同步的禁止项</span>
|
<span>真实操作必须显式确认</span>
|
||||||
</div>
|
</div>
|
||||||
<ShieldAlert size={20} />
|
<ShieldAlert size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.guardList}>
|
<div className={styles.guardList}>
|
||||||
{hyappDeploymentPlan.prohibitedActions.map((action) => (
|
<div className={styles.guardItem}>
|
||||||
<div className={styles.guardItem} key={action}>
|
<span />
|
||||||
<span />
|
<strong>deploy / restart 必须传入 confirmed=true</strong>
|
||||||
<strong>{action}</strong>
|
</div>
|
||||||
</div>
|
<div className={styles.guardItem}>
|
||||||
))}
|
<span />
|
||||||
|
<strong>联调和验收使用 dryRun=true</strong>
|
||||||
|
</div>
|
||||||
|
<div className={styles.guardItem}>
|
||||||
|
<span />
|
||||||
|
<strong>配置读取只走 TAT,密钥字段脱敏</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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() {
|
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 {
|
.logPanel code {
|
||||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.configGrid {
|
.configGrid {
|
||||||
@ -725,6 +726,60 @@
|
|||||||
font-size: 15px;
|
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 {
|
.floatingStatus {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
|
|||||||
@ -17,10 +17,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useAppContextStore } from "../../app/store/useAppContextStore";
|
import { useAppContextStore } from "../../app/store/useAppContextStore";
|
||||||
import {
|
|
||||||
getHyappDeploymentHosts,
|
|
||||||
getHyappDeploymentServiceByName,
|
|
||||||
} from "../../entities/deployment-plan";
|
|
||||||
import {
|
import {
|
||||||
type ServiceEvent,
|
type ServiceEvent,
|
||||||
type ServiceInstance,
|
type ServiceInstance,
|
||||||
@ -117,6 +113,10 @@ function formatYamlValue(value: string | number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createInstanceConfigYaml(instance: ServiceInstance) {
|
function createInstanceConfigYaml(instance: ServiceInstance) {
|
||||||
|
if (instance.configYaml) {
|
||||||
|
return instance.configYaml;
|
||||||
|
}
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
"apiVersion: ops.deploy-platform/v1",
|
"apiVersion: ops.deploy-platform/v1",
|
||||||
"kind: ServiceInstanceConfig",
|
"kind: ServiceInstanceConfig",
|
||||||
@ -266,10 +266,8 @@ export function ServiceDetailPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<DetailTab>("overview");
|
const [activeTab, setActiveTab] = useState<DetailTab>("overview");
|
||||||
const [selectedInstanceId, setSelectedInstanceId] = useState("");
|
const [selectedInstanceId, setSelectedInstanceId] = useState("");
|
||||||
const [configModalInstanceId, setConfigModalInstanceId] = useState("");
|
const [configModalInstanceId, setConfigModalInstanceId] = useState("");
|
||||||
const detailQuery = useServiceDetailQuery(serviceId);
|
const detailQuery = useServiceDetailQuery(serviceId, environment);
|
||||||
const service = detailQuery.data;
|
const service = detailQuery.data;
|
||||||
const deploymentService = service ? getHyappDeploymentServiceByName(service.name) : undefined;
|
|
||||||
const deploymentHosts = deploymentService ? getHyappDeploymentHosts(deploymentService) : [];
|
|
||||||
|
|
||||||
const summaryItems = useMemo(() => {
|
const summaryItems = useMemo(() => {
|
||||||
if (!service) {
|
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) {
|
if (!service) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@ -373,7 +384,7 @@ export function ServiceDetailPage() {
|
|||||||
<Card className={styles.section} padded={false}>
|
<Card className={styles.section} padded={false}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<h2>监控概览</h2>
|
<h2>监控概览</h2>
|
||||||
<span>最近 1 小时</span>
|
<span>当前采样</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricGrid}>
|
<div className={styles.metricGrid}>
|
||||||
{service.metrics.map((metric) => (
|
{service.metrics.map((metric) => (
|
||||||
@ -462,14 +473,29 @@ export function ServiceDetailPage() {
|
|||||||
<strong>{service.owner}</strong>
|
<strong>{service.owner}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>实例规格</span>
|
<span>Unit</span>
|
||||||
<strong>2C / 4Gi</strong>
|
<strong>{service.unit ?? "未采集"}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>发布策略</span>
|
<span>端口</span>
|
||||||
<strong>滚动发布</strong>
|
<strong>{service.targetPort ?? "未采集"}</strong>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</Card>
|
||||||
),
|
),
|
||||||
events: (
|
events: (
|
||||||
@ -591,13 +617,16 @@ export function ServiceDetailPage() {
|
|||||||
<Card className={styles.section} padded={false}>
|
<Card className={styles.section} padded={false}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<h2>日志</h2>
|
<h2>日志</h2>
|
||||||
<span>最近发布窗口</span>
|
<span>journalctl</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.logPanel}>
|
<div className={styles.logPanel}>
|
||||||
<code>[14:18:02] release {service.version} completed</code>
|
{service.instances.map((instance) => (
|
||||||
<code>[14:18:01] readiness probe passed on all pods</code>
|
<code key={instance.id}>
|
||||||
<code>[14:17:42] config checksum updated</code>
|
{instance.name}
|
||||||
<code>[14:17:10] rolling update started by backend</code>
|
{"\n"}
|
||||||
|
{instance.logs || "未采集"}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
),
|
),
|
||||||
@ -617,33 +646,33 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.releaseGrid}>
|
<div className={styles.releaseGrid}>
|
||||||
<div>
|
<div>
|
||||||
<span>本地命令</span>
|
<span>服务</span>
|
||||||
<code>{deploymentService?.local.commands.rebuild ?? "未绑定"}</code>
|
<code>{service.name}</code>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>生产 Unit</span>
|
<span>生产 Unit</span>
|
||||||
<strong>{deploymentService?.prod?.unit ?? "未进 TAT inventory"}</strong>
|
<strong>{service.unit ?? "TAT 未返回"}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>CLB 入口</span>
|
<span>容器</span>
|
||||||
<strong>{deploymentService?.prod?.clb.entrypoint ?? "local only"}</strong>
|
<strong>{service.container ?? "TAT 未返回"}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>配置模板</span>
|
<span>配置</span>
|
||||||
<code>{deploymentService?.prod?.configPath ?? deploymentService?.local.configPath ?? "未绑定"}</code>
|
<code>{service.configPath ?? "TAT 未返回"}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.releaseHosts}>
|
<div className={styles.releaseHosts}>
|
||||||
<span>实例分布</span>
|
<span>实例分布</span>
|
||||||
<div>
|
<div>
|
||||||
{deploymentHosts.length > 0 ? (
|
{service.instances.length > 0 ? (
|
||||||
deploymentHosts.map((host) => (
|
service.instances.map((instance) => (
|
||||||
<Tag key={host.id} tone="success">
|
<Tag key={instance.id} tone="success">
|
||||||
{host.id} / {host.privateIp}
|
{instance.node} / {instance.podIp}
|
||||||
</Tag>
|
</Tag>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<Tag tone="warning">local-compose</Tag>
|
<Tag tone="warning">TAT 未返回</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -716,7 +745,7 @@ export function ServiceDetailPage() {
|
|||||||
<CheckCircle2 size={15} />
|
<CheckCircle2 size={15} />
|
||||||
<span>依赖与实例状态已同步</span>
|
<span>依赖与实例状态已同步</span>
|
||||||
<Clock3 size={14} />
|
<Clock3 size={14} />
|
||||||
<span>刚刚</span>
|
<span>TAT</span>
|
||||||
<Database size={15} />
|
<Database size={15} />
|
||||||
<span>{service.dependencies.length} 个依赖</span>
|
<span>{service.dependencies.length} 个依赖</span>
|
||||||
<Box size={15} />
|
<Box size={15} />
|
||||||
|
|||||||
@ -19,3 +19,120 @@
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
white-space: nowrap;
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { useAppContextStore } from "../../app/store/useAppContextStore";
|
import { useAppContextStore } from "../../app/store/useAppContextStore";
|
||||||
import { useServicesQuery } from "../../entities/service";
|
import { useServiceDetailQuery, useServicesQuery } from "../../entities/service";
|
||||||
import type { ServiceListParams } from "../../entities/service";
|
import type { ServiceDetail, ServiceListParams } from "../../entities/service";
|
||||||
import { useReleaseDrawerStore } from "../../features/release-service";
|
import { useReleaseDrawerStore } from "../../features/release-service";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
import { Card } from "../../shared/ui/Card";
|
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 { ServiceTable } from "../../widgets/service-table";
|
||||||
import styles from "./ServicesPage.module.css";
|
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() {
|
export function ServicesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { clusterId, environment, projectId } = useAppContextStore();
|
const { clusterId, environment, projectId } = useAppContextStore();
|
||||||
const openRelease = useReleaseDrawerStore((state) => state.openRelease);
|
const openRelease = useReleaseDrawerStore((state) => state.openRelease);
|
||||||
|
const [configServiceId, setConfigServiceId] = useState("");
|
||||||
const serviceListParams = useMemo<ServiceListParams>(() => {
|
const serviceListParams = useMemo<ServiceListParams>(() => {
|
||||||
return {
|
return {
|
||||||
clusterId,
|
clusterId,
|
||||||
@ -21,13 +85,29 @@ export function ServicesPage() {
|
|||||||
}, [clusterId, environment, projectId]);
|
}, [clusterId, environment, projectId]);
|
||||||
const servicesQuery = useServicesQuery(serviceListParams);
|
const servicesQuery = useServicesQuery(serviceListParams);
|
||||||
const services = servicesQuery.data?.items ?? [];
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">微服务</h1>
|
<h1 className="page-title">微服务</h1>
|
||||||
<p className="page-subtitle">集中检索和管理当前项目环境下的服务</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -36,14 +116,33 @@ export function ServicesPage() {
|
|||||||
<span className={styles.count}>
|
<span className={styles.count}>
|
||||||
共 {servicesQuery.data?.total ?? 0} 个服务
|
共 {servicesQuery.data?.total ?? 0} 个服务
|
||||||
</span>
|
</span>
|
||||||
|
<Button disabled={servicesQuery.isFetching} isLoading={servicesQuery.isFetching} onClick={() => void servicesQuery.refetch()} size="sm" variant="secondary">
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{servicesQuery.isError ? (
|
||||||
|
<EmptyState
|
||||||
|
title="微服务读取失败"
|
||||||
|
description={servicesQuery.error instanceof Error ? servicesQuery.error.message : "deploy 机器未返回数据"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<ServiceTable
|
<ServiceTable
|
||||||
isLoading={servicesQuery.isLoading}
|
isLoading={servicesQuery.isLoading || (servicesQuery.isFetching && !servicesQuery.data)}
|
||||||
|
onViewConfig={(service) => setConfigServiceId(service.id)}
|
||||||
onRelease={openRelease}
|
onRelease={openRelease}
|
||||||
onViewDetails={(service) => navigate(`/services/${service.id}`)}
|
onViewDetails={(service) => navigate(`/services/${service.id}`)}
|
||||||
services={services}
|
services={services}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{configServiceId ? (
|
||||||
|
<ConfigYamlModal
|
||||||
|
error={configQuery.error}
|
||||||
|
isLoading={configQuery.isLoading || (configQuery.isFetching && !configQuery.data)}
|
||||||
|
onClose={() => setConfigServiceId("")}
|
||||||
|
service={configQuery.data}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</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";
|
import type { ServiceListParams } from "../../entities/service";
|
||||||
|
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
|
dataResources: () => ["data-resources"] as const,
|
||||||
deployApiInventory: () => ["deploy-api-inventory"] as const,
|
deployApiInventory: () => ["deploy-api-inventory"] as const,
|
||||||
deployApiDiscover: (services: string[]) => ["deploy-api-discover", services] as const,
|
deployApiDiscover: (services: string[]) => ["deploy-api-discover", services] as const,
|
||||||
deployApiPlan: (services: string[]) => ["deploy-api-plan", services] as const,
|
deployApiPlan: (services: string[]) => ["deploy-api-plan", services] as const,
|
||||||
deployApiTestboxConfigs: (services: string[]) => ["deploy-api-testbox-configs", 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,
|
services: (params: ServiceListParams) => ["services", params] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
Bell,
|
|
||||||
Database,
|
Database,
|
||||||
FileClock,
|
FileClock,
|
||||||
Gauge,
|
Gauge,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Settings,
|
ServerCog,
|
||||||
Shield,
|
|
||||||
Workflow,
|
Workflow,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@ -23,12 +20,9 @@ type AppShellProps = {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: LayoutDashboard, label: "总览", to: "/overview" },
|
{ icon: LayoutDashboard, label: "总览", to: "/overview" },
|
||||||
{ icon: Workflow, label: "微服务", to: "/services" },
|
{ icon: Workflow, label: "微服务", to: "/services" },
|
||||||
|
{ icon: ServerCog, label: "测试服务", to: "/test-services" },
|
||||||
{ icon: FileClock, label: "部署方案", to: "/releases" },
|
{ icon: FileClock, label: "部署方案", to: "/releases" },
|
||||||
{ icon: Database, label: "数据漫游", to: "/resources" },
|
{ 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" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
|
|||||||
@ -10,12 +10,14 @@ import styles from "./ServiceTable.module.css";
|
|||||||
|
|
||||||
type ServiceTableProps = {
|
type ServiceTableProps = {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
onViewConfig?: (service: Service) => void;
|
||||||
onRelease?: (service: Service) => void;
|
onRelease?: (service: Service) => void;
|
||||||
onViewDetails?: (service: Service) => void;
|
onViewDetails?: (service: Service) => void;
|
||||||
services: Service[];
|
services: Service[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ServiceTableActions = {
|
type ServiceTableActions = {
|
||||||
|
onViewConfig: ((service: Service) => void) | undefined;
|
||||||
onRelease: ((service: Service) => void) | undefined;
|
onRelease: ((service: Service) => void) | undefined;
|
||||||
onViewDetails: ((service: Service) => void) | undefined;
|
onViewDetails: ((service: Service) => void) | undefined;
|
||||||
};
|
};
|
||||||
@ -33,6 +35,7 @@ function usageTone(value: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createColumns({
|
function createColumns({
|
||||||
|
onViewConfig,
|
||||||
onRelease,
|
onRelease,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
}: ServiceTableActions): TableColumn<Service>[] {
|
}: ServiceTableActions): TableColumn<Service>[] {
|
||||||
@ -115,7 +118,11 @@ function createColumns({
|
|||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton label="更多操作">
|
<IconButton
|
||||||
|
disabled={!onViewConfig}
|
||||||
|
label="查看配置 YAML"
|
||||||
|
onClick={() => onViewConfig?.(service)}
|
||||||
|
>
|
||||||
<MoreVertical size={16} />
|
<MoreVertical size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
@ -140,11 +147,12 @@ const skeletonServices: Service[] = Array.from({ length: 5 }, (_, index) => ({
|
|||||||
|
|
||||||
export function ServiceTable({
|
export function ServiceTable({
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
onViewConfig,
|
||||||
onRelease,
|
onRelease,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
services,
|
services,
|
||||||
}: ServiceTableProps) {
|
}: ServiceTableProps) {
|
||||||
const columns = createColumns({ onRelease, onViewDetails });
|
const columns = createColumns({ onRelease, onViewConfig, onViewDetails });
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user