This commit is contained in:
zhx 2026-05-21 16:43:57 +08:00
parent 29dcce8ca0
commit fb1a75a2a4
55 changed files with 5154 additions and 1835 deletions

26
AGENTS.md Normal file
View 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`,密钥字段保持脱敏。

View File

@ -1,6 +1,6 @@
# Deploy Platform
液态玻璃风格的项目运维管理平台。
项目运维平台。
## Scripts
@ -43,3 +43,20 @@ make up
```
部署模板和 TAT 脚本见 `deploy/README.md`
## 数据资源
- API`GET /deploy/api/hyapp/resources?remote=1`
- 链路Tencent TAT -> deploy 机器
- MySQL、Redis、MQ腾讯云托管资源
- 业务服务器配置:优先 SSH失败后 TAT 兜底
- 前端状态TAT 未返回前保持加载
- 禁止:生产 SQL/Mongo 查询入口
## 微服务
- 列表:`GET /deploy/api/hyapp/discover?remote=1`
- 详情 YAML`GET /deploy/api/hyapp/discover?remote=1&includeConfig=1`
- 数据来源TAT 读取 systemd、Docker、镜像、资源、日志和配置
- YAML 入口:列表右侧三点、详情配置区
- 禁止:静态假数据兜底展示运行态

View File

@ -42,9 +42,17 @@ npm run deploy:serve
- `GET /deploy/api/health`:只返回凭据是否存在,不返回密钥明文。
- `GET /deploy/api/hyapp/inventory`:读取 HyApp 生产 inventory。
- `GET /deploy/api/hyapp/plan?services=gateway-service,room-service`:生成发布计划,不执行远端动作。
- `GET /deploy/api/hyapp/discover?services=user-service&remote=1`:通过 Tencent TAT 读取真实实例、容器、端口和 CLB 权重。
- `GET /deploy/api/hyapp/discover?services=user-service&remote=1`:通过 Tencent TAT 读取真实实例、systemd、Docker、镜像、资源、端口和 CLB 权重。
- `GET /deploy/api/hyapp/discover?services=user-service&remote=1&includeConfig=1`:读取详情 YAML密钥字段脱敏。
- `GET /deploy/api/hyapp/resources?remote=1`数据资源状态。链路TAT -> deploy业务服务器配置 SSH 失败后 TAT 兜底。
- `POST /deploy/api/hyapp/deploy`:真实发布,必须带 `confirmed=true`;测试时使用 `dryRun=true`
- `POST /deploy/api/hyapp/restart`:真实重启,必须带 `confirmed=true`;测试时使用 `dryRun=true`
- `GET /deploy/api/hyapp/testbox/status`:读取测试机 test 分支、timer 和 Compose/systemd 服务状态。
- `POST /deploy/api/hyapp/testbox/service`:测试机服务 `update/start/stop/restart`,必须带 `confirmed=true`
- `POST /deploy/api/hyapp/testbox/timer`:测试机自动更新 timer `start/stop/restart`,必须带 `confirmed=true`
- `POST /deploy/api/hyapp/testbox/config`:写入测试机单服务配置,自动备份旧文件,可选保存后重启。
- `GET /deploy/api/hyapp/testbox/migrations`:只列出测试机仓库 `server/admin/migrations/*.sql`
- `POST /deploy/api/hyapp/testbox/migrations/apply`:只执行测试机仓库中的 migration 文件,必须带 `confirmed=true`
详细命令见:

View File

@ -163,7 +163,7 @@ function readRequestJson(request) {
request.on("data", (chunk) => {
raw += chunk;
if (raw.length > 64 * 1024) {
if (raw.length > 256 * 1024) {
reject(new Error("request body too large"));
request.destroy();
}
@ -226,6 +226,44 @@ function buildEnvArgs() {
return tencentEnvFile ? ["--env-file", tencentEnvFile] : [];
}
function parseMigrationFiles(value) {
const source = Array.isArray(value) ? value.join(",") : value;
const files = String(source || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
for (const file of files) {
if (!/^[0-9][A-Za-z0-9_.-]*\.sql$/.test(file)) {
throw new Error(`invalid migration file name: ${file}`);
}
}
return [...new Set(files)];
}
function requireConfirmed(body) {
if (body.dryRun === true) {
return;
}
if (body.confirmed !== true) {
throw new Error("confirmed=true is required for real execution");
}
}
function buildTestboxBaseArgs(action) {
return [
action,
...buildEnvArgs(),
"--region",
hyappTestbox.region,
"--instance-id",
hyappTestbox.instanceId,
"--root",
hyappTestbox.root,
];
}
function buildActionArgs(action, body) {
const services = parseServices(body.services, "");
const hosts = parseHosts(body.hosts);
@ -358,6 +396,8 @@ async function handleApi(request, response, url) {
if (url.pathname === "/deploy/api/hyapp/discover") {
const services = parseServices(url.searchParams.get("services"), "gateway-service,room-service,user-service");
const executeRemote = url.searchParams.get("remote") === "1";
const includeConfig = url.searchParams.get("includeConfig") === "1" || url.searchParams.get("includeConfig") === "true";
const maxConfigBytes = Number(url.searchParams.get("maxConfigBytes") || 12000);
const args = [
"discover",
...buildEnvArgs(),
@ -370,27 +410,32 @@ async function handleApi(request, response, url) {
if (!executeRemote) {
args.push("--dry-run");
}
if (includeConfig) {
args.push("--include-config", "--max-config-bytes", String(Math.max(1000, Math.min(maxConfigBytes, 50000))));
}
const result = await runDeployScript("hyapp_tat_deploy.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/resources") {
const executeRemote = url.searchParams.get("remote") !== "0";
const args = ["resources", ...buildEnvArgs(), "--inventory", hyappInventoryPath];
if (!executeRemote) {
args.push("--dry-run");
}
const result = await runDeployScript("hyapp_data_resources.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/configs") {
const services = parseServices(url.searchParams.get("services"), hyappTestboxConfigServices);
const raw = url.searchParams.get("raw") === "1" || url.searchParams.get("raw") === "true";
const args = [
"configs",
...buildEnvArgs(),
"--region",
hyappTestbox.region,
"--instance-id",
hyappTestbox.instanceId,
"--root",
hyappTestbox.root,
"--services",
services.join(","),
];
const args = [...buildTestboxBaseArgs("configs"), "--services", services.join(",")];
if (raw && process.env.HYAPP_TESTBOX_CONFIG_ALLOW_RAW === "1") {
args.push("--raw");
@ -401,6 +446,130 @@ async function handleApi(request, response, url) {
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/status") {
const services = parseServices(url.searchParams.get("services"), hyappTestboxConfigServices);
const args = [...buildTestboxBaseArgs("status"), "--services", services.join(",")];
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/service") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
requireConfirmed(body);
const services = parseServices(body.services, hyappTestboxConfigServices);
const operation = String(body.operation || "").trim();
if (!["restart", "start", "stop", "update"].includes(operation)) {
throw new Error(`invalid service operation: ${operation}`);
}
const args = [...buildTestboxBaseArgs("service"), "--services", services.join(","), "--operation", operation];
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/timer") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
requireConfirmed(body);
const operation = String(body.operation || "").trim();
if (!["start", "stop", "restart", "status"].includes(operation)) {
throw new Error(`invalid timer operation: ${operation}`);
}
const args = [...buildTestboxBaseArgs("timer"), "--operation", operation];
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/config") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
requireConfirmed(body);
const service = parseServices(body.service, "")[0];
const content = String(body.content ?? "");
if (!content.trim()) {
throw new Error("config content is required");
}
if (content.length > 60_000) {
throw new Error("config content is too large");
}
if (content.includes("***")) {
throw new Error("redacted config cannot be saved; replace masked secret fields before saving");
}
const args = [
...buildTestboxBaseArgs("write-config"),
"--service",
service,
"--content-base64",
Buffer.from(content, "utf-8").toString("base64"),
];
if (body.restart === true) {
args.push("--restart");
}
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/migrations") {
const args = buildTestboxBaseArgs("migrations");
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/testbox/migrations/apply") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
requireConfirmed(body);
const files = parseMigrationFiles(body.files);
const args = buildTestboxBaseArgs("migrations-apply");
if (files.length > 0) {
args.push("--files", files.join(","));
}
if (body.dryRun === true) {
args.push("--dry-run");
}
const result = await runDeployScript("hyapp_testbox_config.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
sendError(response, 404, "deploy api route not found");
}

View File

@ -17,10 +17,18 @@
- `GET /deploy/api/hyapp/plan?services=gateway-service,room-service`
- `GET /deploy/api/hyapp/discover?services=gateway-service&remote=0`
- `GET /deploy/api/hyapp/discover?services=user-service&remote=1`
- `GET /deploy/api/hyapp/discover?services=user-service&remote=1&includeConfig=1`
- `GET /deploy/api/hyapp/resources?remote=1`
- `POST /deploy/api/hyapp/deploy`
- `POST /deploy/api/hyapp/restart`
- `GET /deploy/api/hyapp/testbox/status`
- `POST /deploy/api/hyapp/testbox/service`
- `POST /deploy/api/hyapp/testbox/timer`
- `POST /deploy/api/hyapp/testbox/config`
- `GET /deploy/api/hyapp/testbox/migrations`
- `POST /deploy/api/hyapp/testbox/migrations/apply`
`deploy``restart` 是真实操作接口,必须在 JSON body 中显式传入 `confirmed: true`。联调和验收使用 `dryRun: true`,只返回执行计划,不修改远端实例。
`resources?remote=1`TAT -> deploy业务服务器配置 SSH 失败后 TAT 兜底;不开放 SQL/Mongo 查询入口。`deploy``restart` 是真实操作接口,必须在 JSON body 中显式传入 `confirmed: true`。联调和验收使用 `dryRun: true`
## Build Image

View File

@ -3,7 +3,8 @@
本目录包含两类 TAT 发布入口:
- `deploy_platform_tat_deploy.py`:发布当前 `deploy-platform` 前端项目。
- `hyapp_tat_deploy.py`:发布 HyApp 后端 Go 微服务,逻辑复制自 `/Users/hy/Documents/hy/hyapp-server/deploy/tencent-tat`
- `hyapp_tat_deploy.py`:发布 HyApp 后端 Go 微服务,读取真实 systemd、Docker、镜像、资源、日志和 YAML。
- `hyapp_data_resources.py`数据资源状态。TAT -> deploy业务服务器配置 SSH 失败后 TAT 兜底。
两者使用同一套 TAT/CLB 滚动部署模型:可选 CLB 摘流TAT 远端更新 `docker.env` 镜像并重启 systemd unit健康恢复后恢复权重。
@ -101,6 +102,16 @@ python3 deploy/tencent-tat/hyapp_tat_deploy.py plan \
--services gateway-service,room-service,user-service
```
读取真实运行态和配置 YAML
```bash
python3 deploy/tencent-tat/hyapp_tat_deploy.py discover \
--env-file /opt/hy-app-monitor/.env \
--inventory deploy/tencent-tat/hyapp-services.inventory.prod.json \
--services gateway-service \
--include-config
```
生产使用时复制成真实 inventory
```bash
@ -108,6 +119,22 @@ cp deploy/tencent-tat/hyapp-services.inventory.prod.example.json deploy/tencent-
vi deploy/tencent-tat/hyapp-services.inventory.prod.json
```
数据资源状态只读查询:
```bash
python3 deploy/tencent-tat/hyapp_data_resources.py resources \
--env-file /opt/hy-app-monitor/.env \
--inventory deploy/tencent-tat/hyapp-services.inventory.prod.json
```
规则:
- MySQL、Redis、MQ 是腾讯云托管资源。
- 读取入口固定为 TAT -> deploy。
- 业务服务器配置优先 SSHSSH 失败后 TAT 兜底。
- 只读 `/etc/hyapp/<service>/config.yaml``docker.env`、systemd active。
- 不执行 SQL/Mongo 查询,不修改 MySQL、Redis、MQ。
## HyApp Testbox
测试机部署不走生产镜像仓库,也不摘 CLB。当前模式是测试机自己拉取远端 `test` 分支:
@ -128,3 +155,11 @@ python3 deploy/tencent-tat/hyapp_testbox_config.py configs \
```
默认返回会遮罩 `secret``password``token``key` 等字段;确实需要原始值时,服务端必须显式设置 `HYAPP_TESTBOX_CONFIG_ALLOW_RAW=1`,并在 API 请求里带 `raw=1`
测试服务后台还使用同一个脚本封装固定动作,不开放任意 shell
- `status`:读取 test 分支、已部署 commit、timer 和服务状态。
- `service --operation update|start|stop|restart`:对 Compose 服务或 `admin-server` systemd 执行固定操作。
- `timer --operation start|stop|restart|status`:控制 `hyapp-server-test-deploy.timer`
- `write-config --service <name> --content-base64 <yaml>`:写配置前备份旧文件,可选 `--restart`
- `migrations` / `migrations-apply`:只列出或执行 `source/server/admin/migrations/*.sql`,不接收页面手写 SQL。

View File

@ -8,9 +8,52 @@
"service_health_timeout_seconds": 150,
"stabilize_seconds": 5,
"managed_dependencies": {
"mysql": "TencentDB for MySQL private endpoint; this deployment never starts local MySQL.",
"redis": "TencentDB for Redis private endpoint; room owner route and login/risk cache use this managed Redis.",
"mq": "Tencent Cloud managed MQ; future consumers only receive connection/topic config and are not deployed as local infra."
"mysql": "TencentDB MySQL",
"redis": "TencentDB Redis",
"mq": "Tencent Cloud MQ"
},
"data_resources": {
"mysql": {
"name": "TencentDB MySQL",
"type": "mysql",
"topology": "托管主从 MySQL",
"backup_status": "云产品自动备份,平台不执行恢复动作",
"owner": "后端平台",
"related_services": [
"room-service",
"wallet-service",
"user-service",
"activity-service",
"cron-service",
"game-service",
"notice-service",
"admin-server"
]
},
"redis": {
"name": "TencentDB Redis",
"type": "redis",
"topology": "托管 Redis",
"backup_status": "缓存资源不作为恢复事实源",
"owner": "后端平台",
"related_services": [
"gateway-service",
"room-service",
"user-service"
]
},
"mq": {
"name": "Tencent Cloud MQ",
"type": "mq",
"topology": "托管消息队列",
"backup_status": "云产品托管,平台不执行恢复动作",
"owner": "后端平台",
"related_services": [
"room-service",
"activity-service",
"notice-service"
]
}
},
"deploy_server": {
"name": "deploy",

View 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())

View File

@ -137,6 +137,8 @@ def build_plan(
"target_port": int(service["target_port"]),
"unit": service["unit"],
"container": service["container"],
"env_file": service["env_file"],
"config_path": service.get("config_path") or f"/etc/hyapp/{service_name}/config.yaml",
"image": image,
"clb_enabled": bool(clb.get("enabled")) and not no_clb,
"clb_load_balancer_id": str(clb.get("load_balancer_id") or ""),
@ -507,33 +509,247 @@ def remote_restart_script(service: dict[str, Any], *, image: str, health_timeout
return "\n".join(lines) + "\n"
def remote_discover_script() -> str:
# 只读取 hyapp 命名空间内的 unit/env/container不扫描或修改 Java 项目。
return r"""set -euo pipefail
echo "== hyapp units =="
systemctl list-units 'hyapp-*.service' --no-pager --all || true
echo "== hyapp env files =="
if [ -d /etc/hyapp ]; then
find /etc/hyapp -maxdepth 2 -name docker.env -print 2>/dev/null | sort | while read -r env_file; do
echo "-- $env_file"
sed -n 's/^\(IMAGE\|CONTAINER_NAME\|CONFIG_PATH\|STOP_TIMEOUT_SEC\)=/\1=/p' "$env_file" || true
done
else
echo "/etc/hyapp missing"
fi
echo "== hyapp containers =="
docker ps --filter 'name=hyapp-' --format '{{.Names}} {{.Image}} {{.Status}}' || true
echo "== listening ports =="
ss -lntp 2>/dev/null | awk 'NR==1 || /:13000|:13001|:13004|:13005|:13006|:13008|:13009|:13100|:13101|:13104|:13105|:13106|:13108|:13109/' || true
def remote_discover_script(*, host_services: list[dict[str, Any]], include_config: bool, max_config_bytes: int) -> str:
# 只读取 inventory 明确声明的 hyapp unit/env/container/config不扫描或修改 Java 项目。
payload = {
"includeConfig": include_config,
"maxConfigBytes": max_config_bytes,
"services": [
{
"configPath": service.get("config_path") or "",
"container": service.get("container") or "",
"envFile": service.get("env_file") or "",
"name": service.get("service") or "",
"targetPort": service.get("target_port") or 0,
"unit": service.get("unit") or "",
}
for service in host_services
],
}
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
script = r"""set -euo pipefail
python3 - <<'PY'
import base64
import datetime
import json
import os
import re
import subprocess
payload = json.loads(base64.b64decode(__PAYLOAD__).decode("utf-8"))
services = payload.get("services") or []
include_config = bool(payload.get("includeConfig"))
max_config_bytes = int(payload.get("maxConfigBytes") or 12000)
secret_key_re = re.compile(r"(secret|password|passwd|token|credential|private[_-]?key|access[_-]?key|sign[_-]?key|dsn)", re.I)
def command(args, timeout=8):
try:
return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=timeout).strip()
except Exception as exc:
return str(exc).strip()
def percent_value(value):
match = re.search(r"[-+]?\d+(?:\.\d+)?", str(value or ""))
return float(match.group(0)) if match else None
def read_file(path, max_bytes):
item = {
"content": "",
"exists": False,
"path": path,
"truncated": False,
}
if not path or not os.path.exists(path):
return item
item["exists"] = True
with open(path, "r", encoding="utf-8", errors="replace") as handle:
content = handle.read(max_bytes + 1)
item["truncated"] = len(content) > max_bytes
item["content"] = content[:max_bytes]
return item
def redact_text(text):
redacted = []
for line in text.splitlines():
match = re.match(r"^(\s*[-\w.]+\s*(?::|=)\s*)(.*)$", line)
if match and secret_key_re.search(match.group(1)):
redacted.append(match.group(1) + '"***"')
else:
redacted.append(line)
return "\n".join(redacted) + ("\n" if text.endswith("\n") else "")
def parse_env(text):
env = {}
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
env[key] = "***" if secret_key_re.search(key) else value
return env
def parse_docker_inspect(container):
if not container:
return {}
raw = command(["docker", "inspect", container], timeout=10)
try:
decoded = json.loads(raw)
except Exception:
return {"inspectError": raw[-1000:]}
return decoded[0] if decoded else {}
def parse_docker_stats(container):
if not container:
return {}
raw = command(["docker", "stats", "--no-stream", "--format", "{{json .}}", container], timeout=12)
line = raw.splitlines()[0] if raw else ""
try:
stats = json.loads(line)
except Exception:
return {"statsError": raw[-1000:]}
return {
"cpuPercent": percent_value(stats.get("CPUPerc")),
"memoryPercent": percent_value(stats.get("MemPerc")),
"memoryUsage": stats.get("MemUsage") or "",
}
def parse_time(value):
text = str(value or "")
if not text or text.startswith("0001-"):
return None
try:
return datetime.datetime.fromisoformat(text.replace("Z", "+00:00"))
except Exception:
return None
def duration_text(value):
started_at = parse_time(value)
if started_at is None:
return "未采集"
now = datetime.datetime.now(datetime.timezone.utc)
delta = now - started_at.astimezone(datetime.timezone.utc)
seconds = max(int(delta.total_seconds()), 0)
days, remainder = divmod(seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes = remainder // 60
if days > 0:
return f"{days}{hours} 小时"
if hours > 0:
return f"{hours} 小时 {minutes} 分钟"
return f"{minutes} 分钟"
def image_tag(image):
text = str(image or "")
if ":" not in text:
return text
return text.rsplit(":", 1)[1]
def service_status(unit_state, container_status, health_status):
if unit_state == "active" and container_status == "running" and health_status in ("", "healthy"):
return "healthy"
if unit_state == "activating" or container_status in ("created", "restarting") or health_status == "starting":
return "running"
if unit_state == "active" and container_status == "running":
return "warning"
if unit_state or container_status or health_status:
return "critical"
return "unknown"
items = []
for service in services:
name = str(service.get("name") or "")
unit = str(service.get("unit") or "")
container = str(service.get("container") or "")
env_file = str(service.get("envFile") or "")
env_payload = read_file(env_file, max_config_bytes)
env = parse_env(env_payload["content"])
config_path = env.get("CONFIG_PATH") or str(service.get("configPath") or "") or f"/etc/hyapp/{name}/config.yaml"
config_payload = read_file(config_path, max_config_bytes) if include_config else {
"content": "",
"exists": os.path.exists(config_path) if config_path else False,
"path": config_path,
"truncated": False,
}
inspect = parse_docker_inspect(container)
state = inspect.get("State") or {}
config = inspect.get("Config") or {}
health = (state.get("Health") or {}).get("Status") or ""
image = env.get("IMAGE") or config.get("Image") or ""
started_at = state.get("StartedAt") or ""
created_at = inspect.get("Created") or ""
stats = parse_docker_stats(container)
target_port = int(service.get("targetPort") or 0)
listeners = command(["bash", "-lc", f"ss -lntp 2>/dev/null | grep ':{target_port} ' || true"]) if target_port else ""
logs = command(["journalctl", "-u", unit, "-n", "30", "--no-pager", "-o", "short-iso"], timeout=10) if include_config and unit else ""
items.append({
"configExists": bool(config_payload["exists"]),
"configPath": config_path,
"configTruncated": bool(config_payload["truncated"]),
"configYaml": redact_text(config_payload["content"]) if include_config and config_payload["exists"] else "",
"container": container,
"containerId": str(inspect.get("Id") or "")[:12],
"containerStatus": str(state.get("Status") or ""),
"cpuPercent": stats.get("cpuPercent"),
"createdAt": created_at,
"env": env,
"envFile": env_file,
"envFileExists": bool(env_payload["exists"]),
"healthStatus": health,
"image": image,
"imageTag": image_tag(image),
"listeners": listeners,
"logs": logs,
"memoryPercent": stats.get("memoryPercent"),
"memoryUsage": stats.get("memoryUsage") or "",
"name": name,
"restartCount": int(inspect.get("RestartCount") or 0) if inspect else 0,
"runningTime": duration_text(started_at),
"startedAt": started_at,
"status": service_status(command(["systemctl", "is-active", unit]) if unit else "", str(state.get("Status") or ""), health),
"targetPort": target_port,
"unit": unit,
"unitState": command(["systemctl", "is-active", unit]) if unit else "",
})
print(json.dumps({
"hostname": command(["hostname"]),
"ok": True,
"services": items,
}, ensure_ascii=False))
PY
"""
return script.replace("__PAYLOAD__", repr(blob))
def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool) -> dict[str, Any]:
def execute_discovery(
inventory: dict[str, Any],
plan: list[dict[str, Any]],
*,
dry_run: bool,
include_config: bool,
max_config_bytes: int,
) -> dict[str, Any]:
if dry_run:
return {"ok": True, "dry_run": True, "steps": plan}
clb_client = build_clb_client(inventory)
seen_hosts = list(dict.fromkeys(step["host"] for step in plan))
by_host = {name: next(step for step in plan if step["host"] == name) for name in seen_hosts}
services_by_host = {name: [step for step in plan if step["host"] == name] for name in seen_hosts}
def discover_host(host_name: str, seed: dict[str, Any]) -> dict[str, Any]:
try:
@ -541,17 +757,28 @@ def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *,
result = run_tat_shell(
tat_client,
instance_id=seed["instance_id"],
script=remote_discover_script(),
script=remote_discover_script(
host_services=services_by_host[host_name],
include_config=include_config,
max_config_bytes=max_config_bytes,
),
command_name=f"hyapp-discover-{host_name}",
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
poll_seconds=int(inventory.get("tat_poll_seconds") or 2),
)
output = str(result.get("output") or "")
try:
payload = json.loads(output or "{}")
except json.JSONDecodeError:
payload = {}
services = list(payload.get("services") or [])
return {
"host": host_name,
"instance_id": seed["instance_id"],
"private_ip": seed["private_ip"],
"status": result["status"],
"output": str(result.get("output") or "")[-5000:],
"output": "" if services else output[-5000:],
"services": services,
}
except Exception as exc: # noqa: BLE001
return {
@ -560,6 +787,7 @@ def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *,
"private_ip": seed["private_ip"],
"status": "ERROR",
"error": str(exc),
"services": [],
}
host_results: dict[str, dict[str, Any]] = {}
@ -670,6 +898,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
parser.add_argument("--image", action="append", default=[], help="Override one image: service=image. Can be repeated.")
parser.add_argument("--no-clb", action="store_true", help="Skip CLB drain. Only use for isolated emergency maintenance.")
parser.add_argument("--dry-run", action="store_true", help="Print the execution plan without calling Tencent APIs.")
parser.add_argument("--include-config", action="store_true", help="Include redacted service config YAML in discover output.")
parser.add_argument("--max-config-bytes", type=int, default=12_000, help="Maximum bytes to read from each config file during discover.")
parser.add_argument("--yes", action="store_true", help="Required for real restart/deploy execution.")
return parser.parse_args(argv)
@ -695,7 +925,13 @@ def main(argv: list[str]) -> int:
print(json.dumps({"ok": True, "steps": plan}, ensure_ascii=False, indent=2))
return 0
if args.action == "discover":
result = execute_discovery(inventory, plan, dry_run=args.dry_run)
result = execute_discovery(
inventory,
plan,
dry_run=args.dry_run,
include_config=args.include_config,
max_config_bytes=args.max_config_bytes,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 1
result = execute_plan(inventory, plan, dry_run=args.dry_run, assume_yes=args.yes)

View File

@ -27,6 +27,9 @@ DEFAULT_SERVICES = (
"admin-server",
)
SERVICE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
MIGRATION_FILE_RE = re.compile(r"^[0-9][A-Za-z0-9_.-]*\.sql$")
SERVICE_OPERATIONS = {"restart", "start", "stop", "update"}
TIMER_OPERATIONS = {"start", "stop", "restart", "status"}
def load_env_file(path: str) -> None:
@ -79,6 +82,21 @@ def parse_services(raw_services: str) -> list[str]:
return list(dict.fromkeys(services))
def parse_migration_files(raw_files: str) -> list[str]:
files = csv_values(raw_files)
invalid = [file for file in files if not MIGRATION_FILE_RE.match(file)]
if invalid:
raise RuntimeError(f"invalid migration file names: {', '.join(invalid)}")
return list(dict.fromkeys(files))
def parse_operation(operation: str, allowed: set[str], label: str) -> str:
value = str(operation or "").strip()
if value not in allowed:
raise RuntimeError(f"invalid {label} operation: {value}")
return value
def build_tat_client(region: str) -> Any:
sid, skey = require_credentials()
try:
@ -191,7 +209,7 @@ root = payload["root"]
services = payload["services"]
redact = bool(payload["redact"])
max_bytes = int(payload["maxBytes"])
secret_key_re = re.compile(r"(secret|password|passwd|token|credential|private[_-]?key|access[_-]?key|dsn)", re.I)
secret_key_re = re.compile(r"(secret|password|passwd|token|credential|private[_-]?key|access[_-]?key|sign[_-]?key|dsn)", re.I)
def service_path(service):
if service == "admin-server":
@ -252,6 +270,507 @@ PY
"""
def remote_status_script(*, root: str, services: list[str]) -> str:
payload = {
"root": root,
"services": services,
}
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
return f"""set -euo pipefail
python3 - <<'PY'
import base64
import json
import os
import subprocess
payload = json.loads(base64.b64decode({blob!r}).decode("utf-8"))
root = payload["root"]
services = payload["services"]
source = os.path.join(root, "source")
timer = "hyapp-server-test-deploy.timer"
deploy_service = "hyapp-server-test-deploy.service"
def command_output(args, timeout=12):
try:
return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=timeout).strip()
except Exception as exc:
return str(exc)
def compose_args():
args = ["docker", "compose", "-p", "hyapp-test", "-f", os.path.join(source, "docker-compose.yml")]
override = os.path.join(root, "docker-compose.testbox.yml")
if os.path.exists(override):
args.extend(["-f", override])
return args
def service_path(service):
if service == "admin-server":
return os.path.join(root, "admin-server", "config.yaml")
return os.path.join(source, "services", service, "configs", "config.docker.yaml")
def inspect_container(name):
raw = command_output(["docker", "inspect", name], timeout=8)
try:
data = json.loads(raw)[0]
except Exception:
return {{
"composeService": name,
"configPath": service_path(name),
"exists": False,
"health": "unknown",
"image": "",
"name": name,
"restartCount": 0,
"service": name,
"startedAt": "",
"state": "missing",
"status": "unknown",
}}
state = data.get("State") or {{}}
health = (state.get("Health") or {{}}).get("Status") or "none"
return {{
"composeService": name,
"configPath": service_path(name),
"containerId": str(data.get("Id") or "")[:12],
"exists": True,
"health": health,
"image": (data.get("Config") or {{}}).get("Image") or "",
"name": data.get("Name", "").lstrip("/") or name,
"restartCount": state.get("RestartCount") or 0,
"service": name,
"startedAt": state.get("StartedAt") or "",
"state": state.get("Status") or "unknown",
"status": "healthy" if state.get("Status") == "running" and health in ("healthy", "none") else ("running" if state.get("Status") == "running" else "critical"),
}}
service_states = []
for service in services:
if service == "admin-server":
active = command_output(["systemctl", "is-active", "hyapp-admin-server.service"], timeout=5)
service_states.append({{
"composeService": "hyapp-admin-server.service",
"configPath": service_path(service),
"exists": active not in ("inactive", "unknown"),
"health": "systemd",
"image": "systemd",
"name": "hyapp-admin-server.service",
"restartCount": 0,
"service": service,
"startedAt": command_output(["systemctl", "show", "hyapp-admin-server.service", "--property=ActiveEnterTimestamp", "--value"], timeout=5),
"state": active,
"status": "healthy" if active == "active" else ("unknown" if active.startswith("Command") else "critical"),
}})
else:
service_states.append(inspect_container(service))
remote_test_raw = command_output(["git", "-C", source, "ls-remote", "origin", "refs/heads/test"], timeout=15)
result = {{
"deployedCommit": command_output(["bash", "-lc", f"cat {{root}}/.deployed_commit 2>/dev/null || true"], timeout=5),
"deployServiceState": command_output(["systemctl", "is-active", deploy_service], timeout=5),
"git": {{
"branch": command_output(["git", "-C", source, "rev-parse", "--abbrev-ref", "HEAD"], timeout=8),
"localCommit": command_output(["git", "-C", source, "rev-parse", "HEAD"], timeout=8),
"remoteTestCommit": remote_test_raw.split()[0] if remote_test_raw else "",
}},
"ok": True,
"services": service_states,
"target": {{
"instanceId": "lhins-q0m38zc6",
"privateIp": "10.11.0.2",
"publicIp": "43.165.195.39",
"root": root,
"timer": timer,
}},
"timerState": command_output(["systemctl", "is-active", timer], timeout=5),
"updatedAt": command_output(["date", "-u", "+%Y-%m-%dT%H:%M:%SZ"], timeout=5),
}}
print(json.dumps(result, ensure_ascii=False))
PY
"""
def remote_service_action_script(*, root: str, services: list[str], operation: str) -> str:
payload = {
"operation": operation,
"root": root,
"services": services,
}
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
return f"""set -euo pipefail
python3 - <<'PY'
import base64
import json
import os
import subprocess
payload = json.loads(base64.b64decode({blob!r}).decode("utf-8"))
root = payload["root"]
source = os.path.join(root, "source")
services = payload["services"]
operation = payload["operation"]
def run(args, cwd=None, timeout=900):
proc = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
return {{"args": args, "code": proc.returncode, "output": proc.stdout.strip()}}
def compose_args():
args = ["docker", "compose", "-p", "hyapp-test", "-f", os.path.join(source, "docker-compose.yml")]
override = os.path.join(root, "docker-compose.testbox.yml")
if os.path.exists(override):
args.extend(["-f", override])
return args
go_services = [service for service in services if service != "admin-server"]
has_admin = "admin-server" in services
commands = []
ok = True
def record(result):
global ok
commands.append(result)
if result["code"] != 0:
ok = False
if operation == "update":
record(run(["git", "fetch", "origin", "test"], cwd=source, timeout=120))
if ok:
record(run(["git", "checkout", "test"], cwd=source, timeout=60))
if ok:
record(run(["git", "pull", "--ff-only", "origin", "test"], cwd=source, timeout=180))
if ok and go_services:
record(run(compose_args() + ["up", "-d", "--build"] + go_services, cwd=source, timeout=1200))
if ok and has_admin:
record(run(["systemctl", "restart", "hyapp-admin-server.service"], timeout=180))
if ok and not services:
record(run([os.path.join(root, "bin", "deploy.sh")], timeout=1800))
elif operation in ("restart", "start", "stop"):
if go_services:
compose_operation = "up" if operation == "start" else operation
extra = ["-d"] + go_services if operation == "start" else go_services
record(run(compose_args() + [compose_operation] + extra, cwd=source, timeout=420))
if has_admin:
record(run(["systemctl", operation, "hyapp-admin-server.service"], timeout=180))
else:
ok = False
commands.append({{"args": [], "code": 1, "output": f"unsupported operation: {{operation}}"}})
status = run(compose_args() + ["ps"], cwd=source, timeout=60)
print(json.dumps({{
"commands": commands,
"ok": ok,
"operation": operation,
"services": services,
"status": status,
}}, ensure_ascii=False))
PY
"""
def remote_timer_action_script(*, root: str, operation: str) -> str:
payload = {
"operation": operation,
"root": root,
}
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
return f"""set -euo pipefail
python3 - <<'PY'
import base64
import json
import subprocess
payload = json.loads(base64.b64decode({blob!r}).decode("utf-8"))
operation = payload["operation"]
timer = "hyapp-server-test-deploy.timer"
def run(args, timeout=120):
proc = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
return {{"args": args, "code": proc.returncode, "output": proc.stdout.strip()}}
commands = []
if operation == "status":
commands.append(run(["systemctl", "status", timer, "--no-pager"], timeout=30))
else:
commands.append(run(["systemctl", operation, timer], timeout=120))
state = run(["systemctl", "is-active", timer], timeout=20)
print(json.dumps({{
"commands": commands,
"ok": all(command["code"] == 0 for command in commands),
"operation": operation,
"timerState": state["output"],
}}, ensure_ascii=False))
PY
"""
def remote_write_config_script(*, root: str, service: str, content_b64: str, restart: bool) -> str:
payload = {
"content": content_b64,
"restart": restart,
"root": root,
"service": service,
}
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
return f"""set -euo pipefail
python3 - <<'PY'
import base64
import json
import os
import shutil
import subprocess
import time
payload = json.loads(base64.b64decode({blob!r}).decode("utf-8"))
root = payload["root"]
source = os.path.join(root, "source")
service = payload["service"]
restart = bool(payload["restart"])
content = base64.b64decode(payload["content"].encode("ascii"))
if b"\\x00" in content:
raise RuntimeError("config content contains NUL byte")
if len(content) > 60000:
raise RuntimeError("config content is too large")
def service_path(name):
if name == "admin-server":
return os.path.join(root, "admin-server", "config.yaml")
return os.path.join(source, "services", name, "configs", "config.docker.yaml")
def compose_args():
args = ["docker", "compose", "-p", "hyapp-test", "-f", os.path.join(source, "docker-compose.yml")]
override = os.path.join(root, "docker-compose.testbox.yml")
if os.path.exists(override):
args.extend(["-f", override])
return args
def run(args, cwd=None, timeout=300):
proc = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
return {{"args": args, "code": proc.returncode, "output": proc.stdout.strip()}}
path = service_path(service)
os.makedirs(os.path.dirname(path), exist_ok=True)
backup = ""
if os.path.exists(path):
backup = path + ".bak." + time.strftime("%Y%m%d%H%M%S", time.gmtime())
shutil.copy2(path, backup)
with open(path, "wb") as handle:
handle.write(content)
if backup:
shutil.copymode(backup, path)
else:
os.chmod(path, 0o600)
commands = []
if restart:
if service == "admin-server":
commands.append(run(["systemctl", "restart", "hyapp-admin-server.service"], timeout=180))
else:
commands.append(run(compose_args() + ["restart", service], cwd=source, timeout=300))
print(json.dumps({{
"backupPath": backup,
"commands": commands,
"ok": all(command["code"] == 0 for command in commands),
"path": path,
"restarted": restart,
"service": service,
}}, ensure_ascii=False))
PY
"""
def remote_migrations_script(*, root: str, files: list[str], operation: str, dry_run: bool) -> str:
payload = {
"dryRun": dry_run,
"files": files,
"operation": operation,
"root": root,
}
blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
return f"""set -euo pipefail
python3 - <<'PY'
import base64
import glob
import hashlib
import json
import os
import re
import shlex
import shutil
import subprocess
import time
payload = json.loads(base64.b64decode({blob!r}).decode("utf-8"))
root = payload["root"]
source = os.path.join(root, "source")
operation = payload["operation"]
requested_files = payload["files"]
dry_run = bool(payload["dryRun"])
migration_dir = os.path.join(source, "server", "admin", "migrations")
config_path = os.path.join(root, "admin-server", "config.yaml")
def read_text(path):
with open(path, "r", encoding="utf-8", errors="replace") as handle:
return handle.read()
def sha256_file(path):
with open(path, "rb") as handle:
return hashlib.sha256(handle.read()).hexdigest()
def parse_dsn():
if not os.path.exists(config_path):
return None
text = read_text(config_path)
match = re.search(r'^mysql_dsn:\\s*["\\']?([^"\\'\\n]+)', text, re.M)
if not match:
return None
dsn = match.group(1).strip()
match = re.match(r"([^:@/]+):([^@]*)@tcp\\(([^:)]+)(?::(\\d+))?\\)/([^?]+)", dsn)
if not match:
return None
return {{
"database": match.group(5),
"host": match.group(3),
"password": match.group(2),
"port": match.group(4) or "3306",
"user": match.group(1),
}}
def mysql_args(target):
return [
"mysql",
"--batch",
"--raw",
"--skip-column-names",
"-h",
target["host"],
"-P",
target["port"],
"-u",
target["user"],
f"-p{{target['password']}}",
target["database"],
]
def run_mysql(target, sql, timeout=120):
proc = subprocess.run(mysql_args(target), input=sql, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
return {{"code": proc.returncode, "output": proc.stdout.strip()}}
def list_files():
rows = []
for path in sorted(glob.glob(os.path.join(migration_dir, "*.sql"))):
name = os.path.basename(path)
rows.append({{
"applied": False,
"appliedAtMs": 0,
"checksum": sha256_file(path),
"dirty": False,
"path": path,
"status": "pending",
"version": name,
}})
return rows
def sql_quote(value):
return "'" + str(value).replace("\\\\", "\\\\\\\\").replace("'", "''") + "'"
target = parse_dsn()
mysql_available = bool(shutil.which("mysql"))
migrations = list_files()
applied = {{}}
error = ""
if target and mysql_available:
ensure = run_mysql(target, \"\"\"
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(128) PRIMARY KEY,
checksum CHAR(64) NOT NULL DEFAULT '',
dirty BOOLEAN NOT NULL DEFAULT FALSE,
applied_at_ms BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\"\"\", timeout=60)
if ensure["code"] == 0:
query = run_mysql(target, "SELECT version, checksum, dirty, applied_at_ms FROM schema_migrations;", timeout=60)
if query["code"] == 0:
for line in query["output"].splitlines():
parts = line.split("\\t")
if len(parts) >= 4:
applied[parts[0]] = {{"checksum": parts[1], "dirty": parts[2] in ("1", "true", "TRUE"), "appliedAtMs": int(parts[3] or 0)}}
else:
error = query["output"]
else:
error = ensure["output"]
for row in migrations:
current = applied.get(row["version"])
if current:
row["applied"] = True
row["appliedAtMs"] = current["appliedAtMs"]
row["dirty"] = current["dirty"]
if current["dirty"]:
row["status"] = "dirty"
elif current["checksum"] and current["checksum"] != row["checksum"]:
row["status"] = "checksum_changed"
else:
row["status"] = "applied"
commands = []
if operation == "apply":
if dry_run:
commands.append({{"code": 0, "output": "dry run only"}})
elif not target:
commands.append({{"code": 1, "output": "admin-server mysql_dsn not found or unsupported"}})
elif not mysql_available:
commands.append({{"code": 1, "output": "mysql client is not installed on testbox"}})
elif error:
commands.append({{"code": 1, "output": error}})
else:
selected = requested_files or [row["version"] for row in migrations if row["status"] == "pending"]
by_version = {{row["version"]: row for row in migrations}}
for version in selected:
row = by_version.get(version)
if not row:
commands.append({{"code": 1, "output": f"migration not found: {{version}}"}})
break
if row["status"] == "applied":
commands.append({{"code": 0, "output": f"skip applied {{version}}"}})
continue
if row["status"] in ("dirty", "checksum_changed"):
commands.append({{"code": 1, "output": f"migration {{version}} is {{row['status']}}"}})
break
checksum = row["checksum"]
insert = run_mysql(target, f"INSERT INTO schema_migrations(version, checksum, dirty) VALUES ({{sql_quote(version)}}, {{sql_quote(checksum)}}, TRUE);", timeout=60)
commands.append({{"code": insert["code"], "output": f"mark dirty {{version}}\\n{{insert['output']}}".strip()}})
if insert["code"] != 0:
break
with open(row["path"], "r", encoding="utf-8", errors="replace") as handle:
body = handle.read()
apply = run_mysql(target, body, timeout=600)
commands.append({{"code": apply["code"], "output": f"apply {{version}}\\n{{apply['output']}}".strip()}})
if apply["code"] != 0:
break
applied_at_ms = int(time.time() * 1000)
finish = run_mysql(target, f"UPDATE schema_migrations SET dirty = FALSE, checksum = {{sql_quote(checksum)}}, applied_at_ms = {{applied_at_ms}} WHERE version = {{sql_quote(version)}};", timeout=60)
commands.append({{"code": finish["code"], "output": f"finish {{version}}\\n{{finish['output']}}".strip()}})
if finish["code"] != 0:
break
ok = not commands or all(command["code"] == 0 for command in commands)
print(json.dumps({{
"commands": commands,
"configPath": config_path,
"database": {{"database": target["database"], "host": target["host"], "port": target["port"], "user": target["user"]}} if target else None,
"dryRun": dry_run,
"error": error,
"migrationDir": migration_dir,
"migrations": migrations,
"mysqlAvailable": mysql_available,
"ok": ok,
"operation": operation,
}}, ensure_ascii=False))
PY
"""
def read_configs(args: argparse.Namespace) -> dict[str, Any]:
services = parse_services(args.services)
client = build_tat_client(args.region)
@ -272,17 +791,111 @@ def read_configs(args: argparse.Namespace) -> dict[str, Any]:
return payload
def parse_tat_json(result: dict[str, Any]) -> dict[str, Any]:
if result["status"] != "SUCCESS":
return {"ok": False, "status": result["status"], "output": result.get("output") or ""}
try:
payload = json.loads(str(result.get("output") or "{}"))
except json.JSONDecodeError:
return {"ok": False, "status": result["status"], "output": result.get("output") or ""}
return payload
def read_status(args: argparse.Namespace) -> dict[str, Any]:
services = parse_services(args.services)
client = build_tat_client(args.region)
result = run_tat_shell(
client,
instance_id=args.instance_id,
script=remote_status_script(root=args.root, services=services),
command_name="hyapp-testbox-status",
timeout_seconds=args.timeout_seconds,
poll_seconds=args.poll_seconds,
)
return parse_tat_json(result)
def run_service_action(args: argparse.Namespace) -> dict[str, Any]:
services = parse_services(args.services)
operation = parse_operation(args.operation, SERVICE_OPERATIONS, "service")
client = build_tat_client(args.region)
result = run_tat_shell(
client,
instance_id=args.instance_id,
script=remote_service_action_script(root=args.root, services=services, operation=operation),
command_name=f"hyapp-testbox-service-{operation}",
timeout_seconds=args.timeout_seconds,
poll_seconds=args.poll_seconds,
)
return parse_tat_json(result)
def run_timer_action(args: argparse.Namespace) -> dict[str, Any]:
operation = parse_operation(args.operation, TIMER_OPERATIONS, "timer")
client = build_tat_client(args.region)
result = run_tat_shell(
client,
instance_id=args.instance_id,
script=remote_timer_action_script(root=args.root, operation=operation),
command_name=f"hyapp-testbox-timer-{operation}",
timeout_seconds=args.timeout_seconds,
poll_seconds=args.poll_seconds,
)
return parse_tat_json(result)
def write_config(args: argparse.Namespace) -> dict[str, Any]:
if not args.service:
raise RuntimeError("service is required for write-config")
service = parse_services(args.service)[0]
raw_content = base64.b64decode(args.content_base64.encode("ascii"))
if len(raw_content) > 60_000:
raise RuntimeError("config content is too large")
content_b64 = base64.b64encode(raw_content).decode("ascii")
client = build_tat_client(args.region)
result = run_tat_shell(
client,
instance_id=args.instance_id,
script=remote_write_config_script(root=args.root, service=service, content_b64=content_b64, restart=args.restart),
command_name=f"hyapp-testbox-write-config-{service}",
timeout_seconds=args.timeout_seconds,
poll_seconds=args.poll_seconds,
)
return parse_tat_json(result)
def read_or_apply_migrations(args: argparse.Namespace) -> dict[str, Any]:
operation = "apply" if args.action == "migrations-apply" else "list"
files = parse_migration_files(args.files)
client = build_tat_client(args.region)
result = run_tat_shell(
client,
instance_id=args.instance_id,
script=remote_migrations_script(root=args.root, files=files, operation=operation, dry_run=args.dry_run),
command_name=f"hyapp-testbox-migrations-{operation}",
timeout_seconds=args.timeout_seconds,
poll_seconds=args.poll_seconds,
)
return parse_tat_json(result)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Read HYApp testbox deployment config via Tencent TAT.")
parser.add_argument("action", choices=("configs",))
parser = argparse.ArgumentParser(description="Manage HYApp testbox through Tencent TAT.")
parser.add_argument("action", choices=("configs", "status", "service", "timer", "write-config", "migrations", "migrations-apply"))
parser.add_argument("--env-file", default="", help="optional env file with Tencent Cloud credentials")
parser.add_argument("--region", default=DEFAULT_REGION)
parser.add_argument("--instance-id", default=DEFAULT_INSTANCE_ID)
parser.add_argument("--root", default=DEFAULT_ROOT)
parser.add_argument("--services", default=",".join(DEFAULT_SERVICES))
parser.add_argument("--service", default="", help="single service for write-config")
parser.add_argument("--operation", default="", help="service or timer operation")
parser.add_argument("--content-base64", default="", help="base64 encoded YAML content for write-config")
parser.add_argument("--restart", action="store_true", help="restart service after write-config")
parser.add_argument("--files", default="", help="comma separated migration file names")
parser.add_argument("--dry-run", action="store_true", help="validate action without applying migrations")
parser.add_argument("--raw", action="store_true", help="return unredacted YAML")
parser.add_argument("--max-bytes", type=int, default=20_000)
parser.add_argument("--timeout-seconds", type=int, default=120)
parser.add_argument("--timeout-seconds", type=int, default=1200)
parser.add_argument("--poll-seconds", type=int, default=2)
return parser
@ -292,7 +905,20 @@ def main() -> int:
args = parser.parse_args()
try:
load_env_file(args.env_file)
payload = read_configs(args)
if args.action == "configs":
payload = read_configs(args)
elif args.action == "status":
payload = read_status(args)
elif args.action == "service":
payload = run_service_action(args)
elif args.action == "timer":
payload = run_timer_action(args)
elif args.action == "write-config":
payload = write_config(args)
elif args.action in {"migrations", "migrations-apply"}:
payload = read_or_apply_migrations(args)
else:
raise RuntimeError(f"unsupported action: {args.action}")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload.get("ok") else 1
except Exception as exc: # noqa: BLE001

View File

@ -1,13 +1,11 @@
import { createBrowserRouter, Navigate } from "react-router-dom";
import { App } from "./App";
import { AuditPage } from "../pages/audit";
import { LogsEventsPage } from "../pages/logs-events";
import { OverviewPage } from "../pages/overview";
import { PlaceholderPage } from "../pages/placeholder";
import { ReleasesPage } from "../pages/releases";
import { ResourcesPage } from "../pages/resources";
import { ServiceDetailPage } from "../pages/service-detail";
import { ServicesPage } from "../pages/services";
import { TestServicesPage } from "../pages/test-services";
export const router = createBrowserRouter([
{
@ -18,12 +16,9 @@ export const router = createBrowserRouter([
{ path: "overview", element: <OverviewPage /> },
{ path: "services", element: <ServicesPage /> },
{ path: "services/:serviceId", element: <ServiceDetailPage /> },
{ path: "test-services", element: <TestServicesPage /> },
{ path: "releases", element: <ReleasesPage /> },
{ path: "resources", element: <ResourcesPage /> },
{ path: "logs-events", element: <LogsEventsPage /> },
{ path: "audit", element: <AuditPage /> },
{ path: "settings", element: <PlaceholderPage title="配置中心" /> },
{ path: "alerts", element: <PlaceholderPage title="监控告警" /> },
{ path: "*", element: <Navigate to="/overview" replace /> },
],
},

View 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);
}

View 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,
});
}

View File

@ -0,0 +1,11 @@
export { useDataResourcesQuery } from "./api/useDataResourcesQuery";
export type {
DataResource,
DataResourceCheck,
DataResourceCheckStatus,
DataResourceSourceHost,
DataResourceStatus,
DataResourceSummary,
DataResourceType,
DataResourcesResponse,
} from "./model/types";

View 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;
};
};
};

View File

@ -1,16 +1,45 @@
export type DeployApiInventoryResponse = {
hosts: Record<string, unknown>;
hosts: Record<
string,
{
instance_id?: string;
private_ip?: string;
}
>;
managedDependencies: Record<string, string>;
ok: boolean;
region: string;
registry: string;
services: Record<string, unknown>;
services: Record<
string,
{
clb?: {
drain_seconds?: number;
enabled?: boolean;
listener_ids?: string[];
load_balancer_id?: string;
};
config_path?: string;
container?: string;
env_file?: string;
hosts?: string[];
image_template?: string;
owner?: string;
target_port?: number;
unit?: string;
}
>;
};
async function readJsonResponse<T>(response: Response): Promise<T> {
const contentType = response.headers.get("content-type") ?? "";
if (!response.ok) {
if (contentType.includes("application/json")) {
const payload = (await response.json()) as { detail?: string; error?: string };
throw new Error(payload.detail || payload.error || `Deploy API HTTP ${response.status}`);
}
throw new Error(`Deploy API HTTP ${response.status}`);
}
@ -21,6 +50,19 @@ async function readJsonResponse<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}
async function postJson<T>(path: string, body: Record<string, unknown>): Promise<T> {
const response = await fetch(path, {
body: JSON.stringify(body),
headers: {
accept: "application/json",
"content-type": "application/json",
},
method: "POST",
});
return readJsonResponse<T>(response);
}
export async function getDeployApiInventory(): Promise<DeployApiInventoryResponse> {
const response = await fetch("/deploy/api/hyapp/inventory", {
headers: {
@ -74,6 +116,24 @@ export type DeployApiDiscoverResponse = {
host: string;
output?: string;
private_ip: string;
services?: Array<{
configPath?: string;
container?: string;
containerStatus?: string;
cpuPercent?: number | null;
healthStatus?: string;
image?: string;
imageTag?: string;
memoryPercent?: number | null;
memoryUsage?: string;
name: string;
restartCount?: number;
runningTime?: string;
status?: "healthy" | "warning" | "critical" | "unknown" | "running";
targetPort?: number;
unit?: string;
unitState?: string;
}>;
status: string;
}>;
ok: boolean;
@ -114,8 +174,9 @@ export type DeployApiTestboxConfigResponse = {
timerState?: string;
};
export async function getDeployApiTestboxConfigs(services: string[]): Promise<DeployApiTestboxConfigResponse> {
export async function getDeployApiTestboxConfigs(services: string[], raw = false): Promise<DeployApiTestboxConfigResponse> {
const params = new URLSearchParams({
raw: raw ? "1" : "0",
services: services.join(","),
});
const response = await fetch(`/deploy/api/hyapp/testbox/configs?${params.toString()}`, {
@ -126,3 +187,148 @@ export async function getDeployApiTestboxConfigs(services: string[]): Promise<De
return readJsonResponse<DeployApiTestboxConfigResponse>(response);
}
export type DeployApiTestboxStatusResponse = {
deployedCommit?: string;
deployServiceState?: string;
git?: {
branch?: string;
localCommit?: string;
remoteTestCommit?: string;
};
ok: boolean;
services: Array<{
composeService: string;
configPath: string;
containerId?: string;
exists: boolean;
health: string;
image: string;
name: string;
restartCount: number;
service: string;
startedAt: string;
state: string;
status: "healthy" | "warning" | "critical" | "unknown" | "running";
}>;
target?: DeployApiTestboxConfigResponse["target"];
timerState?: string;
updatedAt?: string;
};
export async function getDeployApiTestboxStatus(services: string[]): Promise<DeployApiTestboxStatusResponse> {
const params = new URLSearchParams({
services: services.join(","),
});
const response = await fetch(`/deploy/api/hyapp/testbox/status?${params.toString()}`, {
headers: {
accept: "application/json",
},
});
return readJsonResponse<DeployApiTestboxStatusResponse>(response);
}
export type DeployApiTestboxCommand = {
args?: string[];
code: number;
output: string;
};
export type DeployApiTestboxServiceActionResponse = {
commands: DeployApiTestboxCommand[];
ok: boolean;
operation: string;
services: string[];
status?: DeployApiTestboxCommand;
};
export function runDeployApiTestboxServiceAction(params: {
operation: "restart" | "start" | "stop" | "update";
services: string[];
}): Promise<DeployApiTestboxServiceActionResponse> {
return postJson<DeployApiTestboxServiceActionResponse>("/deploy/api/hyapp/testbox/service", {
confirmed: true,
operation: params.operation,
services: params.services,
});
}
export type DeployApiTestboxTimerActionResponse = {
commands: DeployApiTestboxCommand[];
ok: boolean;
operation: string;
timerState: string;
};
export function runDeployApiTestboxTimerAction(operation: "start" | "stop" | "restart" | "status"): Promise<DeployApiTestboxTimerActionResponse> {
return postJson<DeployApiTestboxTimerActionResponse>("/deploy/api/hyapp/testbox/timer", {
confirmed: true,
operation,
});
}
export type DeployApiTestboxConfigWriteResponse = {
backupPath: string;
commands: DeployApiTestboxCommand[];
ok: boolean;
path: string;
restarted: boolean;
service: string;
};
export function saveDeployApiTestboxConfig(params: {
content: string;
restart: boolean;
service: string;
}): Promise<DeployApiTestboxConfigWriteResponse> {
return postJson<DeployApiTestboxConfigWriteResponse>("/deploy/api/hyapp/testbox/config", {
confirmed: true,
content: params.content,
restart: params.restart,
service: params.service,
});
}
export type DeployApiTestboxMigration = {
applied: boolean;
appliedAtMs: number;
checksum: string;
dirty: boolean;
path: string;
status: "pending" | "applied" | "dirty" | "checksum_changed";
version: string;
};
export type DeployApiTestboxMigrationsResponse = {
commands?: DeployApiTestboxCommand[];
configPath: string;
database?: {
database: string;
host: string;
port: string;
user: string;
} | null;
dryRun?: boolean;
error?: string;
migrationDir: string;
migrations: DeployApiTestboxMigration[];
mysqlAvailable: boolean;
ok: boolean;
operation: "list" | "apply";
};
export function getDeployApiTestboxMigrations(): Promise<DeployApiTestboxMigrationsResponse> {
return fetch("/deploy/api/hyapp/testbox/migrations", {
headers: {
accept: "application/json",
},
}).then((response) => readJsonResponse<DeployApiTestboxMigrationsResponse>(response));
}
export function applyDeployApiTestboxMigrations(files: string[]): Promise<DeployApiTestboxMigrationsResponse> {
return postJson<DeployApiTestboxMigrationsResponse>("/deploy/api/hyapp/testbox/migrations/apply", {
confirmed: true,
files,
});
}

View File

@ -1,6 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "../../../shared/api/queryKeys";
import { getDeployApiDiscover, getDeployApiInventory, getDeployApiPlan, getDeployApiTestboxConfigs } from "./getDeployApiInventory";
import {
getDeployApiDiscover,
getDeployApiInventory,
getDeployApiPlan,
getDeployApiTestboxConfigs,
getDeployApiTestboxMigrations,
getDeployApiTestboxStatus,
} from "./getDeployApiInventory";
const STATUS_REFRESH_INTERVAL_MS = 15_000;
@ -13,8 +20,9 @@ export function useDeployApiInventoryQuery() {
});
}
export function useDeployApiPlanQuery(services: string[]) {
export function useDeployApiPlanQuery(services: string[], enabled = true) {
return useQuery({
enabled: enabled && services.length > 0,
queryFn: () => getDeployApiPlan(services),
queryKey: queryKeys.deployApiPlan(services),
retry: 0,
@ -31,11 +39,32 @@ export function useDeployApiDiscoverQuery(services: string[], enabled: boolean)
});
}
export function useDeployApiTestboxConfigsQuery(services: string[]) {
export function useDeployApiTestboxConfigsQuery(services: string[], enabled = true) {
return useQuery({
enabled,
queryFn: () => getDeployApiTestboxConfigs(services),
queryKey: queryKeys.deployApiTestboxConfigs(services),
refetchInterval: 60_000,
retry: 0,
});
}
export function useDeployApiTestboxStatusQuery(services: string[], enabled = true) {
return useQuery({
enabled,
queryFn: () => getDeployApiTestboxStatus(services),
queryKey: queryKeys.deployApiTestboxStatus(services),
refetchInterval: enabled ? STATUS_REFRESH_INTERVAL_MS : false,
retry: 0,
});
}
export function useDeployApiTestboxMigrationsQuery(enabled = true) {
return useQuery({
enabled,
queryFn: getDeployApiTestboxMigrations,
queryKey: queryKeys.deployApiTestboxMigrations(),
refetchInterval: 60_000,
retry: 0,
});
}

View File

@ -1,28 +1,27 @@
export { hyappDeploymentPlan } from "./mock/hyappDeploymentPlan";
export {
getHyappDeploymentHosts,
getHyappDeploymentServiceById,
getHyappDeploymentServiceByName,
} from "./lib/getHyappDeploymentService";
export {
useDeployApiDiscoverQuery,
useDeployApiInventoryQuery,
useDeployApiPlanQuery,
useDeployApiTestboxConfigsQuery,
useDeployApiTestboxMigrationsQuery,
useDeployApiTestboxStatusQuery,
} from "./api/useDeployApiQueries";
export {
applyDeployApiTestboxMigrations,
runDeployApiTestboxServiceAction,
runDeployApiTestboxTimerAction,
saveDeployApiTestboxConfig,
} from "./api/getDeployApiInventory";
export type {
DeployApiDiscoverResponse,
DeployApiInventoryResponse,
DeployApiPlanResponse,
DeployApiTestboxCommand,
DeployApiTestboxConfigResponse,
DeployApiTestboxConfigWriteResponse,
DeployApiTestboxMigration,
DeployApiTestboxMigrationsResponse,
DeployApiTestboxServiceActionResponse,
DeployApiTestboxStatusResponse,
DeployApiTestboxTimerActionResponse,
} from "./api/getDeployApiInventory";
export type {
DeploymentCommand,
DeploymentHost,
DeploymentMode,
DeploymentStep,
HyappDeploymentPlan,
HyappDeploymentService,
ManagedDependency,
TestboxDeployTarget,
} from "./model/types";

View File

@ -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));
}

View File

@ -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,
},
};

View File

@ -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;
};
};

View 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] ?? "";
}

View File

@ -1,9 +1,3 @@
import { mockServices } from "../mock/services";
import {
getHyappDeploymentHosts,
getHyappDeploymentServiceByName,
hyappDeploymentPlan,
} from "../../deployment-plan";
import type {
Service,
ServiceDependency,
@ -14,33 +8,19 @@ import type {
ServiceMetric,
ServiceMetricPoint,
} from "../model/types";
const MOCK_LATENCY_MS = 180;
const baseTimes = [
"14:00",
"14:05",
"14:10",
"14:15",
"14:20",
"14:25",
"14:30",
"14:35",
"14:40",
"14:45",
"14:50",
"14:55",
"15:00",
];
function createSeries(values: number[]): ServiceMetricPoint[] {
return values.map((value, index) => ({
time:
baseTimes[index] ??
`${14 + Math.floor(index / 12)}:${String((index * 5) % 60).padStart(2, "0")}`,
value,
}));
}
import {
averagePercent,
type DeployInventoryResponse,
type DeployInventoryService,
type DeployRuntimeService,
fetchDeployDiscover,
fetchDeployInventory,
getRuntimeServices,
mostFrequent,
serviceIdToName,
serviceNameToId,
statusFromRuntimes,
} from "./deployApi";
const dependencyTypeMap: Record<string, ServiceDependency["type"]> = {
mq: "message-queue",
@ -48,320 +28,236 @@ const dependencyTypeMap: Record<string, ServiceDependency["type"]> = {
redis: "cache",
};
const dependencies: ServiceDependency[] = hyappDeploymentPlan.managedDependencies.map((dependency) => ({
id: `dep-${dependency.id}`,
name: dependency.name,
status: "healthy",
type: dependencyTypeMap[dependency.id] ?? "storage",
}));
function createMetrics(service: Service): ServiceMetric[] {
const qpsBase =
service.status === "critical"
? 760
: service.status === "warning"
? 480
: 326.8;
const errorBase =
service.status === "critical"
? 3.8
: service.status === "warning"
? 1.2
: 0.23;
function createCurrentSeries(value: number): ServiceMetricPoint[] {
return [
{ time: "当前", value },
{ time: "当前", value },
];
}
function createMetrics(cpuUsage: number, memoryUsage: number): ServiceMetric[] {
return [
{
color: "blue",
id: "cpu",
label: "CPU 使用率",
max: 100,
points: createSeries([
47,
41,
52,
44,
49,
45,
58,
42,
46,
43,
48,
39,
service.cpuUsage,
]),
points: createCurrentSeries(cpuUsage),
unit: "%",
value: `${service.cpuUsage}%`,
value: `${cpuUsage}%`,
},
{
color: "green",
id: "memory",
label: "内存使用率",
max: 100,
points: createSeries([
55,
52,
56,
51,
53,
58,
59,
61,
57,
55,
58,
54,
service.memoryUsage,
]),
points: createCurrentSeries(memoryUsage),
unit: "%",
value: `${service.memoryUsage}%`,
},
{
color: "blue",
id: "qps",
label: "QPS",
max: 1000,
points: createSeries([
520,
610,
482,
565,
533,
615,
472,
525,
488,
501,
468,
536,
Math.round(qpsBase),
]),
unit: "",
value: String(qpsBase),
},
{
color: "red",
id: "error-rate",
label: "错误率",
max: 5,
points: createSeries([
0.6,
1.1,
0.5,
0.8,
0.4,
1.4,
0.5,
0.7,
0.3,
0.6,
0.4,
0.9,
errorBase,
]),
unit: "%",
value: `${errorBase}%`,
},
{
color: "purple",
id: "avg-latency",
label: "平均响应延迟",
max: 200,
points: createSeries([
72, 64, 81, 68, 77, 71, 92, 66, 74, 69, 76, 70, 78,
]),
unit: "ms",
value: "78 ms",
},
{
color: "orange",
id: "p95-latency",
label: "p95 响应延迟",
max: 400,
points: createSeries([
172, 196, 155, 181, 168, 205, 176, 184, 171, 194, 166, 188, 182,
]),
unit: "ms",
value: "182 ms",
value: `${memoryUsage}%`,
},
];
}
function createInstanceConfigItems(
service: Service,
index: number,
host: { id: string; privateIp: string } | undefined,
): ServiceInstanceConfigItem[] {
const instanceIndex = index + 1;
const deploymentService = getHyappDeploymentServiceByName(service.name);
const prodTarget = deploymentService?.prod;
const localTarget = deploymentService?.local;
const servicePort = prodTarget?.targetPort ?? localTarget?.grpcPort ?? localTarget?.httpPort ?? 0;
const configPath = prodTarget?.configPath ?? localTarget?.configPath ?? "configs/config.yaml";
const image = prodTarget?.imageTemplate ?? `${hyappDeploymentPlan.registry}/${service.name}:${service.version}`;
function formatRemoteTime(value: string | undefined) {
if (!value) {
return "-";
}
return [
{
description: "当前运行环境",
id: `${service.id}-instance-${instanceIndex}-cfg-env`,
key: "APP_ENV",
source: "env",
updatedAt: "14:25",
value: service.namespace,
},
{
description: "服务监听端口",
id: `${service.id}-instance-${instanceIndex}-cfg-port`,
key: "SERVER_PORT",
source: "config-map",
updatedAt: "14:25",
value: String(servicePort),
},
{
description: "业务配置模板",
id: `${service.id}-instance-${instanceIndex}-cfg-config`,
key: "CONFIG_PATH",
source: "system",
updatedAt: "14:25",
value: configPath,
},
{
description: "MySQL 托管实例 DSN",
id: `${service.id}-instance-${instanceIndex}-cfg-mysql`,
key: "MYSQL_DSN",
source: "secret",
updatedAt: "14:25",
value: "hyapp:****@tcp(tencentdb.mysql.internal:3306)/hyapp?loc=UTC",
},
{
description: "Redis 托管实例",
id: `${service.id}-instance-${instanceIndex}-cfg-redis`,
key: "REDIS_HOST",
source: "config-map",
updatedAt: "14:25",
value: "tencentdb.redis.internal:6379",
},
{
description: "托管 MQ 接入点",
id: `${service.id}-instance-${instanceIndex}-cfg-mq`,
key: "MQ_ENDPOINT",
source: "config-map",
updatedAt: "14:25",
value: "tencentmq.internal",
},
{
description: "当前运行镜像",
id: `${service.id}-instance-${instanceIndex}-cfg-image`,
key: "IMAGE",
source: "system",
updatedAt: "14:25",
value: image.replace("${REGISTRY}", hyappDeploymentPlan.registry).replace("${TAG}", service.version),
},
{
description: "部署目标节点",
id: `${service.id}-instance-${instanceIndex}-cfg-host`,
key: "HOST_PRIVATE_IP",
source: "system",
updatedAt: "14:18",
value: host?.privateIp ?? "127.0.0.1",
},
];
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "-";
}
return date.toLocaleString("zh-CN", { hour12: false });
}
function createInstances(service: Service): ServiceInstance[] {
const deploymentService = getHyappDeploymentServiceByName(service.name);
const hosts = deploymentService ? getHyappDeploymentHosts(deploymentService) : [];
const instanceCount = Math.max(hosts.length, service.desiredInstances);
function eventTime(value: string | undefined) {
if (!value) {
return "-";
}
return Array.from({ length: instanceCount }, (_, index) => {
const isReady = index < service.readyInstances;
const host = hosts[index];
const node = host?.id ?? "local-compose";
const privateIp = host?.privateIp ?? `127.0.0.${index + 1}`;
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "-";
}
return date.toLocaleTimeString("zh-CN", { hour12: false, hour: "2-digit", minute: "2-digit" });
}
function managedDependencies(inventory: DeployInventoryResponse): ServiceDependency[] {
return Object.entries(inventory.managedDependencies ?? {}).map(([id, value]) => {
const name = typeof value === "string" ? value : id;
return {
configItems: createInstanceConfigItems(service, index, host),
id: `${service.id}-instance-${index + 1}`,
image: `${hyappDeploymentPlan.registry}/${service.name}:${service.version}`,
name: `${service.name}@${node}`,
podIp: privateIp,
node,
restartCount: index === 1 ? 2 : index === 0 ? 1 : 0,
resourceLimit: deploymentService?.prod ? "systemd Docker / host network" : "docker compose",
runningTime: index === 2 ? "6 天 20 小时" : "7 天 12 小时",
status: isReady ? "healthy" : service.status,
zone: index % 2 === 0 ? "me-saudi-a" : "me-saudi-b",
id: `dep-${id}`,
name,
status: "unknown",
type: dependencyTypeMap[id] ?? "storage",
};
});
}
function createEvents(service: Service): ServiceEvent[] {
return [
function createConfigItems(service: Service, runtime: DeployRuntimeService): ServiceInstanceConfigItem[] {
const systemItems: ServiceInstanceConfigItem[] = [
{
id: `${service.id}-event-1`,
message: `${service.readyInstances} 个实例保持健康`,
time: "14:39",
title: "健康检查",
description: "生产配置文件",
id: `${service.id}-${runtime.host}-config-path`,
key: "CONFIG_PATH",
source: "system",
updatedAt: eventTime(runtime.startedAt),
value: runtime.configPath ?? "未采集",
},
{
id: `${service.id}-event-2`,
message: "CLB 权重已恢复",
time: "14:25",
title: "恢复接流",
description: "当前运行镜像",
id: `${service.id}-${runtime.host}-image`,
key: "IMAGE",
source: "system",
updatedAt: eventTime(runtime.startedAt),
value: runtime.image || "未采集",
},
{
id: `${service.id}-event-3`,
message: `${service.version} 已发布成功`,
time: "14:18",
title: "发布成功",
description: "systemd unit",
id: `${service.id}-${runtime.host}-unit`,
key: "UNIT",
source: "system",
updatedAt: eventTime(runtime.startedAt),
value: runtime.unit || "未采集",
},
{
id: `${service.id}-event-4`,
message: `${service.version} 正在发布`,
time: "14:15",
title: "开始发布",
},
{
id: `${service.id}-event-5`,
message: `${service.name} systemd unit 已启动`,
time: "14:00",
title: "实例启动",
description: "服务端口",
id: `${service.id}-${runtime.host}-target-port`,
key: "TARGET_PORT",
source: "system",
updatedAt: eventTime(runtime.startedAt),
value: String(runtime.targetPort ?? service.targetPort ?? "未采集"),
},
];
const envItems = Object.entries(runtime.env ?? {}).map<ServiceInstanceConfigItem>(([key, value]) => ({
description: "docker.env",
id: `${service.id}-${runtime.host}-env-${key}`,
key,
source: "env",
updatedAt: eventTime(runtime.startedAt),
value,
}));
return [...systemItems, ...envItems];
}
function createServiceDetail(service: Service): ServiceDetail {
function createInstances(service: Service, runtimes: DeployRuntimeService[]): ServiceInstance[] {
return runtimes.map((runtime, index) => ({
configItems: createConfigItems(service, runtime),
configPath: runtime.configPath,
configYaml: runtime.configYaml,
configYamlTruncated: runtime.configTruncated,
containerStatus: runtime.containerStatus,
cpuUsage: runtime.cpuPercent,
envFile: runtime.envFile,
healthStatus: runtime.healthStatus,
id: `${service.id}-${runtime.host}-${index + 1}`,
image: runtime.image || "未采集",
logs: runtime.logs,
memoryUsage: runtime.memoryPercent,
memoryUsageText: runtime.memoryUsage,
name: `${service.name}@${runtime.host}`,
node: runtime.host,
podIp: runtime.privateIp,
restartCount: runtime.restartCount ?? 0,
resourceLimit: runtime.memoryUsage || "Docker",
runningTime: runtime.runningTime || "未采集",
status: runtime.status ?? "unknown",
zone: runtime.host,
}));
}
function createEvents(service: Service, runtimes: DeployRuntimeService[]): ServiceEvent[] {
const events = runtimes.flatMap((runtime) => [
{
id: `${service.id}-${runtime.host}-state`,
message: `${runtime.unitState || "unknown"} / ${runtime.containerStatus || "unknown"} / ${runtime.healthStatus || "no-healthcheck"}`,
time: eventTime(runtime.startedAt),
title: `${runtime.host} 运行状态`,
},
{
id: `${service.id}-${runtime.host}-image`,
message: runtime.image || "未采集",
time: eventTime(runtime.startedAt),
title: `${runtime.host} 镜像`,
},
]);
return events.length > 0
? events
: [
{
id: `${service.id}-no-runtime`,
message: "TAT 未返回该服务运行态",
time: "-",
title: "未采集",
},
];
}
function createService(
params: { environment: string },
serviceName: string,
deployService: DeployInventoryService,
runtimes: DeployRuntimeService[],
): Service {
const desiredInstances = Math.max(deployService.hosts?.length ?? runtimes.length, 1);
const readyInstances = runtimes.filter((runtime) => runtime.status === "healthy").length;
const firstRuntime = runtimes[0];
return {
...service,
createdAt: "2024-08-12 10:30",
dependencies,
errorRate:
service.status === "critical"
? "3.8%"
: service.status === "warning"
? "1.2%"
: "0.23%",
events: createEvents(service),
instances: createInstances(service),
metrics: createMetrics(service),
qps:
service.status === "critical"
? "760"
: service.status === "warning"
? "480"
: "326.8",
runningTime: "7 天 12 小时",
configPath: firstRuntime?.configPath ?? deployService.config_path,
configYaml: firstRuntime?.configYaml,
configYamlTruncated: firstRuntime?.configTruncated,
container: deployService.container ?? firstRuntime?.container,
cpuUsage: averagePercent(runtimes.map((runtime) => runtime.cpuPercent)),
desiredInstances,
id: serviceNameToId(serviceName),
lastReleaseText: mostFrequent(runtimes.map((runtime) => runtime.runningTime))
? `运行 ${mostFrequent(runtimes.map((runtime) => runtime.runningTime))}`
: "未采集",
memoryUsage: averagePercent(runtimes.map((runtime) => runtime.memoryPercent)),
name: serviceName,
namespace: params.environment,
owner: deployService.owner ?? "未配置",
readyInstances,
sourceHosts: runtimes.map((runtime) => runtime.host),
status: statusFromRuntimes(runtimes, desiredInstances),
targetPort: deployService.target_port ?? firstRuntime?.targetPort,
unit: deployService.unit ?? firstRuntime?.unit,
version: mostFrequent(runtimes.map((runtime) => runtime.imageTag || runtime.image)) || "未采集",
};
}
export async function getServiceDetail(
serviceId: string,
): Promise<ServiceDetail | null> {
await new Promise((resolve) =>
globalThis.setTimeout(resolve, MOCK_LATENCY_MS),
);
export async function getServiceDetail(serviceId: string, environment = "prod"): Promise<ServiceDetail | null> {
const inventory = await fetchDeployInventory();
const serviceName = serviceIdToName(serviceId, inventory);
const service = mockServices.find((item) => item.id === serviceId);
if (!serviceName) {
return null;
}
return service ? createServiceDetail(service) : null;
const discover = await fetchDeployDiscover([serviceName], true);
const runtimes = getRuntimeServices(discover).filter((runtime) => runtime.name === serviceName);
const deployService = inventory.services?.[serviceName] ?? {};
const service = createService({ environment }, serviceName, deployService, runtimes);
const cpuUsage = averagePercent(runtimes.map((runtime) => runtime.cpuPercent));
const memoryUsage = averagePercent(runtimes.map((runtime) => runtime.memoryPercent));
return {
...service,
createdAt: formatRemoteTime(mostFrequent(runtimes.map((runtime) => runtime.createdAt))) || "未采集",
dependencies: managedDependencies(inventory),
errorRate: "未接入",
events: createEvents(service, runtimes),
instances: createInstances(service, runtimes),
metrics: createMetrics(cpuUsage, memoryUsage),
qps: "未接入",
runningTime: mostFrequent(runtimes.map((runtime) => runtime.runningTime)) || "未采集",
};
}

View File

@ -1,65 +1,59 @@
import { mockServices } from "../mock/services";
import { ServiceListParams, ServiceListResponse } from "../model/types";
import type { Service, ServiceListParams, ServiceListResponse } from "../model/types";
import {
averagePercent,
fetchDeployDiscover,
fetchDeployInventory,
getRuntimeServices,
mostFrequent,
serviceNameToId,
statusFromRuntimes,
} from "./deployApi";
const MOCK_LATENCY_MS = 160;
function createServiceFromRuntime(
params: ServiceListParams,
serviceName: string,
deployService: NonNullable<Awaited<ReturnType<typeof fetchDeployInventory>>["services"]>[string],
runtimes: ReturnType<typeof getRuntimeServices>,
): Service {
const desiredInstances = Math.max(deployService.hosts?.length ?? runtimes.length, 1);
const readyInstances = runtimes.filter((runtime) => runtime.status === "healthy").length;
const version = mostFrequent(runtimes.map((runtime) => runtime.imageTag || runtime.image)) || "未采集";
const runningTime = mostFrequent(runtimes.map((runtime) => runtime.runningTime));
const firstRuntime = runtimes[0];
type DeployInventoryService = {
hosts?: string[];
target_port?: number;
};
type DeployInventoryResponse = {
ok?: boolean;
services?: Record<string, DeployInventoryService>;
};
async function getServicesFromDeployApi(params: ServiceListParams) {
const response = await fetch("/deploy/api/hyapp/inventory", {
headers: {
accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Deploy API HTTP ${response.status}`);
}
const payload = (await response.json()) as DeployInventoryResponse;
if (payload.ok === false || !payload.services) {
throw new Error("Deploy API inventory is unavailable");
}
return Object.entries(payload.services).map(([serviceName, deployService]) => {
const fallback = mockServices.find((service) => service.name === serviceName);
const instanceCount = Math.max(deployService.hosts?.length ?? fallback?.desiredInstances ?? 1, 1);
return {
cpuUsage: fallback?.cpuUsage ?? 0,
desiredInstances: instanceCount,
id: fallback?.id ?? `svc-${serviceName.replace(/-service$/, "")}`,
lastReleaseText: fallback?.lastReleaseText ?? "inventory",
memoryUsage: fallback?.memoryUsage ?? 0,
name: serviceName,
namespace: params.environment,
owner: fallback?.owner ?? "platform",
readyInstances: instanceCount,
status: fallback?.status ?? "healthy",
version: fallback?.version ?? "inventory",
};
});
return {
configPath: firstRuntime?.configPath ?? deployService.config_path,
configYaml: firstRuntime?.configYaml,
configYamlTruncated: firstRuntime?.configTruncated,
container: deployService.container ?? firstRuntime?.container,
cpuUsage: averagePercent(runtimes.map((runtime) => runtime.cpuPercent)),
desiredInstances,
id: serviceNameToId(serviceName),
lastReleaseText: runningTime ? `运行 ${runningTime}` : "未采集",
memoryUsage: averagePercent(runtimes.map((runtime) => runtime.memoryPercent)),
name: serviceName,
namespace: params.environment,
owner: deployService.owner ?? "未配置",
readyInstances,
sourceHosts: runtimes.map((runtime) => runtime.host),
status: statusFromRuntimes(runtimes, desiredInstances),
targetPort: deployService.target_port ?? firstRuntime?.targetPort,
unit: deployService.unit ?? firstRuntime?.unit,
version,
};
}
export async function getServices(params: ServiceListParams): Promise<ServiceListResponse> {
await new Promise((resolve) => globalThis.setTimeout(resolve, MOCK_LATENCY_MS));
const inventory = await fetchDeployInventory();
const serviceNames = Object.keys(inventory.services ?? {});
const discover = await fetchDeployDiscover(serviceNames, false);
const runtimes = getRuntimeServices(discover);
const items = serviceNames.map((serviceName) => {
const deployService = inventory.services?.[serviceName] ?? {};
const serviceRuntimes = runtimes.filter((runtime) => runtime.name === serviceName);
let items;
try {
items = await getServicesFromDeployApi(params);
} catch {
items = mockServices.filter((service) => service.namespace === params.environment);
}
return createServiceFromRuntime(params, serviceName, deployService, serviceRuntimes);
});
return {
items,

View File

@ -4,11 +4,11 @@ import { getServiceDetail } from "./getServiceDetail";
const STATUS_REFRESH_INTERVAL_MS = 15_000;
export function useServiceDetailQuery(serviceId: string) {
export function useServiceDetailQuery(serviceId: string, environment = "prod") {
return useQuery({
enabled: serviceId.length > 0,
queryFn: () => getServiceDetail(serviceId),
queryKey: queryKeys.serviceDetail(serviceId),
queryFn: () => getServiceDetail(serviceId, environment),
queryKey: queryKeys.serviceDetail(serviceId, environment),
refetchInterval: serviceId.length > 0 ? STATUS_REFRESH_INTERVAL_MS : false,
});
}

View File

@ -3,7 +3,6 @@ export { getServices } from "./api/getServices";
export { useServiceDetailQuery } from "./api/useServiceDetailQuery";
export { useServicesQuery } from "./api/useServicesQuery";
export { getServiceSummary } from "./lib/getServiceSummary";
export { mockServices } from "./mock/services";
export type {
Service,
ServiceDependency,

View File

@ -2,30 +2,17 @@ import { Service } from "../model/types";
export type ServiceSummary = {
abnormalCount: number;
activeAlerts: number;
databaseHealthyText: string;
healthyCount: number;
latestRelease: {
serviceName: string;
version: string;
};
totalCount: number;
};
export function getServiceSummary(services: Service[]): ServiceSummary {
const healthyCount = services.filter((service) => service.status === "healthy").length;
const abnormalCount = services.filter((service) => service.status === "warning" || service.status === "critical").length;
const criticalCount = services.filter((service) => service.status === "critical").length;
const warningCount = services.filter((service) => service.status === "warning").length;
const latestRelease = services[0];
return {
abnormalCount,
activeAlerts: criticalCount * 3 + warningCount,
databaseHealthyText: "3/3",
healthyCount,
latestRelease: {
serviceName: latestRelease?.name ?? "-",
version: latestRelease?.version ?? "-",
},
totalCount: services.length,
};
}

View File

@ -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,
}));

View File

@ -3,6 +3,10 @@ import { StatusBadgeStatus } from "../../../shared/ui/StatusBadge";
export type ServiceStatus = StatusBadgeStatus;
export type Service = {
configPath?: string | undefined;
configYaml?: string | undefined;
configYamlTruncated?: boolean | undefined;
container?: string | undefined;
id: string;
name: string;
namespace: string;
@ -14,6 +18,9 @@ export type Service = {
memoryUsage: number;
lastReleaseText: string;
owner: string;
sourceHosts?: string[] | undefined;
targetPort?: number | undefined;
unit?: string | undefined;
};
export type ServiceDependency = {
@ -49,8 +56,18 @@ export type ServiceInstanceConfigItem = {
export type ServiceInstance = {
configItems: ServiceInstanceConfigItem[];
configPath?: string | undefined;
configYaml?: string | undefined;
configYamlTruncated?: boolean | undefined;
containerStatus?: string | undefined;
cpuUsage?: number | null | undefined;
envFile?: string | undefined;
healthStatus?: string | undefined;
id: string;
image: string;
logs?: string | undefined;
memoryUsage?: number | null | undefined;
memoryUsageText?: string | undefined;
name: string;
podIp: string;
node: string;

View File

@ -1,26 +1,18 @@
import { mockServices } from "../../../entities/service";
import { CreateReleaseRequest, CreateReleaseResult } from "../model/types";
import type { CreateReleaseRequest, CreateReleaseResult } from "../model/types";
type DeployApiResult = {
error?: string;
ok?: boolean;
steps?: unknown[];
error?: string;
};
export async function createRelease(request: CreateReleaseRequest): Promise<CreateReleaseResult> {
const service = mockServices.find((item) => item.id === request.serviceId);
if (!service) {
throw new Error("服务不存在");
}
const fromVersion = service.version;
const shouldExecute = request.environment === "prod";
const response = await fetch("/deploy/api/hyapp/deploy", {
body: JSON.stringify({
confirmed: shouldExecute ? request.confirmed === true : true,
dryRun: !shouldExecute,
services: [service.name],
services: [request.serviceName],
tag: request.targetVersion,
}),
headers: {
@ -34,16 +26,11 @@ export async function createRelease(request: CreateReleaseRequest): Promise<Crea
throw new Error(result.error || `部署接口返回 HTTP ${response.status}`);
}
service.version = request.targetVersion;
service.status = "healthy";
service.readyInstances = service.desiredInstances;
service.lastReleaseText = "刚刚";
const releaseResult: CreateReleaseResult = {
id: `release-${Date.now()}`,
completedAt: new Date().toISOString(),
fromVersion,
serviceId: service.id,
fromVersion: request.fromVersion,
id: `release-${Date.now()}`,
serviceId: request.serviceId,
status: "success",
targetVersion: request.targetVersion,
};

View File

@ -1,45 +1,34 @@
import { Service } from "../../../entities/service";
import { getHyappDeploymentServiceById, hyappDeploymentPlan } from "../../../entities/deployment-plan";
import { ReleasePrecheck } from "../model/types";
import type { Service } from "../../../entities/service";
import type { ReleasePrecheck } from "../model/types";
export function getReleasePrechecks(service: Service, environment: string): ReleasePrecheck[] {
const deploymentService = getHyappDeploymentServiceById(service.id);
const isProd = environment === "prod";
const hasProdTarget = Boolean(deploymentService?.prod);
const hasRuntimeTarget = Boolean(service.unit && service.container && service.targetPort);
return [
{
detail: `${service.readyInstances}/${service.desiredInstances} 可用`,
id: "instances",
label: "实例状态",
result: service.readyInstances === service.desiredInstances ? "passed" : "warning",
detail: `${service.readyInstances}/${service.desiredInstances} 可用`,
},
{
detail: hasRuntimeTarget ? `${service.unit} / ${service.container}` : "TAT 未返回部署目标",
id: "deploy-target",
label: "部署目标",
result: !isProd || hasProdTarget ? "passed" : "warning",
detail:
isProd && !hasProdTarget
? "生产 inventory 未绑定该服务"
: `systemd unit ${deploymentService?.prod?.unit ?? deploymentService?.local.composeService ?? service.name}`,
},
{
id: "dependencies",
label: "托管依赖",
result: "passed",
detail: hyappDeploymentPlan.managedDependencies.map((item) => item.name).join("、") + " 不随发布重启",
result: !isProd || hasRuntimeTarget ? "passed" : "warning",
},
{
detail: service.configPath ?? "TAT 未返回配置路径",
id: "config",
label: "配置变更",
result: "passed",
detail: deploymentService?.prod?.configPath ?? deploymentService?.local.configPath ?? "按服务配置模板校验",
label: "配置",
result: service.configPath ? "passed" : "warning",
},
{
detail: `${service.name}:${service.version}`,
id: "image",
label: "镜像拉取",
result: "passed",
detail: `${hyappDeploymentPlan.registry}/${service.name}:${service.version}`,
label: "镜像",
result: service.version && service.version !== "未采集" ? "passed" : "warning",
},
];
}

View File

@ -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 },
];
}

View File

@ -10,8 +10,10 @@ export type ReleasePrecheck = {
export type CreateReleaseRequest = {
confirmed?: boolean;
environment: string;
fromVersion: string;
operator: string;
serviceId: string;
serviceName: string;
strategy: ReleaseStrategy;
targetVersion: string;
};

View File

@ -207,6 +207,30 @@
line-height: 18px;
}
.tagInput {
min-height: 38px;
width: 100%;
min-width: 0;
padding: 0 12px;
color: var(--color-text-primary);
border: 1px solid rgba(255, 255, 255, 0.66);
border-radius: 14px;
outline: none;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.62), rgba(241, 248, 255, 0.32)),
rgba(255, 255, 255, 0.32);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
inset 0 -1px 0 rgba(91, 125, 166, 0.08);
font-size: 13px;
font-weight: 800;
}
.tagInput:focus {
border-color: rgba(47, 125, 246, 0.42);
box-shadow: var(--shadow-liquid-control-hover);
}
.strategyList,
.precheckList,
.summary {

View File

@ -1,21 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { AlertTriangle, CheckCircle2, Loader2, Rocket, X } from "lucide-react";
import { Environment } from "../../../app/store/useAppContextStore";
import {
getHyappDeploymentHosts,
getHyappDeploymentServiceById,
hyappDeploymentPlan,
} from "../../../entities/deployment-plan";
import { Service } from "../../../entities/service";
import { Button } from "../../../shared/ui/Button";
import { Checkbox } from "../../../shared/ui/Checkbox";
import { IconButton } from "../../../shared/ui/IconButton";
import { Radio } from "../../../shared/ui/Radio";
import { Select } from "../../../shared/ui/Select";
import { Tag } from "../../../shared/ui/Tag";
import { useCreateReleaseMutation } from "../api/useCreateReleaseMutation";
import { getReleasePrechecks } from "../lib/getReleasePrechecks";
import { getReleaseVersionOptions } from "../lib/getReleaseVersionOptions";
import { ReleaseStrategy } from "../model/types";
import styles from "./ReleaseDrawer.module.css";
@ -45,10 +38,7 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user
const [confirmed, setConfirmed] = useState(false);
const releaseMutation = useCreateReleaseMutation();
const versionOptions = useMemo(() => (service ? getReleaseVersionOptions(service.version) : []), [service]);
const prechecks = useMemo(() => (service ? getReleasePrechecks(service, environment) : []), [environment, service]);
const deployTarget = useMemo(() => (service ? getHyappDeploymentServiceById(service.id) : undefined), [service]);
const deployHosts = useMemo(() => (deployTarget ? getHyappDeploymentHosts(deployTarget) : []), [deployTarget]);
useEffect(() => {
if (!service) {
@ -56,34 +46,28 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user
}
setStrategy("rolling");
setTargetVersion(versionOptions[0]?.value ?? service.version);
setTargetVersion("");
setConfirmed(false);
releaseMutation.reset();
}, [releaseMutation, service, versionOptions]);
}, [releaseMutation, service]);
if (!service) {
return null;
}
const isProd = environment === "prod";
const hasExecutableTarget = !isProd || Boolean(deployTarget?.prod);
const hasExecutableTarget = !isProd || Boolean(service.unit && service.container && service.targetPort);
const canSubmit = targetVersion !== "" && targetVersion !== service.version && hasExecutableTarget && (!isProd || confirmed);
const hasWarning = prechecks.some((item) => item.result === "warning");
const deployCommand =
isProd && deployTarget?.prod
? [
"python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy",
" --env-file /opt/hy-app-monitor/.env",
" --inventory deploy/tencent-tat/hyapp-services.inventory.prod.json",
` --services ${service.name}`,
` --tag ${targetVersion}`,
" --yes",
].join("\n")
: deployTarget?.local.commands.rebuild ?? `cd ${hyappDeploymentPlan.repositoryPath} && make rebuild SERVICE=${service.name}`;
const targetSummary =
isProd && deployTarget?.prod
? `${deployTarget.prod.unit} / ${deployTarget.prod.clb.loadBalancerId}`
: deployTarget?.local.composeService ?? service.name;
const deployCommand = [
"python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy",
" --env-file /opt/hy-app-monitor/.env",
" --inventory deploy/tencent-tat/hyapp-services.inventory.prod.json",
` --services ${service.name}`,
` --tag ${targetVersion || "<tag>"}`,
isProd ? " --yes" : " --dry-run",
].join("\n");
const targetSummary = service.unit && service.container ? `${service.unit} / ${service.container}` : service.name;
async function handleSubmit() {
if (!service || !canSubmit) {
@ -95,6 +79,8 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user
confirmed,
operator: userName,
serviceId: service.id,
serviceName: service.name,
fromVersion: service.version,
strategy,
targetVersion,
});
@ -128,7 +114,13 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user
<span className={styles.step}>1</span>
<div className={styles.sectionContent}>
<h3></h3>
<Select aria-label="选择目标版本" onValueChange={setTargetVersion} options={versionOptions} value={targetVersion} />
<input
aria-label="目标镜像 tag"
className={styles.tagInput}
onChange={(event) => setTargetVersion(event.target.value.trim())}
placeholder="输入真实镜像 tag"
value={targetVersion}
/>
<p>
<Tag tone="brand">{service.version}</Tag>
</p>
@ -166,9 +158,7 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user
<span>
<strong></strong>
<small>
{deployHosts.length > 0
? deployHosts.map((host) => host.id).join("、")
: "local-compose"}
{service.sourceHosts?.length ? service.sourceHosts.join("、") : "TAT 未返回"}
</small>
</span>
</div>

View File

@ -1,5 +0,0 @@
import { PlaceholderPage } from "../placeholder";
export function AuditPage() {
return <PlaceholderPage title="权限与审计" />;
}

View File

@ -1 +0,0 @@
export { AuditPage } from "./AuditPage";

View File

@ -1,5 +0,0 @@
import { PlaceholderPage } from "../placeholder";
export function LogsEventsPage() {
return <PlaceholderPage title="日志与事件" />;
}

View File

@ -1 +0,0 @@
export { LogsEventsPage } from "./LogsEventsPage";

View File

@ -1,7 +1,8 @@
import { AlertTriangle, Bell, Database, Layers3, Rocket } from "lucide-react";
import { AlertTriangle, Database, Layers3, Server } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { getServiceSummary, useServicesQuery } from "../../entities/service";
import { useAppContextStore } from "../../app/store/useAppContextStore";
import { useDataResourcesQuery } from "../../entities/data-resource";
import { useReleaseDrawerStore } from "../../features/release-service";
import { Card } from "../../shared/ui/Card";
import { MetricCard } from "../../shared/ui/MetricCard";
@ -18,8 +19,13 @@ export function OverviewPage() {
environment,
projectId,
});
const dataResourcesQuery = useDataResourcesQuery();
const services = servicesQuery.data?.items ?? [];
const summary = getServiceSummary(services);
const dataResourceSummary = dataResourcesQuery.data?.summary;
const dataResourceHealthyText = dataResourceSummary
? `${dataResourceSummary.healthy}/${Object.values(dataResourceSummary).reduce((total, value) => total + value, 0)}`
: "-";
return (
<div className="page">
@ -35,8 +41,14 @@ export function OverviewPage() {
<section className={styles.metrics}>
<MetricCard
icon={<Layers3 size={24} />}
label="运行中服务数"
meta="Healthy"
label="服务总数"
meta="Inventory"
value={summary.totalCount}
/>
<MetricCard
icon={<Server size={24} />}
label="健康服务数"
meta="TAT"
tone="green"
value={summary.healthyCount}
/>
@ -47,25 +59,12 @@ export function OverviewPage() {
tone="orange"
value={summary.abnormalCount}
/>
<MetricCard
icon={<Rocket size={24} />}
label="最近发布"
meta={summary.latestRelease.serviceName}
value={summary.latestRelease.version}
/>
<MetricCard
icon={<Bell size={24} />}
label="活跃告警"
meta="未恢复"
tone="red"
value={summary.activeAlerts}
/>
<MetricCard
icon={<Database size={24} />}
label="数据库状态"
meta="正常资源"
label="数据资源"
meta="健康 / 总数"
tone="green"
value={summary.databaseHealthyText}
value={dataResourceHealthyText}
/>
</section>

View File

@ -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>
);
}

View File

@ -1 +0,0 @@
export { PlaceholderPage } from "./PlaceholderPage";

View File

@ -1,65 +1,167 @@
import { CloudCog, Database, FileText, HardDrive, PlayCircle, ServerCog, ShieldAlert } from "lucide-react";
import {
CloudCog,
Database,
FileText,
GitBranch,
HardDrive,
Network,
PlayCircle,
ServerCog,
ShieldAlert,
TerminalSquare,
} from "lucide-react";
import {
getHyappDeploymentHosts,
hyappDeploymentPlan,
type HyappDeploymentService,
type DeployApiDiscoverResponse,
type DeployApiInventoryResponse,
useDeployApiDiscoverQuery,
useDeployApiInventoryQuery,
useDeployApiPlanQuery,
useDeployApiTestboxConfigsQuery,
} from "../../entities/deployment-plan";
import { mockServices } from "../../entities/service";
import type { Service, ServiceStatus } from "../../entities/service";
import { useReleaseDrawerStore } from "../../features/release-service";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
import { EmptyState } from "../../shared/ui/EmptyState";
import { StatusBadge } from "../../shared/ui/StatusBadge";
import { Table, type TableColumn } from "../../shared/ui/Table";
import { Tag } from "../../shared/ui/Tag";
import styles from "./ReleasesPage.module.css";
const remoteDiscoverServices = hyappDeploymentPlan.services.filter((service) => service.prod).map((service) => service.name);
const testboxConfigServices = [
"gateway-service",
"room-service",
"wallet-service",
"user-service",
"activity-service",
"cron-service",
"game-service",
"notice-service",
"admin-server",
];
type RuntimeService = NonNullable<NonNullable<DeployApiDiscoverResponse["hosts"]>[number]["services"]>[number] & {
host: string;
privateIp: string;
};
type ServiceRow = {
clb: DeployApiInventoryResponse["services"][string]["clb"];
configPath: string;
container: string;
hosts: string[];
image: string;
service: Service;
targetPort: number | null;
unit: string;
};
function serviceNameToId(serviceName: string) {
return `svc-${serviceName.replace(/-service$/, "")}`;
}
function averagePercent(values: Array<number | null | undefined>) {
const numericValues = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
if (numericValues.length === 0) {
return 0;
}
return Math.round(numericValues.reduce((total, value) => total + value, 0) / numericValues.length);
}
function mostFrequent(values: Array<string | undefined>) {
const counts = new Map<string, number>();
values.forEach((value) => {
const text = String(value || "").trim();
if (text) {
counts.set(text, (counts.get(text) ?? 0) + 1);
}
});
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
}
function statusFromRuntime(runtimes: RuntimeService[], desiredInstances: number): ServiceStatus {
const readyCount = runtimes.filter((runtime) => runtime.status === "healthy").length;
if (desiredInstances > 0 && readyCount >= desiredInstances) {
return "healthy";
}
if (readyCount > 0) {
return "warning";
}
if (runtimes.some((runtime) => runtime.status === "running")) {
return "running";
}
return runtimes.length > 0 ? "critical" : "unknown";
}
function runtimeServices(discover: DeployApiDiscoverResponse | undefined): RuntimeService[] {
return (discover?.hosts ?? []).flatMap((host) =>
(host.services ?? []).map((service) => ({
...service,
host: host.host,
privateIp: host.private_ip,
})),
);
}
function buildRows(
inventory: DeployApiInventoryResponse | undefined,
discover: DeployApiDiscoverResponse | undefined,
): ServiceRow[] {
if (!inventory?.services) {
return [];
}
const runtimes = runtimeServices(discover);
return Object.entries(inventory.services).map(([serviceName, deployService]) => {
const serviceRuntimes = runtimes.filter((runtime) => runtime.name === serviceName);
const hosts = deployService.hosts ?? [];
const desiredInstances = Math.max(hosts.length, serviceRuntimes.length, 1);
const readyInstances = serviceRuntimes.filter((runtime) => runtime.status === "healthy").length;
const version = mostFrequent(serviceRuntimes.map((runtime) => runtime.imageTag || runtime.image)) || "未采集";
const runningTime = mostFrequent(serviceRuntimes.map((runtime) => runtime.runningTime));
const firstRuntime = serviceRuntimes[0];
const unit = deployService.unit ?? firstRuntime?.unit ?? "";
const container = deployService.container ?? firstRuntime?.container ?? "";
const configPath = firstRuntime?.configPath ?? deployService.config_path ?? "";
const targetPort = deployService.target_port ?? firstRuntime?.targetPort ?? null;
const service: Service = {
configPath,
container,
cpuUsage: averagePercent(serviceRuntimes.map((runtime) => runtime.cpuPercent)),
desiredInstances,
id: serviceNameToId(serviceName),
lastReleaseText: runningTime ? `运行 ${runningTime}` : "未采集",
memoryUsage: averagePercent(serviceRuntimes.map((runtime) => runtime.memoryPercent)),
name: serviceName,
namespace: "prod",
owner: deployService.owner ?? "未配置",
readyInstances,
sourceHosts: serviceRuntimes.map((runtime) => runtime.host),
status: statusFromRuntime(serviceRuntimes, desiredInstances),
targetPort: targetPort ?? undefined,
unit,
version,
};
return {
clb: deployService.clb,
configPath,
container,
hosts,
image: mostFrequent(serviceRuntimes.map((runtime) => runtime.image)) || deployService.image_template || "",
service,
targetPort,
unit,
};
});
}
export function ReleasesPage() {
const openRelease = useReleaseDrawerStore((state) => state.openRelease);
const apiInventoryQuery = useDeployApiInventoryQuery();
const apiPlanQuery = useDeployApiPlanQuery(["gateway-service", "room-service", "user-service"]);
const apiDiscoverQuery = useDeployApiDiscoverQuery(remoteDiscoverServices, true);
const testboxConfigQuery = useDeployApiTestboxConfigsQuery(testboxConfigServices);
const apiStatusTone = apiInventoryQuery.isSuccess ? "success" : apiInventoryQuery.isError ? "warning" : "running";
const apiStatusText = apiInventoryQuery.isSuccess ? "已连接" : apiInventoryQuery.isError ? "未连接" : "查询中";
const adminProd = hyappDeploymentPlan.adminProduction;
const testbox = hyappDeploymentPlan.testbox;
const serviceNames = Object.keys(apiInventoryQuery.data?.services ?? {});
const apiPlanQuery = useDeployApiPlanQuery(serviceNames, serviceNames.length > 0);
const apiDiscoverQuery = useDeployApiDiscoverQuery(serviceNames, serviceNames.length > 0);
const testboxConfigQuery = useDeployApiTestboxConfigsQuery(serviceNames, serviceNames.length > 0);
const rows = buildRows(apiInventoryQuery.data, apiDiscoverQuery.data);
const remoteHosts = apiDiscoverQuery.data?.hosts ?? [];
const remoteClbSnapshots = apiDiscoverQuery.data?.clb ?? [];
const lastRemoteSyncedAt = apiDiscoverQuery.dataUpdatedAt
? new Date(apiDiscoverQuery.dataUpdatedAt).toLocaleTimeString("zh-CN", { hour12: false })
: "-";
const remoteHostSuccessCount = remoteHosts.filter((host) => host.status === "SUCCESS").length;
const remoteClbSuccessCount = remoteClbSnapshots.filter((snapshot) => !snapshot.error).length;
const remoteErrorCount =
remoteHosts.filter((host) => host.status !== "SUCCESS").length + remoteClbSnapshots.filter((snapshot) => snapshot.error).length;
const lastRemoteSyncedAt = apiDiscoverQuery.dataUpdatedAt
? new Date(apiDiscoverQuery.dataUpdatedAt).toLocaleTimeString("zh-CN", { hour12: false })
: "-";
const apiStatusTone = apiInventoryQuery.isSuccess ? "success" : apiInventoryQuery.isError ? "warning" : "running";
const apiStatusText = apiInventoryQuery.isSuccess ? "已连接" : apiInventoryQuery.isError ? "未连接" : "查询中";
const remoteStatusTone = apiDiscoverQuery.isFetching && !apiDiscoverQuery.data
? "running"
: apiDiscoverQuery.isError || remoteErrorCount > 0
@ -76,95 +178,80 @@ export function ReleasesPage() {
: apiDiscoverQuery.isSuccess
? "已同步"
: "未查询";
const remoteSummaryText = apiDiscoverQuery.isSuccess
? `实例 ${remoteHostSuccessCount}/${remoteHosts.length}CLB ${remoteClbSuccessCount}/${remoteClbSnapshots.length},最近刷新 ${lastRemoteSyncedAt}`
: `覆盖 ${remoteDiscoverServices.length} 个生产服务,通过腾讯云 TAT 读取运行态15 秒自动刷新`;
const handleRemoteDiscover = () => {
void apiDiscoverQuery.refetch();
};
const testboxConfigTone = testboxConfigQuery.isSuccess ? "success" : testboxConfigQuery.isError ? "warning" : "running";
const testboxConfigText = testboxConfigQuery.isSuccess ? "已读取" : testboxConfigQuery.isError ? "读取失败" : "读取中";
const handleTestboxConfigRefresh = () => {
void testboxConfigQuery.refetch();
};
const serviceColumns: TableColumn<HyappDeploymentService>[] = [
const serviceColumns: TableColumn<ServiceRow>[] = [
{
key: "service",
title: "服务",
render: (service) => (
render: (row) => (
<span className={styles.serviceCell}>
<strong>{service.name}</strong>
<small>{service.domain}</small>
<strong>{row.service.name}</strong>
<small>{row.configPath || "TAT 未返回配置路径"}</small>
</span>
),
},
{
key: "status",
title: "状态",
render: (service) => <StatusBadge status={service.status} />,
render: (row) => <StatusBadge status={row.service.status} />,
},
{
key: "local",
title: "本地 Compose",
render: (service) => (
<span className={styles.inlineTags}>
<Tag tone="brand">{service.local.composeService}</Tag>
<Tag>{service.local.aliases[0]}</Tag>
key: "version",
title: "镜像",
render: (row) => (
<span className={styles.stackText}>
<strong>{row.service.version}</strong>
<small>{row.image || "TAT 未返回镜像"}</small>
</span>
),
},
{
key: "prod",
title: "生产目标",
render: (service) =>
service.prod ? (
<span className={styles.hostList}>
{getHyappDeploymentHosts(service).map((host) => (
<Tag key={host.id} tone="success">
{host.id}
</Tag>
))}
</span>
) : (
<Tag tone="warning"> TAT inventory</Tag>
),
key: "instances",
title: "实例",
render: (row) => `${row.service.readyInstances}/${row.service.desiredInstances}`,
},
{
key: "unit",
key: "target",
title: "Unit / 端口",
render: (service) => (
render: (row) => (
<span className={styles.stackText}>
<strong>{service.prod?.unit ?? service.local.composeService}</strong>
<small>{service.prod?.targetPort ?? service.local.grpcPort ?? service.local.httpPort}</small>
<strong>{row.unit || "TAT 未返回"}</strong>
<small>{row.targetPort ?? "TAT 未返回"}</small>
</span>
),
},
{
key: "hosts",
title: "机器",
render: (row) => (
<span className={styles.hostList}>
{row.hosts.length > 0 ? row.hosts.map((host) => <Tag key={host}>{host}</Tag>) : <Tag tone="warning">TAT </Tag>}
</span>
),
},
{
key: "clb",
title: "CLB",
render: (service) =>
service.prod ? (
render: (row) =>
row.clb?.enabled ? (
<span className={styles.stackText}>
<strong>{service.prod.clb.loadBalancerId}</strong>
<small>{service.prod.clb.entrypoint}</small>
<strong>{row.clb.load_balancer_id ?? "TAT 未返回"}</strong>
<small>{row.clb.listener_ids?.join("、") || "TAT 未返回 listener"}</small>
</span>
) : (
<span className="text-muted">local only</span>
<span className="text-muted"></span>
),
},
{
className: styles.actions,
key: "action",
title: "操作",
className: styles.actions,
render: (service) => {
const serviceEntity = mockServices.find((item) => item.id === service.id);
return (
<Button disabled={!serviceEntity} onClick={() => serviceEntity && openRelease(serviceEntity)} size="sm" variant="secondary">
</Button>
);
},
render: (row) => (
<Button disabled={row.service.status === "unknown"} onClick={() => openRelease(row.service)} size="sm" variant="secondary">
</Button>
),
},
];
@ -172,30 +259,18 @@ export function ReleasesPage() {
<div className="page">
<div className="page-header">
<div>
<h1 className="page-title">HyApp </h1>
<p className="page-subtitle">
{hyappDeploymentPlan.repositoryPath} Composesystemd Tencent TAT
</p>
<h1 className="page-title"></h1>
</div>
</div>
<section className={styles.heroGrid}>
<Card className={styles.heroCard} padded={false}>
<span className={styles.heroIcon}>
<GitBranch size={22} />
</span>
<div>
<span></span>
<strong>{hyappDeploymentPlan.repositoryPath}</strong>
</div>
</Card>
<Card className={styles.heroCard} padded={false}>
<span className={styles.heroIcon}>
<CloudCog size={22} />
</span>
<div>
<span></span>
<strong>{hyappDeploymentPlan.region}</strong>
<span></span>
<strong>{apiInventoryQuery.data?.region ?? "-"}</strong>
</div>
</Card>
<Card className={styles.heroCard} padded={false}>
@ -204,7 +279,7 @@ export function ReleasesPage() {
</span>
<div>
<span></span>
<strong>{hyappDeploymentPlan.registry}</strong>
<strong>{apiInventoryQuery.data?.registry ?? "-"}</strong>
</div>
</Card>
<Card className={styles.heroCard} padded={false}>
@ -212,245 +287,62 @@ export function ReleasesPage() {
<ServerCog size={22} />
</span>
<div>
<span></span>
<strong>
{hyappDeploymentPlan.deployServer.id} / {hyappDeploymentPlan.deployServer.privateIp}
</strong>
<span></span>
<strong>{serviceNames.length}</strong>
</div>
</Card>
<Card className={styles.heroCard} padded={false}>
<span className={styles.heroIcon}>
<PlayCircle size={22} />
</span>
<div>
<span></span>
<strong>{apiPlanQuery.data?.steps.length ?? "-"}</strong>
</div>
</Card>
</section>
<section className={styles.modeGrid}>
{hyappDeploymentPlan.modes.map((mode) => (
<Card className={styles.modeCard} key={mode.id} padded={false}>
<div className={styles.modeHeader}>
{mode.id === "local-compose" ? <TerminalSquare size={20} /> : <Network size={20} />}
<h2>{mode.title}</h2>
</div>
<p>{mode.description}</p>
</Card>
))}
</section>
<Card className={styles.adminProdPanel} padded={false}>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<span>
{adminProd.frontend.privateIp}:{adminProd.frontend.port} {adminProd.backend.listenAddr}
</span>
</div>
<Tag tone="success" size="md">
{adminProd.backend.nodeId}
</Tag>
</div>
<div className={styles.adminProdGrid}>
<div>
<span></span>
<strong>
{adminProd.frontend.publicIp}:{adminProd.frontend.port}
</strong>
<small>
{adminProd.frontend.instanceId} / {adminProd.frontend.privateIp}
</small>
</div>
<div>
<span></span>
<strong>{adminProd.frontend.timer}</strong>
<small>
{adminProd.frontend.branch} {adminProd.frontend.deployScript}
</small>
</div>
<div>
<span>admin-server </span>
<strong>{adminProd.backend.listenAddr}</strong>
<small>
{adminProd.backend.instanceId} / {adminProd.backend.publicIp}
</small>
</div>
<div>
<span></span>
<strong>{adminProd.backend.unit}</strong>
<small>{adminProd.backend.wgUnit}</small>
</div>
</div>
<div className={styles.adminRouteList}>
<div>
<strong> API </strong>
<span>{adminProd.frontend.apiProxy}</span>
<small>{adminProd.frontend.nginxConfig}</small>
</div>
<div>
<strong></strong>
<span>{adminProd.backend.binaryPath}</span>
<small>{adminProd.backend.configPath}</small>
</div>
<div>
<strong></strong>
<span>
{adminProd.previousBackend.name} / {adminProd.previousBackend.publicIp}
</span>
<small>{adminProd.previousBackend.status}</small>
</div>
</div>
<div className={styles.endpointList}>
{adminProd.endpoints.map((endpoint) => (
<a href={endpoint.url} key={endpoint.url} rel="noreferrer" target="_blank">
<strong>{endpoint.label}</strong>
<span>{endpoint.url}</span>
</a>
))}
</div>
</Card>
<Card className={styles.testboxPanel} padded={false}>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<span>
{testbox.publicIp} / {testbox.privateIp} {testbox.branch} timer
</span>
</div>
<Tag tone="success" size="md">
{testbox.timer}
</Tag>
</div>
<div className={styles.testboxGrid}>
<div>
<span></span>
<strong>{testbox.deployScript}</strong>
<small>{testbox.root}</small>
</div>
<div>
<span></span>
<strong>{testbox.source}</strong>
<small>{testbox.composeCommand}</small>
</div>
<div>
<span></span>
<strong>{testbox.cleanupTimer}</strong>
<small>退</small>
</div>
<div>
<span>Admin API</span>
<strong>{testbox.adminApiUrl}</strong>
<small>广 admin-platform /api </small>
</div>
<div>
<span></span>
<strong>{testbox.adminPlatformUrl}</strong>
<small>广 172.16.0.10:7001{testbox.adminPlatformBranch} </small>
</div>
</div>
<div className={styles.endpointList}>
{testbox.endpoints.map((endpoint) => (
<a href={endpoint.url} key={endpoint.url} rel="noreferrer" target="_blank">
<strong>{endpoint.label}</strong>
<span>{endpoint.url}</span>
</a>
))}
</div>
</Card>
<Card className={styles.yamlPanel} padded={false}>
<div className={styles.panelHeader}>
<div>
<h2> Go YAML</h2>
<span>
{testboxConfigQuery.data?.target?.root ?? testbox.root}{" "}
{testboxConfigQuery.data?.deployedCommit?.slice(0, 12) ?? "-"}
</span>
</div>
<div className={styles.panelActions}>
<Tag tone={testboxConfigTone} size="md">
{testboxConfigText}
</Tag>
<Button
disabled={testboxConfigQuery.isFetching}
isLoading={testboxConfigQuery.isLoading}
onClick={handleTestboxConfigRefresh}
size="sm"
variant="secondary"
>
YAML
</Button>
</div>
</div>
{testboxConfigQuery.isError ? (
<div className={styles.apiNotice}>
{testboxConfigQuery.error instanceof Error ? testboxConfigQuery.error.message : "测试机 YAML 读取失败"}
</div>
) : null}
<div className={styles.yamlList}>
{(testboxConfigQuery.data?.files ?? []).map((file) => (
<div className={styles.yamlItem} key={file.path}>
<div>
<FileText size={16} />
<strong>{file.service}</strong>
<small>{file.path}</small>
{!file.exists ? <Tag tone="warning"></Tag> : null}
{file.truncated ? <Tag tone="warning"></Tag> : null}
</div>
<pre>
<code>{file.exists ? file.content : "文件不存在"}</code>
</pre>
</div>
))}
</div>
</Card>
<Card className={styles.apiPanel} padded={false}>
<div className={styles.panelHeader}>
<div>
<h2>Deploy API</h2>
<span> `/deploy/api/*` inventory </span>
<span>inventoryTAT </span>
</div>
<Tag tone={apiStatusTone} size="md">
{apiStatusText}
</Tag>
</div>
<div className={styles.apiGrid}>
<div>
<span>Inventory </span>
<strong>
{apiInventoryQuery.data ? Object.keys(apiInventoryQuery.data.services).length : "-"}
</strong>
</div>
<div>
<span>Plan </span>
<strong>{apiPlanQuery.data?.steps.length ?? "-"}</strong>
</div>
<div>
<span>Registry</span>
<strong>{apiInventoryQuery.data?.registry ?? hyappDeploymentPlan.registry}</strong>
</div>
<div>
<span></span>
<strong>{apiInventoryQuery.error instanceof Error ? apiInventoryQuery.error.message : "无"}</strong>
</div>
</div>
<div className={styles.apiActions}>
<div>
<strong></strong>
<span>{remoteSummaryText}</span>
<span>
{remoteHostSuccessCount}/{remoteHosts.length}CLB {remoteClbSuccessCount}/{remoteClbSnapshots.length}{" "}
{lastRemoteSyncedAt}
</span>
</div>
<div>
<Tag tone={remoteStatusTone} size="md">
{remoteStatusText}
</Tag>
<Button
disabled={apiDiscoverQuery.isFetching}
disabled={apiDiscoverQuery.isFetching || serviceNames.length === 0}
isLoading={apiDiscoverQuery.isLoading}
onClick={handleRemoteDiscover}
onClick={() => void apiDiscoverQuery.refetch()}
size="sm"
variant="secondary"
>
</Button>
</div>
</div>
{apiDiscoverQuery.isError ? (
<div className={styles.apiNotice}>{apiDiscoverQuery.error instanceof Error ? apiDiscoverQuery.error.message : "真实状态查询失败"}</div>
{apiInventoryQuery.isError || apiDiscoverQuery.isError ? (
<div className={styles.apiNotice}>
{apiInventoryQuery.error instanceof Error
? apiInventoryQuery.error.message
: apiDiscoverQuery.error instanceof Error
? apiDiscoverQuery.error.message
: "真实状态查询失败"}
</div>
) : null}
{apiDiscoverQuery.isSuccess ? (
<div className={styles.discoveryGrid}>
@ -489,96 +381,108 @@ export function ReleasesPage() {
<div className={styles.panelHeader}>
<div>
<h2></h2>
<span>Compose systemd unitCLB </span>
<span> inventory TAT discover</span>
</div>
<Tag tone="brand" size="md">
{hyappDeploymentPlan.services.length}
{rows.length}
</Tag>
</div>
<Table columns={serviceColumns} data={hyappDeploymentPlan.services} getRowKey={(service) => service.id} />
<Table
columns={serviceColumns}
data={rows}
empty={<EmptyState title="暂无服务" description="deploy API 未返回服务 inventory。" />}
getRowKey={(row) => row.service.id}
/>
</Card>
<Card className={styles.yamlPanel} padded={false}>
<div className={styles.panelHeader}>
<div>
<h2> YAML</h2>
<span>
{testboxConfigQuery.data?.target?.root ?? "-"} {testboxConfigQuery.data?.deployedCommit?.slice(0, 12) ?? "-"}
</span>
</div>
<div className={styles.panelActions}>
<Tag tone={testboxConfigTone} size="md">
{testboxConfigText}
</Tag>
<Button
disabled={testboxConfigQuery.isFetching || serviceNames.length === 0}
isLoading={testboxConfigQuery.isLoading}
onClick={() => void testboxConfigQuery.refetch()}
size="sm"
variant="secondary"
>
YAML
</Button>
</div>
</div>
{testboxConfigQuery.isError ? (
<div className={styles.apiNotice}>
{testboxConfigQuery.error instanceof Error ? testboxConfigQuery.error.message : "测试机 YAML 读取失败"}
</div>
) : null}
<div className={styles.yamlList}>
{(testboxConfigQuery.data?.files ?? []).map((file) => (
<div className={styles.yamlItem} key={file.path}>
<div>
<FileText size={16} />
<strong>{file.service}</strong>
<small>{file.path}</small>
{!file.exists ? <Tag tone="warning"></Tag> : null}
{file.truncated ? <Tag tone="warning"></Tag> : null}
</div>
<pre>
<code>{file.exists ? file.content : "文件不存在"}</code>
</pre>
</div>
))}
</div>
</Card>
<div className={styles.contentGrid}>
<Card className={styles.panel} padded={false}>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<span>TAT </span>
</div>
</div>
<div className={styles.stepList}>
{hyappDeploymentPlan.productionSteps.map((step, index) => (
<div className={styles.stepItem} key={step.id}>
<span>{index + 1}</span>
<div>
<strong>{step.title}</strong>
<small>{step.detail}</small>
</div>
</div>
))}
</div>
</Card>
<Card className={styles.panel} padded={false}>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<span> TAT </span>
<span> inventory</span>
</div>
</div>
<div className={styles.dependencyList}>
{hyappDeploymentPlan.managedDependencies.map((dependency) => (
<div className={styles.dependencyItem} key={dependency.id}>
{Object.entries(apiInventoryQuery.data?.managedDependencies ?? {}).map(([id, name]) => (
<div className={styles.dependencyItem} key={id}>
<Database size={18} />
<div>
<strong>{dependency.name}</strong>
<span>{dependency.provider}</span>
<small>{dependency.boundary}</small>
<strong>{name}</strong>
<span>{id}</span>
</div>
</div>
))}
</div>
</Card>
</div>
<div className={styles.contentGrid}>
<Card className={styles.panel} padded={false}>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<span>Makefile TAT </span>
</div>
</div>
<div className={styles.commandList}>
{hyappDeploymentPlan.commands.map((command) => (
<div className={styles.commandItem} key={command.id}>
<div>
<PlayCircle size={17} />
<strong>{command.title}</strong>
</div>
<pre>
<code>{command.command}</code>
</pre>
</div>
))}
</div>
</Card>
<Card className={styles.panel} padded={false}>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<span>hyapp-server </span>
<h2></h2>
<span></span>
</div>
<ShieldAlert size={20} />
</div>
<div className={styles.guardList}>
{hyappDeploymentPlan.prohibitedActions.map((action) => (
<div className={styles.guardItem} key={action}>
<span />
<strong>{action}</strong>
</div>
))}
<div className={styles.guardItem}>
<span />
<strong>deploy / restart confirmed=true</strong>
</div>
<div className={styles.guardItem}>
<span />
<strong>使 dryRun=true</strong>
</div>
<div className={styles.guardItem}>
<span />
<strong> TAT</strong>
</div>
</div>
</Card>
</div>

View 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;
}
}

View File

@ -1,5 +1,292 @@
import { PlaceholderPage } from "../placeholder";
import { AlertTriangle, Cable, Database, HardDrive, RefreshCcw, Server, ShieldCheck } from "lucide-react";
import { useMemo } from "react";
import { useDataResourcesQuery, type DataResource, type DataResourceStatus, type DataResourceType } from "../../entities/data-resource";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
import { EmptyState } from "../../shared/ui/EmptyState";
import { MetricCard } from "../../shared/ui/MetricCard";
import { ProgressMeter } from "../../shared/ui/ProgressMeter";
import { StatusBadge, type StatusBadgeStatus } from "../../shared/ui/StatusBadge";
import { Table, type TableColumn } from "../../shared/ui/Table";
import { Tag } from "../../shared/ui/Tag";
import styles from "./ResourcesPage.module.css";
const typeLabel: Record<DataResourceType, string> = {
kafka: "Kafka",
mongodb: "MongoDB",
mq: "MQ",
mysql: "MySQL",
redis: "Redis",
rocketmq: "RocketMQ",
unknown: "未知",
};
const typeTone: Record<DataResourceType, "brand" | "neutral" | "success" | "warning"> = {
kafka: "warning",
mongodb: "success",
mq: "brand",
mysql: "brand",
redis: "success",
rocketmq: "brand",
unknown: "neutral",
};
const statusTone: Record<DataResourceStatus, StatusBadgeStatus> = {
critical: "critical",
healthy: "healthy",
unknown: "unknown",
warning: "warning",
};
function formatNumber(value: number | null | undefined, suffix = "") {
return typeof value === "number" ? `${value}${suffix}` : "未采集";
}
function formatTime(value: string | undefined) {
return value ? new Date(value).toLocaleTimeString("zh-CN", { hour12: false }) : "-";
}
function metricTone(status: DataResourceStatus) {
if (status === "healthy") {
return "green";
}
if (status === "critical") {
return "red";
}
if (status === "warning") {
return "orange";
}
return "blue";
}
function isServerSourceReady(status: string) {
return status === "SUCCESS" || status === "TAT_FALLBACK_SUCCESS";
}
function ResourceNameCell({ resource }: { resource: DataResource }) {
return (
<span className={styles.resourceCell}>
<strong>{resource.name}</strong>
<small>{resource.endpoints.length > 0 ? resource.endpoints.join("、") : resource.description || "未采集"}</small>
</span>
);
}
function LoadingState() {
return (
<Card className={styles.loadingPanel} padded={false}>
<div className={styles.loadingHeader}>
<span />
<div>
<strong></strong>
</div>
</div>
<div className={styles.skeletonGrid}>
{Array.from({ length: 3 }).map((_, index) => (
<div className={styles.skeletonCard} key={index}>
<span />
<strong />
<small />
</div>
))}
</div>
</Card>
);
}
export function ResourcesPage() {
return <PlaceholderPage title="数据漫游" />;
const dataResourcesQuery = useDataResourcesQuery();
const resources = dataResourcesQuery.data?.resources ?? [];
const isInitialLoading = dataResourcesQuery.isLoading || (dataResourcesQuery.isFetching && !dataResourcesQuery.data);
const sourceHosts = dataResourcesQuery.data?.sourceHosts ?? [];
const lastSyncedAt = formatTime(dataResourcesQuery.data?.lastSyncedAt);
const deployTarget = dataResourcesQuery.data?.target ?? dataResourcesQuery.data?.tat?.target;
const sourceOkCount = sourceHosts.filter((host) => isServerSourceReady(host.status)).length;
const checkCount = resources.reduce((total, resource) => total + resource.checks.length, 0);
const healthyCount = resources.filter((resource) => resource.status === "healthy").length;
const abnormalCount = resources.filter((resource) => resource.status === "warning" || resource.status === "critical").length;
const tatStatus = dataResourcesQuery.data?.tat?.status ?? (isInitialLoading ? "CONNECTING" : "UNKNOWN");
const tatTone = tatStatus === "SUCCESS" ? "success" : dataResourcesQuery.isError ? "danger" : "running";
const columns = useMemo<TableColumn<DataResource>[]>(
() => [
{
key: "resource",
render: (resource) => <ResourceNameCell resource={resource} />,
title: "资源",
},
{
key: "type",
render: (resource) => (
<Tag tone={typeTone[resource.type] ?? "neutral"}>{typeLabel[resource.type] ?? resource.type}</Tag>
),
title: "类型",
},
{
key: "status",
render: (resource) => <StatusBadge status={statusTone[resource.status]} />,
title: "状态",
},
{
key: "topology",
render: (resource) => (
<span className={styles.stackText}>
<strong>{resource.topology}</strong>
</span>
),
title: "拓扑",
},
{
key: "capacity",
render: (resource) =>
typeof resource.capacityUsedPercent === "number" ? (
<ProgressMeter label={`${resource.name} 容量`} tone={metricTone(resource.status)} value={resource.capacityUsedPercent} />
) : (
<span className="text-muted"></span>
),
title: "容量",
},
{
key: "connections",
render: (resource) => <strong className={styles.metricText}>{formatNumber(resource.connections)}</strong>,
title: "连接数",
},
{
key: "slow",
render: (resource) => <strong className={styles.metricText}>{formatNumber(resource.slowQueryCount)}</strong>,
title: "慢查询",
},
{
key: "backup",
render: (resource) => <span className={styles.backupText}>{resource.backupStatus}</span>,
title: "备份",
},
{
key: "owner",
render: (resource) => <span className={styles.ownerText}>{resource.owner}</span>,
title: "负责人",
},
],
[],
);
const handleRefresh = () => {
void dataResourcesQuery.refetch();
};
return (
<div className="page">
<div className="page-header">
<div>
<h1 className="page-title"></h1>
</div>
<Button disabled={dataResourcesQuery.isFetching} isLoading={dataResourcesQuery.isFetching} onClick={handleRefresh} size="sm">
<RefreshCcw size={15} />
</Button>
</div>
<Card className={styles.accessPanel} padded={false}>
<div className={styles.accessPath}>
<span>
<Cable size={18} />
TAT
</span>
<i />
<span>
<Server size={18} />
{deployTarget ? `${deployTarget.name} / ${deployTarget.privateIp}` : "deploy"}
</span>
<i />
<span>
<HardDrive size={18} />
</span>
</div>
<div className={styles.accessMeta}>
<Tag tone={tatTone} size="md">
{tatStatus === "SUCCESS" ? "TAT 已连接" : dataResourcesQuery.isError ? "TAT 异常" : "TAT 连接中"}
</Tag>
<span> {lastSyncedAt}</span>
</div>
</Card>
{isInitialLoading ? <LoadingState /> : null}
{dataResourcesQuery.isError ? (
<Card className={styles.noticePanel} padded={false}>
<EmptyState
description={dataResourcesQuery.error instanceof Error ? dataResourcesQuery.error.message : "deploy 机器未返回数据"}
icon={<AlertTriangle size={22} />}
title="数据资源读取失败"
/>
</Card>
) : null}
{!isInitialLoading && !dataResourcesQuery.isError ? (
<>
<section className={styles.metrics}>
<MetricCard icon={<Database size={24} />} label="资源健康" meta="正常 / 总数" tone="green" value={`${healthyCount}/${resources.length}`} />
<MetricCard icon={<AlertTriangle size={24} />} label="异常资源" meta="异常" tone={abnormalCount > 0 ? "orange" : "green"} value={abnormalCount} />
<MetricCard icon={<Server size={24} />} label="服务器来源" meta="SSH / TAT" value={`${sourceOkCount}/${sourceHosts.length}`} />
<MetricCard icon={<ShieldCheck size={24} />} label="TCP 探测" meta="端点" tone="green" value={checkCount} />
</section>
<Card className={styles.panel} padded={false}>
<div className={styles.panelHeader}>
<div>
<h2></h2>
</div>
<Tag tone="brand" size="md">
{resources.length}
</Tag>
</div>
<Table columns={columns} data={resources} getRowKey={(resource) => resource.id} />
</Card>
<div className={styles.detailGrid}>
{resources.map((resource) => (
<Card className={styles.resourcePanel} key={resource.id} padded={false}>
<div className={styles.resourceHeader}>
<div>
<h2>{resource.name}</h2>
<span>{resource.topology}</span>
</div>
<StatusBadge status={statusTone[resource.status]} />
</div>
<div className={styles.checkList}>
{resource.checks.length > 0 ? (
resource.checks.map((check) => (
<div className={styles.checkItem} key={`${resource.id}-${check.endpoint ?? check.detail}`}>
<span data-status={check.status} />
<div>
<strong>{check.endpoint ?? "未识别端点"}</strong>
<small>
{check.detail ?? check.status}
{typeof check.latencyMs === "number" ? ` / ${check.latencyMs}ms` : ""}
</small>
</div>
</div>
))
) : (
<div className={styles.checkItem}>
<span data-status="unknown" />
<div>
<strong></strong>
</div>
</div>
)}
</div>
<div className={styles.relatedList}>
{resource.relatedServices.slice(0, 8).map((service) => (
<Tag key={service}>{service}</Tag>
))}
</div>
</Card>
))}
</div>
</>
) : null}
</div>
);
}

View File

@ -695,6 +695,7 @@
.logPanel code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
white-space: pre-wrap;
}
.configGrid {
@ -725,6 +726,60 @@
font-size: 15px;
}
.configYamlList {
display: grid;
gap: var(--space-3);
margin-top: 16px;
}
.configYamlItem {
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.54);
border-radius: 16px;
background: rgba(255, 255, 255, 0.22);
}
.configYamlHeader {
display: grid;
grid-template-columns: auto minmax(120px, max-content) minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: 46px;
padding: 0 14px;
color: var(--color-text-secondary);
}
.configYamlHeader strong {
color: var(--color-text-primary);
font-size: 13px;
}
.configYamlHeader span {
overflow: hidden;
color: var(--color-text-tertiary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.inlineYamlBlock {
max-height: 420px;
margin: 0;
padding: 16px;
overflow: auto;
color: #d9f6ff;
background:
linear-gradient(145deg, rgba(22, 43, 66, 0.94), rgba(19, 32, 49, 0.92)),
rgba(20, 34, 52, 0.94);
}
.inlineYamlBlock code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.7;
white-space: pre;
}
.floatingStatus {
position: sticky;
bottom: 16px;

View File

@ -17,10 +17,6 @@ import {
} from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { useAppContextStore } from "../../app/store/useAppContextStore";
import {
getHyappDeploymentHosts,
getHyappDeploymentServiceByName,
} from "../../entities/deployment-plan";
import {
type ServiceEvent,
type ServiceInstance,
@ -117,6 +113,10 @@ function formatYamlValue(value: string | number) {
}
function createInstanceConfigYaml(instance: ServiceInstance) {
if (instance.configYaml) {
return instance.configYaml;
}
const lines = [
"apiVersion: ops.deploy-platform/v1",
"kind: ServiceInstanceConfig",
@ -266,10 +266,8 @@ export function ServiceDetailPage() {
const [activeTab, setActiveTab] = useState<DetailTab>("overview");
const [selectedInstanceId, setSelectedInstanceId] = useState("");
const [configModalInstanceId, setConfigModalInstanceId] = useState("");
const detailQuery = useServiceDetailQuery(serviceId);
const detailQuery = useServiceDetailQuery(serviceId, environment);
const service = detailQuery.data;
const deploymentService = service ? getHyappDeploymentServiceByName(service.name) : undefined;
const deploymentHosts = deploymentService ? getHyappDeploymentHosts(deploymentService) : [];
const summaryItems = useMemo(() => {
if (!service) {
@ -356,6 +354,19 @@ export function ServiceDetailPage() {
);
}
if (detailQuery.isError) {
return (
<div className="page">
<Card>
<EmptyState
title="服务读取失败"
description={detailQuery.error instanceof Error ? detailQuery.error.message : "deploy 机器未返回数据"}
/>
</Card>
</div>
);
}
if (!service) {
return (
<div className="page">
@ -373,7 +384,7 @@ export function ServiceDetailPage() {
<Card className={styles.section} padded={false}>
<div className={styles.sectionHeader}>
<h2></h2>
<span> 1 </span>
<span></span>
</div>
<div className={styles.metricGrid}>
{service.metrics.map((metric) => (
@ -462,14 +473,29 @@ export function ServiceDetailPage() {
<strong>{service.owner}</strong>
</div>
<div>
<span></span>
<strong>2C / 4Gi</strong>
<span>Unit</span>
<strong>{service.unit ?? "未采集"}</strong>
</div>
<div>
<span></span>
<strong></strong>
<span></span>
<strong>{service.targetPort ?? "未采集"}</strong>
</div>
</div>
<div className={styles.configYamlList}>
{service.instances.map((instance) => (
<div className={styles.configYamlItem} key={instance.id}>
<div className={styles.configYamlHeader}>
<FileText size={16} />
<strong>{instance.name}</strong>
<span>{instance.configPath ?? "未采集"}</span>
{instance.configYamlTruncated ? <Tag tone="warning"></Tag> : null}
</div>
<pre className={styles.inlineYamlBlock}>
<code>{instance.configYaml || "未采集"}</code>
</pre>
</div>
))}
</div>
</Card>
),
events: (
@ -591,13 +617,16 @@ export function ServiceDetailPage() {
<Card className={styles.section} padded={false}>
<div className={styles.sectionHeader}>
<h2></h2>
<span></span>
<span>journalctl</span>
</div>
<div className={styles.logPanel}>
<code>[14:18:02] release {service.version} completed</code>
<code>[14:18:01] readiness probe passed on all pods</code>
<code>[14:17:42] config checksum updated</code>
<code>[14:17:10] rolling update started by backend</code>
{service.instances.map((instance) => (
<code key={instance.id}>
{instance.name}
{"\n"}
{instance.logs || "未采集"}
</code>
))}
</div>
</Card>
),
@ -617,33 +646,33 @@ export function ServiceDetailPage() {
</div>
<div className={styles.releaseGrid}>
<div>
<span></span>
<code>{deploymentService?.local.commands.rebuild ?? "未绑定"}</code>
<span></span>
<code>{service.name}</code>
</div>
<div>
<span> Unit</span>
<strong>{deploymentService?.prod?.unit ?? "未进 TAT inventory"}</strong>
<strong>{service.unit ?? "TAT 未返回"}</strong>
</div>
<div>
<span>CLB </span>
<strong>{deploymentService?.prod?.clb.entrypoint ?? "local only"}</strong>
<span></span>
<strong>{service.container ?? "TAT 未返回"}</strong>
</div>
<div>
<span></span>
<code>{deploymentService?.prod?.configPath ?? deploymentService?.local.configPath ?? "未绑定"}</code>
<span></span>
<code>{service.configPath ?? "TAT 未返回"}</code>
</div>
</div>
<div className={styles.releaseHosts}>
<span></span>
<div>
{deploymentHosts.length > 0 ? (
deploymentHosts.map((host) => (
<Tag key={host.id} tone="success">
{host.id} / {host.privateIp}
{service.instances.length > 0 ? (
service.instances.map((instance) => (
<Tag key={instance.id} tone="success">
{instance.node} / {instance.podIp}
</Tag>
))
) : (
<Tag tone="warning">local-compose</Tag>
<Tag tone="warning">TAT </Tag>
)}
</div>
</div>
@ -716,7 +745,7 @@ export function ServiceDetailPage() {
<CheckCircle2 size={15} />
<span></span>
<Clock3 size={14} />
<span></span>
<span>TAT</span>
<Database size={15} />
<span>{service.dependencies.length} </span>
<Box size={15} />

View File

@ -19,3 +19,120 @@
font-weight: 800;
white-space: nowrap;
}
.modalOverlay {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 28px;
background: rgba(29, 43, 58, 0.22);
backdrop-filter: blur(18px) saturate(150%);
-webkit-backdrop-filter: blur(18px) saturate(150%);
}
.yamlModal {
display: flex;
width: min(980px, calc(100vw - 56px));
max-height: min(780px, calc(100dvh - 56px));
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 24px;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.66), rgba(230, 242, 255, 0.4)),
rgba(255, 255, 255, 0.5);
box-shadow: var(--shadow-liquid-edge), var(--shadow-glass-lg);
backdrop-filter: blur(38px) saturate(180%) brightness(1.06);
-webkit-backdrop-filter: blur(38px) saturate(180%) brightness(1.06);
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: 18px 18px 14px;
border-bottom: 1px solid rgba(114, 151, 188, 0.14);
}
.modalHeader h2 {
margin: 0 0 4px;
color: var(--color-text-primary);
font-size: 18px;
font-weight: 800;
}
.modalHeader span {
color: var(--color-text-tertiary);
font-size: 12px;
}
.modalState {
padding: 28px;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 800;
}
.yamlList {
display: grid;
gap: var(--space-3);
padding: 18px;
overflow: auto;
}
.yamlItem {
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.54);
border-radius: 16px;
background: rgba(255, 255, 255, 0.22);
}
.yamlItemHeader {
display: grid;
grid-template-columns: auto minmax(120px, max-content) minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: 46px;
padding: 0 14px;
color: var(--color-text-secondary);
}
.yamlItemHeader strong {
color: var(--color-text-primary);
font-size: 13px;
}
.yamlItemHeader small {
overflow: hidden;
color: var(--color-text-tertiary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.yamlItemHeader span {
color: var(--color-warning);
font-size: 12px;
font-weight: 800;
}
.yamlItem pre {
max-height: 420px;
margin: 0;
padding: 16px;
overflow: auto;
color: #d9f6ff;
background:
linear-gradient(145deg, rgba(22, 43, 66, 0.94), rgba(19, 32, 49, 0.92)),
rgba(20, 34, 52, 0.94);
}
.yamlItem code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.7;
white-space: pre;
}

View File

@ -1,17 +1,81 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { FileText, X } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useAppContextStore } from "../../app/store/useAppContextStore";
import { useServicesQuery } from "../../entities/service";
import type { ServiceListParams } from "../../entities/service";
import { useServiceDetailQuery, useServicesQuery } from "../../entities/service";
import type { ServiceDetail, ServiceListParams } from "../../entities/service";
import { useReleaseDrawerStore } from "../../features/release-service";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
import { EmptyState } from "../../shared/ui/EmptyState";
import { IconButton } from "../../shared/ui/IconButton";
import { ServiceTable } from "../../widgets/service-table";
import styles from "./ServicesPage.module.css";
type ConfigYamlModalProps = {
error: unknown;
isLoading: boolean;
onClose: () => void;
service: ServiceDetail | null | undefined;
};
function ConfigYamlModal({ error, isLoading, onClose, service }: ConfigYamlModalProps) {
const instances = service?.instances ?? [];
return (
<div
className={styles.modalOverlay}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
role="presentation"
>
<section aria-labelledby="service-config-yaml-title" aria-modal="true" className={styles.yamlModal} role="dialog">
<div className={styles.modalHeader}>
<div>
<h2 id="service-config-yaml-title"> YAML</h2>
<span>{service?.name ?? "加载中"}</span>
</div>
<IconButton label="关闭配置 YAML" onClick={onClose}>
<X size={18} />
</IconButton>
</div>
{isLoading ? (
<div className={styles.modalState}></div>
) : error ? (
<div className={styles.modalState}>{error instanceof Error ? error.message : "配置读取失败"}</div>
) : instances.length === 0 ? (
<div className={styles.modalState}></div>
) : (
<div className={styles.yamlList}>
{instances.map((instance) => (
<div className={styles.yamlItem} key={instance.id}>
<div className={styles.yamlItemHeader}>
<FileText size={16} />
<strong>{instance.name}</strong>
<small>{instance.configPath ?? "未采集"}</small>
{instance.configYamlTruncated ? <span></span> : null}
</div>
<pre>
<code>{instance.configYaml || "未采集"}</code>
</pre>
</div>
))}
</div>
)}
</section>
</div>
);
}
export function ServicesPage() {
const navigate = useNavigate();
const { clusterId, environment, projectId } = useAppContextStore();
const openRelease = useReleaseDrawerStore((state) => state.openRelease);
const [configServiceId, setConfigServiceId] = useState("");
const serviceListParams = useMemo<ServiceListParams>(() => {
return {
clusterId,
@ -21,13 +85,29 @@ export function ServicesPage() {
}, [clusterId, environment, projectId]);
const servicesQuery = useServicesQuery(serviceListParams);
const services = servicesQuery.data?.items ?? [];
const configQuery = useServiceDetailQuery(configServiceId, environment);
useEffect(() => {
if (!configServiceId) {
return undefined;
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setConfigServiceId("");
}
};
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [configServiceId]);
return (
<div className="page">
<div className="page-header">
<div>
<h1 className="page-title"></h1>
<p className="page-subtitle"></p>
</div>
</div>
@ -36,14 +116,33 @@ export function ServicesPage() {
<span className={styles.count}>
{servicesQuery.data?.total ?? 0}
</span>
<Button disabled={servicesQuery.isFetching} isLoading={servicesQuery.isFetching} onClick={() => void servicesQuery.refetch()} size="sm" variant="secondary">
</Button>
</div>
{servicesQuery.isError ? (
<EmptyState
title="微服务读取失败"
description={servicesQuery.error instanceof Error ? servicesQuery.error.message : "deploy 机器未返回数据"}
/>
) : null}
<ServiceTable
isLoading={servicesQuery.isLoading}
isLoading={servicesQuery.isLoading || (servicesQuery.isFetching && !servicesQuery.data)}
onViewConfig={(service) => setConfigServiceId(service.id)}
onRelease={openRelease}
onViewDetails={(service) => navigate(`/services/${service.id}`)}
services={services}
/>
</Card>
{configServiceId ? (
<ConfigYamlModal
error={configQuery.error}
isLoading={configQuery.isLoading || (configQuery.isFetching && !configQuery.data)}
onClose={() => setConfigServiceId("")}
service={configQuery.data}
/>
) : null}
</div>
);
}

View 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;
}
}

View 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>
);
}

View File

@ -0,0 +1 @@
export { TestServicesPage } from "./TestServicesPage";

View File

@ -1,10 +1,13 @@
import type { ServiceListParams } from "../../entities/service";
export const queryKeys = {
dataResources: () => ["data-resources"] as const,
deployApiInventory: () => ["deploy-api-inventory"] as const,
deployApiDiscover: (services: string[]) => ["deploy-api-discover", services] as const,
deployApiPlan: (services: string[]) => ["deploy-api-plan", services] as const,
deployApiTestboxConfigs: (services: string[]) => ["deploy-api-testbox-configs", services] as const,
serviceDetail: (serviceId: string) => ["service-detail", serviceId] as const,
deployApiTestboxMigrations: () => ["deploy-api-testbox-migrations"] as const,
deployApiTestboxStatus: (services: string[]) => ["deploy-api-testbox-status", services] as const,
serviceDetail: (serviceId: string, environment: string) => ["service-detail", serviceId, environment] as const,
services: (params: ServiceListParams) => ["services", params] as const,
};

View File

@ -1,14 +1,11 @@
import { ReactNode } from "react";
import { NavLink } from "react-router-dom";
import {
Activity,
Bell,
Database,
FileClock,
Gauge,
LayoutDashboard,
Settings,
Shield,
ServerCog,
Workflow,
} from "lucide-react";
import clsx from "clsx";
@ -23,12 +20,9 @@ type AppShellProps = {
const navItems = [
{ icon: LayoutDashboard, label: "总览", to: "/overview" },
{ icon: Workflow, label: "微服务", to: "/services" },
{ icon: ServerCog, label: "测试服务", to: "/test-services" },
{ icon: FileClock, label: "部署方案", to: "/releases" },
{ icon: Database, label: "数据漫游", to: "/resources" },
{ icon: Settings, label: "配置中心", to: "/settings" },
{ icon: Activity, label: "日志与事件", to: "/logs-events" },
{ icon: Bell, label: "监控告警", to: "/alerts" },
{ icon: Shield, label: "权限与审计", to: "/audit" },
{ icon: Database, label: "数据资源", to: "/resources" },
] as const;
export function AppShell({ children }: AppShellProps) {

View File

@ -10,12 +10,14 @@ import styles from "./ServiceTable.module.css";
type ServiceTableProps = {
isLoading?: boolean;
onViewConfig?: (service: Service) => void;
onRelease?: (service: Service) => void;
onViewDetails?: (service: Service) => void;
services: Service[];
};
type ServiceTableActions = {
onViewConfig: ((service: Service) => void) | undefined;
onRelease: ((service: Service) => void) | undefined;
onViewDetails: ((service: Service) => void) | undefined;
};
@ -33,6 +35,7 @@ function usageTone(value: number) {
}
function createColumns({
onViewConfig,
onRelease,
onViewDetails,
}: ServiceTableActions): TableColumn<Service>[] {
@ -115,7 +118,11 @@ function createColumns({
>
</Button>
<IconButton label="更多操作">
<IconButton
disabled={!onViewConfig}
label="查看配置 YAML"
onClick={() => onViewConfig?.(service)}
>
<MoreVertical size={16} />
</IconButton>
</span>
@ -140,11 +147,12 @@ const skeletonServices: Service[] = Array.from({ length: 5 }, (_, index) => ({
export function ServiceTable({
isLoading = false,
onViewConfig,
onRelease,
onViewDetails,
services,
}: ServiceTableProps) {
const columns = createColumns({ onRelease, onViewDetails });
const columns = createColumns({ onRelease, onViewConfig, onViewDetails });
if (isLoading) {
return (