Add deploy platform initial project

This commit is contained in:
zhx 2026-05-20 16:49:19 +08:00
commit 29dcce8ca0
134 changed files with 18809 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
.git
.DS_Store
*.tsbuildinfo
npm-debug.log*
deploy/tencent-tat/inventory.prod.json
deploy/tencent-tat/deploy-platform.inventory.prod.json
deploy/tencent-tat/hyapp-services.inventory.prod.json

8
.env.example Normal file
View File

@ -0,0 +1,8 @@
VITE_API_BASE_URL=/api
VITE_ENABLE_MOCK=true
VITE_APP_ENV=development
# Deploy API credentials. Copy to .env locally and fill real values when needed.
TENCENTCLOUD_SECRET_ID=
TENCENTCLOUD_SECRET_KEY=
TENCENTCLOUD_REGION=

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
node_modules
dist
coverage
.env
.env.local
.env.*.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
*.tsbuildinfo
vite.config.d.ts
vite.config.js

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
coverage
package-lock.json

27
Dockerfile Normal file
View File

@ -0,0 +1,27 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache python3 py3-pip \
&& python3 -m pip install --no-cache-dir --break-system-packages tencentcloud-sdk-python
COPY --from=build /app/dist ./dist
COPY deploy ./deploy
EXPOSE 29200
ENV DEPLOY_API_PORT=29200
HEALTHCHECK --interval=10s --timeout=3s --retries=12 CMD wget -q -O - http://127.0.0.1:29200/deploy/api/health >/dev/null || exit 1
CMD ["node", "deploy/server.mjs"]

18
Makefile Normal file
View File

@ -0,0 +1,18 @@
NPM ?= npm
DEPLOY_API_PORT ?= 29200
export DEPLOY_API_PORT
.PHONY: build up serve
# `build` 生成生产环境 dist等价于 npm run build。
build:
$(NPM) run build
# `up` 一键构建并启动 deploy server同时托管 dist 和 /deploy/api/*。
up: build
$(NPM) run deploy:serve
# `serve` 直接使用已有 dist 启动 deploy server。
serve:
$(NPM) run deploy:serve

45
README.md Normal file
View File

@ -0,0 +1,45 @@
# Deploy Platform
液态玻璃风格的项目运维管理平台。
## Scripts
```bash
npm install
make build
make up
npm run dev
npm run build
npm run lint
npm run test
```
## Docs
- [UI 设计规范](./docs/liquid-glass-admin-ui-guideline.md)
- [第一版开发内容](./docs/v1-development-scope.md)
- [模块化开发规范](./docs/modular-development-guideline.md)
## Standalone Deploy
构建后可一键运行 `dist``deploy` API
```bash
npm run deploy:run
```
已有 `dist` 时直接启动:
```bash
npm run deploy:serve
```
默认地址:`http://127.0.0.1:29200`
也可以用 Makefile 一键运行:
```bash
make up
```
部署模板和 TAT 脚本见 `deploy/README.md`

52
deploy/README.md Normal file
View File

@ -0,0 +1,52 @@
# Deploy Platform Deployment
当前项目可以作为独立前端服务部署,不依赖 `/Users/hy/Documents/hy/hyapp-server` 的部署目录。
目录职责:
- `server.mjs`:同源运行 `dist` 静态文件和 `/deploy/api/*` 运维查询接口。
- `standalone/`:目标服务器 systemd + Docker 安装模板。
- `tencent-tat/`:发布机通过腾讯云 TAT 调度远端重启和镜像更新,包含当前平台自身发布脚本和 HyApp 微服务发布脚本。
最小部署链路:
1. 本地或 CI 执行 `npm run build` 生成 `dist`
2. 执行 `npm run deploy:serve`,一个 Node 进程同时运行 `dist` 静态站点和 `deploy` API。
3. 发布机构建并推送 `deploy-platform` 镜像。
4. 目标机安装 `hyapp-deploy-platform.service``/etc/hyapp/deploy-platform/docker.env`
5. 发布机执行 `deploy/tencent-tat/deploy_platform_tat_deploy.py deploy` 发布当前平台。
6. 平台服务端调用 `deploy/tencent-tat/hyapp_tat_deploy.py` 查询或发布 HyApp 后端服务。
本地一键运行:
```bash
npm run deploy:run
```
已有 `dist` 时直接运行:
```bash
npm run deploy:serve
```
默认监听 `13200`,可通过 `DEPLOY_API_PORT` 覆盖。
腾讯云凭据读取顺序:
1. 优先读取当前进程环境变量 `TENCENTCLOUD_SECRET_ID``TENCENTCLOUD_SECRET_KEY``TENCENTCLOUD_REGION`
2. 未设置环境变量时,`deploy/server.mjs` 会读取 `TENCENT_CREDENTIALS_FILE` 指向的文件;未指定时默认读取仓库根目录 `.env`
3. 区域未写入凭据时HyApp 发布脚本使用 inventory 中的 `region`
真实接口:
- `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 权重。
- `POST /deploy/api/hyapp/deploy`:真实发布,必须带 `confirmed=true`;测试时使用 `dryRun=true`
- `POST /deploy/api/hyapp/restart`:真实重启,必须带 `confirmed=true`;测试时使用 `dryRun=true`
详细命令见:
- `deploy/standalone/README.md`
- `deploy/tencent-tat/README.md`

456
deploy/server.mjs Normal file
View File

@ -0,0 +1,456 @@
import { createReadStream, promises as fs, readFileSync } from "node:fs";
import { createServer } from "node:http";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
const rootDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
const deployDir = join(rootDir, "deploy");
const distDir = resolve(process.env.DEPLOY_DIST_DIR || join(rootDir, "dist"));
const apiPort = Number(process.env.DEPLOY_API_PORT || process.env.PORT || 29200);
const hyappInventoryPath = resolve(
process.env.HYAPP_DEPLOY_INVENTORY || join(deployDir, "tencent-tat", "hyapp-services.inventory.prod.example.json"),
);
const hyappTestbox = {
instanceId: process.env.HYAPP_TESTBOX_INSTANCE_ID || "lhins-q0m38zc6",
region: process.env.HYAPP_TESTBOX_REGION || "ap-jakarta",
root: process.env.HYAPP_TESTBOX_ROOT || "/opt/hyapp-server-test",
};
const hyappTestboxConfigServices =
process.env.HYAPP_TESTBOX_CONFIG_SERVICES ||
"gateway-service,room-service,wallet-service,user-service,activity-service,cron-service,game-service,notice-service,admin-server";
const platformInventoryPath = resolve(
process.env.DEPLOY_PLATFORM_INVENTORY || join(deployDir, "tencent-tat", "inventory.prod.example.json"),
);
const pythonBin = process.env.PYTHON_BIN || "python3";
const tencentEnvFile = process.env.TENCENT_ENV_FILE || "";
const credentialFile = resolve(process.env.TENCENT_CREDENTIALS_FILE || join(rootDir, ".env"));
const deployEnv = { ...process.env };
const mimeTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "application/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".ico", "image/x-icon"],
[".webp", "image/webp"],
]);
function cleanEnvValue(value) {
return String(value || "")
.trim()
.replace(/^export\s+/, "")
.replace(/^['"`]+|['"`]+$/g, "")
.trim();
}
function loadCredentialsFromFile(path) {
const keys = [
"TENCENTCLOUD_SECRET_ID",
"TENCENTCLOUD_SECRET_KEY",
"TENCENTCLOUD_REGION",
"TENCENT_SECRET_ID",
"TENCENT_SECRET_KEY",
"DEPLOY_REGION",
];
let raw = "";
try {
raw = readFileSync(path, "utf-8");
} catch {
return { loaded: [], source: path };
}
const loaded = [];
for (const key of keys) {
const patterns = [
new RegExp(`(?:^|\\n)\\s*(?:export\\s+)?${key}\\s*=\\s*([^\\n#]+)`, "m"),
new RegExp(`(?:^|\\n)\\s*${key}\\s*:\\s*([^\\n#]+)`, "m"),
];
for (const pattern of patterns) {
const match = raw.match(pattern);
if (match?.[1]) {
const value = cleanEnvValue(match[1]);
if (value && !deployEnv[key]) {
deployEnv[key] = value;
loaded.push(key);
}
break;
}
}
}
if (!deployEnv.TENCENTCLOUD_SECRET_ID && deployEnv.TENCENT_SECRET_ID) {
deployEnv.TENCENTCLOUD_SECRET_ID = deployEnv.TENCENT_SECRET_ID;
loaded.push("TENCENTCLOUD_SECRET_ID");
}
if (!deployEnv.TENCENTCLOUD_SECRET_KEY && deployEnv.TENCENT_SECRET_KEY) {
deployEnv.TENCENTCLOUD_SECRET_KEY = deployEnv.TENCENT_SECRET_KEY;
loaded.push("TENCENTCLOUD_SECRET_KEY");
}
return { loaded: [...new Set(loaded)], source: path };
}
const credentialLoadResult = loadCredentialsFromFile(credentialFile);
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
response.end(JSON.stringify(payload, null, 2));
}
function sendError(response, statusCode, message, detail = undefined) {
sendJson(response, statusCode, {
detail,
error: message,
ok: false,
});
}
async function readJson(path) {
const raw = await fs.readFile(path, "utf-8");
return JSON.parse(raw);
}
function parseServices(value, fallback) {
const source = Array.isArray(value) ? value.join(",") : value;
const services = String(source || fallback || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (services.length === 0) {
throw new Error("services is required");
}
for (const service of services) {
if (!/^[a-z0-9][a-z0-9-]*$/.test(service)) {
throw new Error(`invalid service name: ${service}`);
}
}
return [...new Set(services)];
}
function parseHosts(value) {
if (!value) {
return [];
}
const source = Array.isArray(value) ? value.join(",") : value;
return String(source)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function readRequestJson(request) {
return new Promise((resolvePromise, reject) => {
let raw = "";
request.on("data", (chunk) => {
raw += chunk;
if (raw.length > 64 * 1024) {
reject(new Error("request body too large"));
request.destroy();
}
});
request.on("end", () => {
if (!raw.trim()) {
resolvePromise({});
return;
}
try {
resolvePromise(JSON.parse(raw));
} catch (error) {
reject(error);
}
});
request.on("error", reject);
});
}
function runDeployScript(scriptName, args) {
return new Promise((resolvePromise) => {
const child = execFile(
pythonBin,
[join(deployDir, "tencent-tat", scriptName), ...args],
{
cwd: rootDir,
env: deployEnv,
timeout: Number(process.env.DEPLOY_API_COMMAND_TIMEOUT_MS || 120_000),
},
(error, stdout, stderr) => {
let payload = null;
const text = stdout || stderr || "";
try {
payload = JSON.parse(text);
} catch {
payload = {
ok: false,
output: text,
};
}
resolvePromise({
exitCode: typeof error?.code === "number" ? error.code : 0,
ok: !error && payload?.ok !== false,
payload,
stderr,
});
},
);
child.stdin?.end();
});
}
function buildEnvArgs() {
return tencentEnvFile ? ["--env-file", tencentEnvFile] : [];
}
function buildActionArgs(action, body) {
const services = parseServices(body.services, "");
const hosts = parseHosts(body.hosts);
const tag = String(body.tag || "").trim();
const dryRun = body.dryRun === true;
const confirmed = body.confirmed === true;
if (!dryRun && !confirmed) {
throw new Error("confirmed=true is required for real execution");
}
if (action === "deploy" && !tag) {
throw new Error("tag is required for deploy");
}
const args = [
action,
...buildEnvArgs(),
"--inventory",
hyappInventoryPath,
"--services",
services.join(","),
];
if (hosts.length > 0) {
args.push("--hosts", hosts.join(","));
}
if (action === "deploy") {
args.push("--tag", tag);
}
if (dryRun) {
args.push("--dry-run");
} else {
args.push("--yes");
}
return args;
}
async function handleApi(request, response, url) {
if (url.pathname === "/deploy/api/health") {
sendJson(response, 200, {
credentials: {
region: Boolean(deployEnv.TENCENTCLOUD_REGION || deployEnv.DEPLOY_REGION),
secretId: Boolean(deployEnv.TENCENTCLOUD_SECRET_ID || deployEnv.TENCENT_SECRET_ID),
secretKey: Boolean(deployEnv.TENCENTCLOUD_SECRET_KEY || deployEnv.TENCENT_SECRET_KEY),
source: credentialLoadResult.loaded.length > 0 ? credentialLoadResult.source : "",
},
distDir,
ok: true,
platformInventoryPath,
service: "deploy-platform-api",
});
return;
}
if (url.pathname === "/deploy/api/hyapp/inventory") {
const inventory = await readJson(hyappInventoryPath);
sendJson(response, 200, {
hosts: inventory.hosts,
managedDependencies: inventory.managed_dependencies || {},
ok: true,
region: inventory.region,
registry: inventory.registry,
services: inventory.services,
});
return;
}
if (url.pathname === "/deploy/api/platform/inventory") {
const inventory = await readJson(platformInventoryPath);
sendJson(response, 200, {
hosts: inventory.hosts,
ok: true,
region: inventory.region,
registry: inventory.registry,
services: inventory.services,
});
return;
}
if (url.pathname === "/deploy/api/hyapp/plan") {
const services = parseServices(url.searchParams.get("services"), "gateway-service,room-service,user-service");
const action = url.searchParams.get("action") === "deploy" ? "deploy" : "plan";
const tag = String(url.searchParams.get("tag") || "").trim();
const args =
action === "deploy"
? [
"deploy",
"--dry-run",
"--inventory",
hyappInventoryPath,
"--services",
services.join(","),
"--tag",
tag || "REPLACE_TAG",
]
: ["plan", "--inventory", hyappInventoryPath, "--services", services.join(",")];
const result = await runDeployScript("hyapp_tat_deploy.py", args);
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/restart") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
const result = await runDeployScript("hyapp_tat_deploy.py", buildActionArgs("restart", body));
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
if (url.pathname === "/deploy/api/hyapp/deploy") {
if (request.method !== "POST") {
sendError(response, 405, "POST is required");
return;
}
const body = await readRequestJson(request);
const result = await runDeployScript("hyapp_tat_deploy.py", buildActionArgs("deploy", body));
sendJson(response, result.ok ? 200 : 500, result.payload);
return;
}
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 args = [
"discover",
...buildEnvArgs(),
"--inventory",
hyappInventoryPath,
"--services",
services.join(","),
];
if (!executeRemote) {
args.push("--dry-run");
}
const result = await runDeployScript("hyapp_tat_deploy.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(","),
];
if (raw && process.env.HYAPP_TESTBOX_CONFIG_ALLOW_RAW === "1") {
args.push("--raw");
}
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");
}
async function serveStatic(response, url) {
const requestedPath = decodeURIComponent(url.pathname);
const safePath = normalize(requestedPath).replace(/^(\.\.[/\\])+/, "");
const filePath = resolve(join(distDir, safePath));
if (!filePath.startsWith(distDir)) {
sendError(response, 403, "forbidden");
return;
}
let stat;
let target = filePath;
try {
stat = await fs.stat(target);
if (stat.isDirectory()) {
target = join(target, "index.html");
stat = await fs.stat(target);
}
} catch {
target = join(distDir, "index.html");
stat = await fs.stat(target);
}
response.writeHead(200, {
"content-length": stat.size,
"content-type": mimeTypes.get(extname(target)) || "application/octet-stream",
});
createReadStream(target).pipe(response);
}
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
if (url.pathname.startsWith("/deploy/api/")) {
await handleApi(request, response, url);
return;
}
await serveStatic(response, url);
} catch (error) {
sendError(response, 500, "deploy api internal error", error instanceof Error ? error.message : String(error));
}
});
server.listen(apiPort, "0.0.0.0", () => {
console.log(`deploy-platform listening on http://0.0.0.0:${apiPort}`);
console.log(`serving dist from ${distDir}`);
});

View File

@ -0,0 +1,52 @@
# Deploy Platform Standalone Deployment
本目录用于把当前 `deploy-platform` 前端项目单独部署到 CVM。运行形态是 systemd 托管 Docker 容器,容器内通过 `node deploy/server.mjs` 同源托管 Vite 构建后的静态文件和 `/deploy/api/*` 运维查询接口。
## Runtime
- 镜像:`deploy-platform`
- 容器:`hyapp-deploy-platform`
- systemd unit`hyapp-deploy-platform`
- 监听端口:`29200`
- 健康检查:`http://127.0.0.1:29200/deploy/api/health`
同源接口:
- `GET /deploy/api/health`
- `GET /deploy/api/hyapp/inventory`
- `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`
- `POST /deploy/api/hyapp/deploy`
- `POST /deploy/api/hyapp/restart`
`deploy``restart` 是真实操作接口,必须在 JSON body 中显式传入 `confirmed: true`。联调和验收使用 `dryRun: true`,只返回执行计划,不修改远端实例。
## Build Image
在发布机执行:
```bash
cd /opt/deploy/sources/deploy-platform
TAG="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)"
REGISTRY="10.2.1.3:18082/hyapp"
docker build -t "$REGISTRY/deploy-platform:$TAG" .
docker push "$REGISTRY/deploy-platform:$TAG"
```
## Install On Target Host
```bash
sudo mkdir -p /etc/hyapp/deploy-platform
sudo cp deploy/standalone/docker/deploy-platform.env.example /etc/hyapp/deploy-platform/docker.env
sudo vi /etc/hyapp/deploy-platform/docker.env
sudo cp deploy/standalone/systemd/hyapp-deploy-platform.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now hyapp-deploy-platform
sudo systemctl status hyapp-deploy-platform
curl -fsS http://127.0.0.1:29200/deploy/api/health
```
`docker.env` 只保存镜像、容器名和停止等待时间。腾讯云凭据不写入镜像;需要执行远端 discover 时,通过容器环境变量或 `TENCENT_ENV_FILE` 挂载注入。

View File

@ -0,0 +1,3 @@
IMAGE=10.2.1.3:18082/hyapp/deploy-platform:REPLACE_TAG
CONTAINER_NAME=hyapp-deploy-platform
STOP_TIMEOUT_SEC=15

View File

@ -0,0 +1,17 @@
[Unit]
Description=HYApp deploy platform frontend
After=docker.service network-online.target
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/deploy-platform/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 ${IMAGE}
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,130 @@
# Tencent TAT Deployment
本目录包含两类 TAT 发布入口:
- `deploy_platform_tat_deploy.py`:发布当前 `deploy-platform` 前端项目。
- `hyapp_tat_deploy.py`:发布 HyApp 后端 Go 微服务,逻辑复制自 `/Users/hy/Documents/hy/hyapp-server/deploy/tencent-tat`
两者使用同一套 TAT/CLB 滚动部署模型:可选 CLB 摘流TAT 远端更新 `docker.env` 镜像并重启 systemd unit健康恢复后恢复权重。
## Deploy Platform
脚本只操作以下对象:
- `hyapp-deploy-platform` systemd unit
- `hyapp-deploy-platform` Docker 容器
- `/etc/hyapp/deploy-platform/docker.env`
## Prerequisites
1. 目标机已安装 Tencent TAT agent。
2. 目标机已安装 `deploy/standalone/systemd/hyapp-deploy-platform.service`
3. 目标机已准备 `/etc/hyapp/deploy-platform/docker.env`
4. 发布机安装腾讯云 SDK
```bash
python3 -m pip install tencentcloud-sdk-python
```
凭据通过环境变量或 `--env-file` 提供:
```bash
export TENCENTCLOUD_SECRET_ID="..."
export TENCENTCLOUD_SECRET_KEY="..."
export TENCENTCLOUD_REGION="me-saudi-arabia"
```
## Inventory
```bash
cp deploy/tencent-tat/inventory.prod.example.json deploy/tencent-tat/deploy-platform.inventory.prod.json
vi deploy/tencent-tat/deploy-platform.inventory.prod.json
```
必须替换:
- `hosts.deploy-platform-1.instance_id`
- `hosts.deploy-platform-1.private_ip`
- 如果前面挂 CLB`clb.enabled` 改成 `true`,并设置真实 `load_balancer_id` / `listener_ids`
## Commands
只看计划,不执行远端动作:
```bash
python3 deploy/tencent-tat/deploy_platform_tat_deploy.py plan \
--inventory deploy/tencent-tat/deploy-platform.inventory.prod.json \
--services deploy-platform
```
发现目标机当前状态:
```bash
python3 deploy/tencent-tat/deploy_platform_tat_deploy.py discover \
--env-file /opt/hy-app-monitor/.env \
--inventory deploy/tencent-tat/deploy-platform.inventory.prod.json \
--services deploy-platform
```
滚动更新镜像:
```bash
python3 deploy/tencent-tat/deploy_platform_tat_deploy.py deploy \
--env-file /opt/hy-app-monitor/.env \
--inventory deploy/tencent-tat/deploy-platform.inventory.prod.json \
--services deploy-platform \
--tag 20260514-1800 \
--yes
```
只重启,不改镜像:
```bash
python3 deploy/tencent-tat/deploy_platform_tat_deploy.py restart \
--env-file /opt/hy-app-monitor/.env \
--inventory deploy/tencent-tat/deploy-platform.inventory.prod.json \
--services deploy-platform \
--yes
```
## Failure Boundary
如果启用 CLB脚本会先把当前实例权重改为 `0`TAT 重启成功并通过 Docker health 后再恢复权重。重启或健康检查失败时,脚本停止后续步骤,已经摘流的实例保持权重 `0`,需要人工确认后恢复。
## HyApp Services
平台后续接服务端执行器时,直接调用当前仓库内的 HyApp 微服务发布脚本:
```bash
python3 deploy/tencent-tat/hyapp_tat_deploy.py plan \
--inventory deploy/tencent-tat/hyapp-services.inventory.prod.example.json \
--services gateway-service,room-service,user-service
```
生产使用时复制成真实 inventory
```bash
cp deploy/tencent-tat/hyapp-services.inventory.prod.example.json deploy/tencent-tat/hyapp-services.inventory.prod.json
vi deploy/tencent-tat/hyapp-services.inventory.prod.json
```
## HyApp Testbox
测试机部署不走生产镜像仓库,也不摘 CLB。当前模式是测试机自己拉取远端 `test` 分支:
- 后端测试机:`43.165.195.39` / `10.11.0.2`,实例 `lhins-q0m38zc6`
- 代码目录:`/opt/hyapp-server-test/source`
- 部署脚本:`/opt/hyapp-server-test/bin/deploy.sh`
- 自动轮询:`hyapp-server-test-deploy.timer`,每分钟检查一次 `test` 分支。
- 成功条件Compose 服务全部 healthy 后才写入 `/opt/hyapp-server-test/.deployed_commit`
- 后台前端:广州机 `172.16.0.10:7001`,公网 `http://1.14.164.2:7001/`
测试机 Go 服务 YAML 通过只读 TAT 接口读取:
```bash
python3 deploy/tencent-tat/hyapp_testbox_config.py configs \
--env-file /opt/hy-app-monitor/.env \
--services gateway-service,room-service,admin-server
```
默认返回会遮罩 `secret``password``token``key` 等字段;确实需要原始值时,服务端必须显式设置 `HYAPP_TESTBOX_CONFIG_ALLOW_RAW=1`,并在 API 请求里带 `raw=1`

View File

@ -0,0 +1,703 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import json
import os
import shlex
import sys
import time
from pathlib import Path
from typing import Any
def load_env_file(path: str) -> None:
# hy-app-monitor 现有 .env 可直接复用;这里只注入缺失变量,不覆盖调用方显式 export 的值。
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:
# 发布脚本只认明确的 host/service 映射,避免误操作同机其他项目。
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")
for host_name, host in inventory["hosts"].items():
if not str(host.get("instance_id") or "").strip():
raise RuntimeError(f"hosts.{host_name}.instance_id is required")
if not str(host.get("private_ip") or "").strip():
raise RuntimeError(f"hosts.{host_name}.private_ip is required")
for service_name, service in inventory["services"].items():
for key in ("unit", "container", "env_file", "target_port", "hosts"):
if key not in service:
raise RuntimeError(f"services.{service_name}.{key} is required")
for host_name in service["hosts"]:
if host_name not in inventory["hosts"]:
raise RuntimeError(f"services.{service_name}.hosts contains unknown host: {host_name}")
def csv_values(text: str) -> list[str]:
return [item.strip() for item in str(text or "").split(",") if item.strip()]
def selected_service_names(inventory: dict[str, Any], raw_services: str) -> list[str]:
names = csv_values(raw_services)
if not names:
raise RuntimeError("--services is required")
unknown = [name for name in names if name not in inventory["services"]]
if unknown:
raise RuntimeError(f"unsupported services: {', '.join(unknown)}")
return list(dict.fromkeys(names))
def selected_host_names(service: dict[str, Any], raw_hosts: str) -> list[str]:
hosts = list(dict.fromkeys(str(item) for item in service.get("hosts") or []))
requested = csv_values(raw_hosts)
if not requested:
return hosts
missing = [host for host in requested if host not in hosts]
if missing:
raise RuntimeError(f"selected hosts are not assigned to this service: {', '.join(missing)}")
return requested
def image_overrides(items: list[str]) -> dict[str, str]:
overrides: dict[str, str] = {}
for item in items:
if "=" not in item:
raise RuntimeError("--image must use service=image")
service_name, image = item.split("=", 1)
service_name = service_name.strip()
image = image.strip()
if not service_name or not image:
raise RuntimeError("--image must use non-empty service=image")
overrides[service_name] = image
return overrides
def render_image(inventory: dict[str, Any], service_name: str, tag: str, overrides: dict[str, str]) -> str:
if service_name in overrides:
return overrides[service_name]
if not tag:
raise RuntimeError(f"--tag or --image {service_name}=... is required for deploy")
service = inventory["services"][service_name]
registry = str(inventory.get("registry") or "").rstrip("/")
template = str(service.get("image_template") or "${REGISTRY}/" + service_name + ":${TAG}")
return template.replace("${REGISTRY}", registry).replace("${TAG}", tag)
def build_plan(
inventory: dict[str, Any],
*,
action: str,
service_names: list[str],
raw_hosts: str,
tag: str,
overrides: dict[str, str],
no_clb: bool,
) -> list[dict[str, Any]]:
# 顺序是服务优先、实例串行;启用 CLB 时不会同时摘掉同一组实例。
plan: list[dict[str, Any]] = []
for service_name in service_names:
service = inventory["services"][service_name]
image = render_image(inventory, service_name, tag, overrides) if action == "deploy" else ""
for host_name in selected_host_names(service, raw_hosts):
host = inventory["hosts"][host_name]
clb = service.get("clb") or {}
plan.append(
{
"action": action,
"service": service_name,
"host": host_name,
"instance_id": host["instance_id"],
"private_ip": host["private_ip"],
"target_port": int(service["target_port"]),
"unit": service["unit"],
"container": service["container"],
"image": image,
"clb_enabled": bool(clb.get("enabled")) and not no_clb,
"clb_load_balancer_id": str(clb.get("load_balancer_id") or ""),
"clb_listener_ids": list(clb.get("listener_ids") or []),
"drain_seconds": int(clb.get("drain_seconds") or 0),
}
)
return plan
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 build_clb_client(inventory: dict[str, Any]) -> Any:
sid, skey, reg = require_tencent_credentials(inventory)
try:
from tencentcloud.clb.v20180317 import clb_client
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc
return clb_client.ClbClient(
credential.Credential(sid, skey),
reg,
ClientProfile(httpProfile=HttpProfile(endpoint="clb.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]:
# TAT 是本方案唯一远端入口;脚本不会要求目标机开放 SSH。
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 call_clb(client: Any, method_name: str, payload: dict[str, Any]) -> dict[str, Any]:
from tencentcloud.clb.v20180317 import models as clb_models
request_class = getattr(clb_models, f"{method_name}Request")
request = request_class()
request.from_json_string(json.dumps(payload))
response = invoke_tencent(lambda: getattr(client, method_name)(request), method_name)
decoded = json.loads(response.to_json_string())
if isinstance(decoded.get("Response"), dict):
return dict(decoded["Response"])
return dict(decoded)
def wait_clb_task(client: Any, task_id: str, *, timeout_seconds: int, poll_seconds: int) -> None:
# ModifyTargetWeight 是异步任务,必须轮询到成功后才能重启实例。
deadline = time.time() + max(timeout_seconds, 1)
while time.time() < deadline:
payload = call_clb(client, "DescribeTaskStatus", {"TaskId": task_id})
status = int(payload.get("Status") if payload.get("Status") is not None else 2)
if status == 0:
return
if status == 1:
raise RuntimeError(f"CLB task failed: {payload.get('Message') or task_id}")
time.sleep(max(poll_seconds, 1))
raise RuntimeError(f"CLB task timed out: {task_id}")
def target_matches(step: dict[str, Any], target: dict[str, Any]) -> bool:
instance_id = str(target.get("InstanceId") or target.get("TargetId") or "").strip()
if instance_id and instance_id != step["instance_id"]:
return False
target_port = int(target.get("Port") or 0)
if target_port and target_port != int(step["target_port"]):
return False
ips = [str(item or "").strip() for item in (target.get("PrivateIpAddresses") or [target.get("IP")]) if str(item or "").strip()]
if ips and step["private_ip"] not in ips:
return False
return bool(instance_id or ips)
def binding_key(binding: dict[str, Any]) -> str:
return "|".join(
[
str(binding.get("listener_id") or ""),
str(binding.get("location_id") or ""),
str(binding.get("instance_id") or ""),
str(int(binding.get("port") or 0)),
]
)
def collect_bindings(step: dict[str, Any], listeners: list[dict[str, Any]]) -> list[dict[str, Any]]:
bindings: dict[str, dict[str, Any]] = {}
for listener in listeners:
listener_id = str(listener.get("ListenerId") or "").strip()
for target in listener.get("Targets") or []:
if target_matches(step, target):
binding = make_binding(step, listener_id, "", target)
bindings[binding_key(binding)] = binding
for rule in listener.get("Rules") or []:
location_id = str(rule.get("LocationId") or "").strip()
for target in rule.get("Targets") or []:
if target_matches(step, target):
binding = make_binding(step, listener_id, location_id, target)
bindings[binding_key(binding)] = binding
return list(bindings.values())
def make_binding(step: dict[str, Any], listener_id: str, location_id: str, target: dict[str, Any]) -> dict[str, Any]:
return {
"listener_id": listener_id,
"location_id": location_id,
"instance_id": str(target.get("InstanceId") or target.get("TargetId") or step["instance_id"]).strip(),
"port": int(target.get("Port") or step["target_port"]),
"weight": int(target.get("Weight") or 0),
}
def prepare_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]:
if should_auto_discover_clb(step):
return discover_clb_snapshot(client, step)
payload = call_clb(
client,
"DescribeTargets",
{
"LoadBalancerId": step["clb_load_balancer_id"],
"ListenerIds": step["clb_listener_ids"],
},
)
bindings = collect_bindings(step, list(payload.get("Listeners") or []))
if not bindings:
return discover_clb_snapshot(client, step)
return {"step": step, "bindings": bindings}
def should_auto_discover_clb(step: dict[str, Any]) -> bool:
load_balancer_id = str(step.get("clb_load_balancer_id") or "").strip()
listener_ids = [str(item or "").strip() for item in step.get("clb_listener_ids") or []]
if not load_balancer_id or "REPLACE" in load_balancer_id or load_balancer_id.upper() == "AUTO":
return True
return any((not item) or "REPLACE" in item or item.upper() == "AUTO" for item in listener_ids)
def discover_load_balancers_for_backend(client: Any, step: dict[str, Any]) -> list[dict[str, Any]]:
# 腾讯云 CLB 支持按后端私网 IP 反查负载均衡,避免手填内网 CLB ID。
offset = 0
found: list[dict[str, Any]] = []
while True:
payload = call_clb(
client,
"DescribeLoadBalancers",
{
"BackendPrivateIps": [step["private_ip"]],
"WithRs": 1,
"Limit": 100,
"Offset": offset,
},
)
items = list(payload.get("LoadBalancerSet") or [])
found.extend(items)
total = int(payload.get("TotalCount") or len(found))
if len(found) >= total or not items:
break
offset += len(items)
return found
def discover_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]:
load_balancers = discover_load_balancers_for_backend(client, step)
matched: list[dict[str, Any]] = []
for load_balancer in load_balancers:
load_balancer_id = str(load_balancer.get("LoadBalancerId") or "").strip()
if not load_balancer_id:
continue
payload = call_clb(client, "DescribeTargets", {"LoadBalancerId": load_balancer_id})
bindings = collect_bindings(step, list(payload.get("Listeners") or []))
if bindings:
matched.append({"load_balancer_id": load_balancer_id, "bindings": bindings})
if not matched:
raise RuntimeError(f"CLB target not found for {step['service']} on {step['host']} {step['private_ip']}:{step['target_port']}")
if len(matched) > 1:
summary = ", ".join(item["load_balancer_id"] for item in matched)
raise RuntimeError(f"multiple CLB bindings discovered for {step['service']} on {step['host']}: {summary}")
discovered = matched[0]
step["clb_load_balancer_id"] = discovered["load_balancer_id"]
step["clb_listener_ids"] = sorted({binding["listener_id"] for binding in discovered["bindings"] if binding["listener_id"]})
return {"step": step, "bindings": discovered["bindings"], "discovered": True}
def modify_binding_weight(client: Any, inventory: dict[str, Any], binding: dict[str, Any], weight: int, load_balancer_id: str) -> None:
payload: dict[str, Any] = {
"LoadBalancerId": load_balancer_id,
"ListenerId": binding["listener_id"],
"Targets": [{"InstanceId": binding["instance_id"], "Port": int(binding["port"])}],
"Weight": int(weight),
}
if binding.get("location_id"):
payload["LocationId"] = binding["location_id"]
response = call_clb(client, "ModifyTargetWeight", payload)
wait_clb_task(
client,
str(response.get("RequestId") or "").strip(),
timeout_seconds=int(inventory.get("clb_task_timeout_seconds") or 180),
poll_seconds=int(inventory.get("clb_poll_seconds") or 2),
)
def set_clb_snapshot_weight(client: Any, inventory: dict[str, Any], snapshot: dict[str, Any], weight: int | None) -> None:
step = snapshot["step"]
for binding in snapshot["bindings"]:
target_weight = int(binding["weight"]) if weight is None else int(weight)
modify_binding_weight(client, inventory, binding, target_weight, step["clb_load_balancer_id"])
def remote_restart_script(service: dict[str, Any], *, image: str, health_timeout_seconds: int) -> str:
# 远端只改当前前端服务的 docker.env 和 systemd unit避免碰同机其他项目。
lines = [
"set -euo pipefail",
f"UNIT={shlex.quote(str(service['unit']))}",
f"CONTAINER={shlex.quote(str(service['container']))}",
f"ENV_FILE={shlex.quote(str(service['env_file']))}",
f"HEALTH_TIMEOUT={int(health_timeout_seconds)}",
]
if image:
lines.extend(
[
f"IMAGE={shlex.quote(image)}",
'test -f "$ENV_FILE"',
'TMP_FILE="$(mktemp)"',
'awk -v image="$IMAGE" \'BEGIN{done=0} /^IMAGE=/{print "IMAGE=" image; done=1; next} {print} END{if(!done) print "IMAGE=" image}\' "$ENV_FILE" > "$TMP_FILE"',
'install -m 0640 "$TMP_FILE" "$ENV_FILE"',
'rm -f "$TMP_FILE"',
'docker pull "$IMAGE"',
]
)
lines.extend(
[
'systemctl restart "$UNIT"',
'deadline=$((SECONDS + HEALTH_TIMEOUT))',
'while ! systemctl is-active --quiet "$UNIT"; do',
' if [ "$SECONDS" -ge "$deadline" ]; then',
' systemctl status "$UNIT" --no-pager || true',
' exit 20',
" fi",
" sleep 2",
"done",
'while [ "$SECONDS" -lt "$deadline" ]; do',
' state="$(docker inspect --format \'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}\' "$CONTAINER" 2>/dev/null || true)"',
' if [ "$state" = "healthy" ] || [ "$state" = "running" ]; then',
' docker ps --filter "name=$CONTAINER" --format "{{.Names}} {{.Status}}"',
" exit 0",
" fi",
" sleep 2",
"done",
'docker inspect "$CONTAINER" 2>/dev/null || true',
'journalctl -u "$UNIT" -n 80 --no-pager || true',
"exit 21",
]
)
return "\n".join(lines) + "\n"
def remote_discover_script() -> str:
# 只读取 hyapp 命名空间内的 unit/env/container不扫描或修改其他项目。
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 || /:29200/' || true
"""
def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool) -> dict[str, Any]:
if dry_run:
return {"ok": True, "dry_run": True, "steps": plan}
tat_client = build_tat_client(inventory)
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}
hosts: list[dict[str, Any]] = []
for host_name, seed in by_host.items():
try:
result = run_tat_shell(
tat_client,
instance_id=seed["instance_id"],
script=remote_discover_script(),
command_name=f"deploy-platform-discover-{host_name}",
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
poll_seconds=int(inventory.get("tat_poll_seconds") or 2),
)
hosts.append(
{
"host": host_name,
"instance_id": seed["instance_id"],
"private_ip": seed["private_ip"],
"status": result["status"],
"output": str(result.get("output") or "")[-5000:],
}
)
except Exception as exc: # noqa: BLE001
hosts.append(
{
"host": host_name,
"instance_id": seed["instance_id"],
"private_ip": seed["private_ip"],
"status": "ERROR",
"error": str(exc),
}
)
clb_bindings: list[dict[str, Any]] = []
for step in plan:
if not step["clb_enabled"]:
continue
try:
snapshot = prepare_clb_snapshot(clb_client, dict(step))
clb_bindings.append(
{
"service": step["service"],
"host": step["host"],
"load_balancer_id": snapshot["step"]["clb_load_balancer_id"],
"listener_ids": snapshot["step"]["clb_listener_ids"],
"bindings": snapshot["bindings"],
"discovered": bool(snapshot.get("discovered")),
}
)
except Exception as exc: # noqa: BLE001
clb_bindings.append(
{
"service": step["service"],
"host": step["host"],
"error": str(exc),
}
)
return {"ok": True, "hosts": hosts, "clb": clb_bindings}
def execute_plan(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool, assume_yes: bool) -> dict[str, Any]:
if dry_run:
return {"ok": True, "dry_run": True, "steps": plan}
if not assume_yes:
raise RuntimeError("real execution requires --yes")
tat_client = build_tat_client(inventory)
clb_client = build_clb_client(inventory) if any(step["clb_enabled"] for step in plan) else None
results: list[dict[str, Any]] = []
for step in plan:
started = time.time()
record = dict(step)
snapshot: dict[str, Any] | None = None
try:
if step["clb_enabled"]:
snapshot = prepare_clb_snapshot(clb_client, step)
set_clb_snapshot_weight(clb_client, inventory, snapshot, 0)
if step["drain_seconds"] > 0:
time.sleep(step["drain_seconds"])
record["clb_drained"] = True
service_cfg = inventory["services"][step["service"]]
script = remote_restart_script(
service_cfg,
image=step["image"] if step["action"] == "deploy" else "",
health_timeout_seconds=int(inventory.get("service_health_timeout_seconds") or 150),
)
remote = run_tat_shell(
tat_client,
instance_id=step["instance_id"],
script=script,
command_name=f"deploy-platform-{step['action']}-{step['service']}-{step['host']}",
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
poll_seconds=int(inventory.get("tat_poll_seconds") or 2),
)
record["remote_status"] = remote["status"]
record["remote_output"] = str(remote.get("output") or "")[-2000:]
if remote["status"] != "SUCCESS":
raise RuntimeError(f"TAT command failed: {remote['status']}")
if snapshot is not None:
# CLB 后端健康由 CLB 自己持续探测;服务本地健康通过后恢复原权重。
set_clb_snapshot_weight(clb_client, inventory, snapshot, None)
record["clb_restored"] = True
stabilize = int(inventory.get("stabilize_seconds") or 0)
if stabilize > 0:
time.sleep(stabilize)
record["status"] = "success"
record["duration_seconds"] = round(time.time() - started, 2)
results.append(record)
except Exception as exc: # noqa: BLE001
record["status"] = "failed"
record["error"] = str(exc)
record["duration_seconds"] = round(time.time() - started, 2)
results.append(record)
return {"ok": False, "steps": results, "error": str(exc)}
return {"ok": True, "steps": results}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Deploy deploy-platform through Tencent TAT with optional CLB drain.")
parser.add_argument("action", choices=["plan", "discover", "restart", "deploy"])
parser.add_argument("--inventory", default="deploy/tencent-tat/inventory.prod.example.json")
parser.add_argument("--env-file", default="", help="Optional .env file with Tencent Cloud credentials.")
parser.add_argument("--services", required=True, help="Comma separated service names.")
parser.add_argument("--hosts", default="", help="Optional comma separated host names for one-node maintenance.")
parser.add_argument("--tag", default="", help="Release tag used by image_template during deploy.")
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("--yes", action="store_true", help="Required for real restart/deploy execution.")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
try:
args = parse_args(argv)
load_env_file(args.env_file)
inventory = load_inventory(args.inventory)
service_names = selected_service_names(inventory, args.services)
overrides = image_overrides(args.image)
action = "restart" if args.action == "plan" else args.action
plan = build_plan(
inventory,
action=action,
service_names=service_names,
raw_hosts=args.hosts,
tag=args.tag,
overrides=overrides,
no_clb=args.no_clb,
)
if args.action == "plan":
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)
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)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 1
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@ -0,0 +1,199 @@
{
"region": "me-saudi-arabia",
"registry": "10.2.1.3:18082/hyapp",
"tat_timeout_seconds": 900,
"tat_poll_seconds": 2,
"clb_task_timeout_seconds": 180,
"clb_poll_seconds": 2,
"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."
},
"deploy_server": {
"name": "deploy",
"instance_id": "ins-ntmpcd3a",
"public_ip": "43.164.75.199",
"private_ip": "10.2.1.3",
"role": "control plane only: build/push images and call Tencent TAT/CLB APIs; do not attach to business CLB."
},
"hosts": {
"gateway-1": {
"instance_id": "ins-aeyaoj40",
"private_ip": "10.2.1.8"
},
"gateway-2": {
"instance_id": "ins-irvi5b7u",
"private_ip": "10.2.2.17"
},
"new-app-1": {
"instance_id": "ins-fi5zufpk",
"private_ip": "10.2.1.4"
},
"new-app-2": {
"instance_id": "ins-hwhaoe8c",
"private_ip": "10.2.1.13"
},
"new-game-1": {
"instance_id": "ins-ir229jtw",
"private_ip": "10.2.1.10"
},
"new-game-2": {
"instance_id": "ins-460rw7gu",
"private_ip": "10.2.1.6"
},
"new-app-activity-1": {
"instance_id": "ins-n8wut6n8",
"private_ip": "10.2.1.11"
},
"new-app-activity-2": {
"instance_id": "ins-d1n303sa",
"private_ip": "10.2.1.7"
},
"pay-1": {
"instance_id": "ins-ceqfcxd2",
"private_ip": "10.2.11.14"
},
"pay-2": {
"instance_id": "ins-awhb74q6",
"private_ip": "10.2.12.5"
}
},
"services": {
"gateway-service": {
"unit": "hyapp-gateway-service",
"container": "hyapp-gateway-service",
"env_file": "/etc/hyapp/gateway-service/docker.env",
"image_template": "${REGISTRY}/gateway-service:${TAG}",
"target_port": 13000,
"hosts": [
"gateway-1",
"gateway-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-cbiabr1q",
"listener_ids": [
"lbl-ktvz8m6k"
],
"drain_seconds": 10
}
},
"room-service": {
"unit": "hyapp-room-service",
"container": "hyapp-room-service",
"env_file": "/etc/hyapp/room-service/docker.env",
"image_template": "${REGISTRY}/room-service:${TAG}",
"target_port": 13001,
"hosts": [
"new-app-1",
"new-app-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-d2urlzba"
],
"drain_seconds": 5
}
},
"user-service": {
"unit": "hyapp-user-service",
"container": "hyapp-user-service",
"env_file": "/etc/hyapp/user-service/docker.env",
"image_template": "${REGISTRY}/user-service:${TAG}",
"target_port": 13005,
"hosts": [
"new-app-1",
"new-app-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-honi8z3a"
],
"drain_seconds": 5
}
},
"wallet-service": {
"unit": "hyapp-wallet-service",
"container": "hyapp-wallet-service",
"env_file": "/etc/hyapp/wallet-service/docker.env",
"image_template": "${REGISTRY}/wallet-service:${TAG}",
"target_port": 13004,
"hosts": [
"pay-1",
"pay-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-4f5xi6p0",
"listener_ids": [
"lbl-9wi5mvu4"
],
"drain_seconds": 5
}
},
"activity-service": {
"unit": "hyapp-activity-service",
"container": "hyapp-activity-service",
"env_file": "/etc/hyapp/activity-service/docker.env",
"image_template": "${REGISTRY}/activity-service:${TAG}",
"target_port": 13006,
"hosts": [
"new-app-activity-1",
"new-app-activity-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-j7oqtfhq"
],
"drain_seconds": 5
}
},
"notice-service": {
"unit": "hyapp-notice-service",
"container": "hyapp-notice-service",
"env_file": "/etc/hyapp/notice-service/docker.env",
"image_template": "${REGISTRY}/notice-service:${TAG}",
"target_port": 13009,
"hosts": [
"new-app-activity-1",
"new-app-activity-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-aux9xsjq"
],
"drain_seconds": 5
}
},
"game-service": {
"unit": "hyapp-game-service",
"container": "hyapp-game-service",
"env_file": "/etc/hyapp/game-service/docker.env",
"image_template": "${REGISTRY}/game-service:${TAG}",
"target_port": 13008,
"hosts": [
"new-game-1",
"new-game-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-ku138b4y"
],
"drain_seconds": 5
}
}
}
}

View File

@ -0,0 +1,710 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import json
import os
import shlex
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
def load_env_file(path: str) -> None:
# hy-app-monitor 现有 .env 可直接复用;这里只注入缺失变量,不覆盖调用方显式 export 的值。
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:
# 发布脚本只认明确的 host/service 映射,避免误操作同机已有 Java 项目。
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")
for host_name, host in inventory["hosts"].items():
if not str(host.get("instance_id") or "").strip():
raise RuntimeError(f"hosts.{host_name}.instance_id is required")
if not str(host.get("private_ip") or "").strip():
raise RuntimeError(f"hosts.{host_name}.private_ip is required")
for service_name, service in inventory["services"].items():
for key in ("unit", "container", "env_file", "target_port", "hosts"):
if key not in service:
raise RuntimeError(f"services.{service_name}.{key} is required")
for host_name in service["hosts"]:
if host_name not in inventory["hosts"]:
raise RuntimeError(f"services.{service_name}.hosts contains unknown host: {host_name}")
def csv_values(text: str) -> list[str]:
return [item.strip() for item in str(text or "").split(",") if item.strip()]
def selected_service_names(inventory: dict[str, Any], raw_services: str) -> list[str]:
names = csv_values(raw_services)
if not names:
raise RuntimeError("--services is required")
unknown = [name for name in names if name not in inventory["services"]]
if unknown:
raise RuntimeError(f"unsupported services: {', '.join(unknown)}")
return list(dict.fromkeys(names))
def selected_host_names(service: dict[str, Any], raw_hosts: str) -> list[str]:
hosts = list(dict.fromkeys(str(item) for item in service.get("hosts") or []))
requested = csv_values(raw_hosts)
if not requested:
return hosts
missing = [host for host in requested if host not in hosts]
if missing:
raise RuntimeError(f"selected hosts are not assigned to this service: {', '.join(missing)}")
return requested
def image_overrides(items: list[str]) -> dict[str, str]:
overrides: dict[str, str] = {}
for item in items:
if "=" not in item:
raise RuntimeError("--image must use service=image")
service_name, image = item.split("=", 1)
service_name = service_name.strip()
image = image.strip()
if not service_name or not image:
raise RuntimeError("--image must use non-empty service=image")
overrides[service_name] = image
return overrides
def render_image(inventory: dict[str, Any], service_name: str, tag: str, overrides: dict[str, str]) -> str:
if service_name in overrides:
return overrides[service_name]
if not tag:
raise RuntimeError(f"--tag or --image {service_name}=... is required for deploy")
service = inventory["services"][service_name]
registry = str(inventory.get("registry") or "").rstrip("/")
template = str(service.get("image_template") or "${REGISTRY}/" + service_name + ":${TAG}")
return template.replace("${REGISTRY}", registry).replace("${TAG}", tag)
def build_plan(
inventory: dict[str, Any],
*,
action: str,
service_names: list[str],
raw_hosts: str,
tag: str,
overrides: dict[str, str],
no_clb: bool,
) -> list[dict[str, Any]]:
# 顺序是服务优先、实例串行;多服务发布不会同时摘掉同一组双机实例。
plan: list[dict[str, Any]] = []
for service_name in service_names:
service = inventory["services"][service_name]
image = render_image(inventory, service_name, tag, overrides) if action == "deploy" else ""
for host_name in selected_host_names(service, raw_hosts):
host = inventory["hosts"][host_name]
clb = service.get("clb") or {}
plan.append(
{
"action": action,
"service": service_name,
"host": host_name,
"instance_id": host["instance_id"],
"private_ip": host["private_ip"],
"target_port": int(service["target_port"]),
"unit": service["unit"],
"container": service["container"],
"image": image,
"clb_enabled": bool(clb.get("enabled")) and not no_clb,
"clb_load_balancer_id": str(clb.get("load_balancer_id") or ""),
"clb_listener_ids": list(clb.get("listener_ids") or []),
"drain_seconds": int(clb.get("drain_seconds") or 0),
}
)
return plan
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 build_clb_client(inventory: dict[str, Any]) -> Any:
sid, skey, reg = require_tencent_credentials(inventory)
try:
from tencentcloud.clb.v20180317 import clb_client
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc
return clb_client.ClbClient(
credential.Credential(sid, skey),
reg,
ClientProfile(httpProfile=HttpProfile(endpoint="clb.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]:
# TAT 是本方案唯一远端入口;脚本不会要求目标机开放 SSH。
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 call_clb(client: Any, method_name: str, payload: dict[str, Any]) -> dict[str, Any]:
from tencentcloud.clb.v20180317 import models as clb_models
request_class = getattr(clb_models, f"{method_name}Request")
request = request_class()
request.from_json_string(json.dumps(payload))
response = invoke_tencent(lambda: getattr(client, method_name)(request), method_name)
decoded = json.loads(response.to_json_string())
if isinstance(decoded.get("Response"), dict):
return dict(decoded["Response"])
return dict(decoded)
def wait_clb_task(client: Any, task_id: str, *, timeout_seconds: int, poll_seconds: int) -> None:
# ModifyTargetWeight 是异步任务,必须轮询到成功后才能重启实例。
deadline = time.time() + max(timeout_seconds, 1)
while time.time() < deadline:
payload = call_clb(client, "DescribeTaskStatus", {"TaskId": task_id})
status = int(payload.get("Status") if payload.get("Status") is not None else 2)
if status == 0:
return
if status == 1:
raise RuntimeError(f"CLB task failed: {payload.get('Message') or task_id}")
time.sleep(max(poll_seconds, 1))
raise RuntimeError(f"CLB task timed out: {task_id}")
def target_matches(step: dict[str, Any], target: dict[str, Any]) -> bool:
instance_id = str(target.get("InstanceId") or target.get("TargetId") or "").strip()
if instance_id and instance_id != step["instance_id"]:
return False
target_port = int(target.get("Port") or 0)
if target_port and target_port != int(step["target_port"]):
return False
ips = [str(item or "").strip() for item in (target.get("PrivateIpAddresses") or [target.get("IP")]) if str(item or "").strip()]
if ips and step["private_ip"] not in ips:
return False
return bool(instance_id or ips)
def binding_key(binding: dict[str, Any]) -> str:
return "|".join(
[
str(binding.get("listener_id") or ""),
str(binding.get("location_id") or ""),
str(binding.get("instance_id") or ""),
str(int(binding.get("port") or 0)),
]
)
def collect_bindings(step: dict[str, Any], listeners: list[dict[str, Any]]) -> list[dict[str, Any]]:
bindings: dict[str, dict[str, Any]] = {}
for listener in listeners:
listener_id = str(listener.get("ListenerId") or "").strip()
for target in listener.get("Targets") or []:
if target_matches(step, target):
binding = make_binding(step, listener_id, "", target)
bindings[binding_key(binding)] = binding
for rule in listener.get("Rules") or []:
location_id = str(rule.get("LocationId") or "").strip()
for target in rule.get("Targets") or []:
if target_matches(step, target):
binding = make_binding(step, listener_id, location_id, target)
bindings[binding_key(binding)] = binding
return list(bindings.values())
def make_binding(step: dict[str, Any], listener_id: str, location_id: str, target: dict[str, Any]) -> dict[str, Any]:
return {
"listener_id": listener_id,
"location_id": location_id,
"instance_id": str(target.get("InstanceId") or target.get("TargetId") or step["instance_id"]).strip(),
"port": int(target.get("Port") or step["target_port"]),
"weight": int(target.get("Weight") or 0),
}
def prepare_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]:
if should_auto_discover_clb(step):
return discover_clb_snapshot(client, step)
payload = call_clb(
client,
"DescribeTargets",
{
"LoadBalancerId": step["clb_load_balancer_id"],
"ListenerIds": step["clb_listener_ids"],
},
)
bindings = collect_bindings(step, list(payload.get("Listeners") or []))
if not bindings:
return discover_clb_snapshot(client, step)
return {"step": step, "bindings": bindings}
def should_auto_discover_clb(step: dict[str, Any]) -> bool:
load_balancer_id = str(step.get("clb_load_balancer_id") or "").strip()
listener_ids = [str(item or "").strip() for item in step.get("clb_listener_ids") or []]
if not load_balancer_id or "REPLACE" in load_balancer_id or load_balancer_id.upper() == "AUTO":
return True
return any((not item) or "REPLACE" in item or item.upper() == "AUTO" for item in listener_ids)
def discover_load_balancers_for_backend(client: Any, step: dict[str, Any]) -> list[dict[str, Any]]:
# 腾讯云 CLB 支持按后端私网 IP 反查负载均衡,避免手填内网 CLB ID。
offset = 0
found: list[dict[str, Any]] = []
while True:
payload = call_clb(
client,
"DescribeLoadBalancers",
{
"BackendPrivateIps": [step["private_ip"]],
"WithRs": 1,
"Limit": 100,
"Offset": offset,
},
)
items = list(payload.get("LoadBalancerSet") or [])
found.extend(items)
total = int(payload.get("TotalCount") or len(found))
if len(found) >= total or not items:
break
offset += len(items)
return found
def discover_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]:
load_balancers = discover_load_balancers_for_backend(client, step)
matched: list[dict[str, Any]] = []
for load_balancer in load_balancers:
load_balancer_id = str(load_balancer.get("LoadBalancerId") or "").strip()
if not load_balancer_id:
continue
payload = call_clb(client, "DescribeTargets", {"LoadBalancerId": load_balancer_id})
bindings = collect_bindings(step, list(payload.get("Listeners") or []))
if bindings:
matched.append({"load_balancer_id": load_balancer_id, "bindings": bindings})
if not matched:
raise RuntimeError(f"CLB target not found for {step['service']} on {step['host']} {step['private_ip']}:{step['target_port']}")
if len(matched) > 1:
summary = ", ".join(item["load_balancer_id"] for item in matched)
raise RuntimeError(f"multiple CLB bindings discovered for {step['service']} on {step['host']}: {summary}")
discovered = matched[0]
step["clb_load_balancer_id"] = discovered["load_balancer_id"]
step["clb_listener_ids"] = sorted({binding["listener_id"] for binding in discovered["bindings"] if binding["listener_id"]})
return {"step": step, "bindings": discovered["bindings"], "discovered": True}
def modify_binding_weight(client: Any, inventory: dict[str, Any], binding: dict[str, Any], weight: int, load_balancer_id: str) -> None:
payload: dict[str, Any] = {
"LoadBalancerId": load_balancer_id,
"ListenerId": binding["listener_id"],
"Targets": [{"InstanceId": binding["instance_id"], "Port": int(binding["port"])}],
"Weight": int(weight),
}
if binding.get("location_id"):
payload["LocationId"] = binding["location_id"]
response = call_clb(client, "ModifyTargetWeight", payload)
wait_clb_task(
client,
str(response.get("RequestId") or "").strip(),
timeout_seconds=int(inventory.get("clb_task_timeout_seconds") or 180),
poll_seconds=int(inventory.get("clb_poll_seconds") or 2),
)
def set_clb_snapshot_weight(client: Any, inventory: dict[str, Any], snapshot: dict[str, Any], weight: int | None) -> None:
step = snapshot["step"]
for binding in snapshot["bindings"]:
target_weight = int(binding["weight"]) if weight is None else int(weight)
modify_binding_weight(client, inventory, binding, target_weight, step["clb_load_balancer_id"])
def remote_restart_script(service: dict[str, Any], *, image: str, health_timeout_seconds: int) -> str:
# 远端只改当前 Go 服务的 docker.env 和 systemd unit避免碰同机 Java compose。
lines = [
"set -euo pipefail",
f"UNIT={shlex.quote(str(service['unit']))}",
f"CONTAINER={shlex.quote(str(service['container']))}",
f"ENV_FILE={shlex.quote(str(service['env_file']))}",
f"HEALTH_TIMEOUT={int(health_timeout_seconds)}",
]
if image:
lines.extend(
[
f"IMAGE={shlex.quote(image)}",
'test -f "$ENV_FILE"',
'TMP_FILE="$(mktemp)"',
'awk -v image="$IMAGE" \'BEGIN{done=0} /^IMAGE=/{print "IMAGE=" image; done=1; next} {print} END{if(!done) print "IMAGE=" image}\' "$ENV_FILE" > "$TMP_FILE"',
'install -m 0640 "$TMP_FILE" "$ENV_FILE"',
'rm -f "$TMP_FILE"',
'docker pull "$IMAGE"',
]
)
lines.extend(
[
'systemctl restart "$UNIT"',
'deadline=$((SECONDS + HEALTH_TIMEOUT))',
'while ! systemctl is-active --quiet "$UNIT"; do',
' if [ "$SECONDS" -ge "$deadline" ]; then',
' systemctl status "$UNIT" --no-pager || true',
' exit 20',
" fi",
" sleep 2",
"done",
'while [ "$SECONDS" -lt "$deadline" ]; do',
' state="$(docker inspect --format \'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}\' "$CONTAINER" 2>/dev/null || true)"',
' if [ "$state" = "healthy" ] || [ "$state" = "running" ]; then',
' docker ps --filter "name=$CONTAINER" --format "{{.Names}} {{.Status}}"',
" exit 0",
" fi",
" sleep 2",
"done",
'docker inspect "$CONTAINER" 2>/dev/null || true',
'journalctl -u "$UNIT" -n 80 --no-pager || true',
"exit 21",
]
)
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 execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool) -> 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}
def discover_host(host_name: str, seed: dict[str, Any]) -> dict[str, Any]:
try:
tat_client = build_tat_client(inventory)
result = run_tat_shell(
tat_client,
instance_id=seed["instance_id"],
script=remote_discover_script(),
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),
)
return {
"host": host_name,
"instance_id": seed["instance_id"],
"private_ip": seed["private_ip"],
"status": result["status"],
"output": str(result.get("output") or "")[-5000:],
}
except Exception as exc: # noqa: BLE001
return {
"host": host_name,
"instance_id": seed["instance_id"],
"private_ip": seed["private_ip"],
"status": "ERROR",
"error": str(exc),
}
host_results: dict[str, dict[str, Any]] = {}
max_workers = max(1, min(len(by_host), int(inventory.get("discover_parallelism") or 6)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_host = {executor.submit(discover_host, host_name, seed): host_name for host_name, seed in by_host.items()}
for future in as_completed(future_to_host):
host_name = future_to_host[future]
host_results[host_name] = future.result()
hosts = [host_results[name] for name in seen_hosts]
clb_bindings: list[dict[str, Any]] = []
for step in plan:
if not step["clb_enabled"]:
continue
try:
snapshot = prepare_clb_snapshot(clb_client, dict(step))
clb_bindings.append(
{
"service": step["service"],
"host": step["host"],
"load_balancer_id": snapshot["step"]["clb_load_balancer_id"],
"listener_ids": snapshot["step"]["clb_listener_ids"],
"bindings": snapshot["bindings"],
"discovered": bool(snapshot.get("discovered")),
}
)
except Exception as exc: # noqa: BLE001
clb_bindings.append(
{
"service": step["service"],
"host": step["host"],
"error": str(exc),
}
)
return {"ok": True, "hosts": hosts, "clb": clb_bindings}
def execute_plan(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool, assume_yes: bool) -> dict[str, Any]:
if dry_run:
return {"ok": True, "dry_run": True, "steps": plan}
if not assume_yes:
raise RuntimeError("real execution requires --yes")
tat_client = build_tat_client(inventory)
clb_client = build_clb_client(inventory) if any(step["clb_enabled"] for step in plan) else None
results: list[dict[str, Any]] = []
for step in plan:
started = time.time()
record = dict(step)
snapshot: dict[str, Any] | None = None
try:
if step["clb_enabled"]:
snapshot = prepare_clb_snapshot(clb_client, step)
set_clb_snapshot_weight(clb_client, inventory, snapshot, 0)
if step["drain_seconds"] > 0:
time.sleep(step["drain_seconds"])
record["clb_drained"] = True
service_cfg = inventory["services"][step["service"]]
script = remote_restart_script(
service_cfg,
image=step["image"] if step["action"] == "deploy" else "",
health_timeout_seconds=int(inventory.get("service_health_timeout_seconds") or 150),
)
remote = run_tat_shell(
tat_client,
instance_id=step["instance_id"],
script=script,
command_name=f"hyapp-{step['action']}-{step['service']}-{step['host']}",
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
poll_seconds=int(inventory.get("tat_poll_seconds") or 2),
)
record["remote_status"] = remote["status"]
record["remote_output"] = str(remote.get("output") or "")[-2000:]
if remote["status"] != "SUCCESS":
raise RuntimeError(f"TAT command failed: {remote['status']}")
if snapshot is not None:
# CLB 后端健康由 CLB 自己持续探测;服务本地健康通过后恢复原权重。
set_clb_snapshot_weight(clb_client, inventory, snapshot, None)
record["clb_restored"] = True
stabilize = int(inventory.get("stabilize_seconds") or 0)
if stabilize > 0:
time.sleep(stabilize)
record["status"] = "success"
record["duration_seconds"] = round(time.time() - started, 2)
results.append(record)
except Exception as exc: # noqa: BLE001
record["status"] = "failed"
record["error"] = str(exc)
record["duration_seconds"] = round(time.time() - started, 2)
results.append(record)
return {"ok": False, "steps": results, "error": str(exc)}
return {"ok": True, "steps": results}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Deploy hyapp services through Tencent TAT with optional CLB drain.")
parser.add_argument("action", choices=["plan", "discover", "restart", "deploy"])
parser.add_argument("--inventory", default="deploy/tencent-tat/hyapp-services.inventory.prod.example.json")
parser.add_argument("--env-file", default="", help="Optional .env file with Tencent Cloud credentials.")
parser.add_argument("--services", required=True, help="Comma separated service names.")
parser.add_argument("--hosts", default="", help="Optional comma separated host names for one-node maintenance.")
parser.add_argument("--tag", default="", help="Release tag used by image_template during deploy.")
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("--yes", action="store_true", help="Required for real restart/deploy execution.")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
try:
args = parse_args(argv)
load_env_file(args.env_file)
inventory = load_inventory(args.inventory)
service_names = selected_service_names(inventory, args.services)
overrides = image_overrides(args.image)
action = "restart" if args.action == "plan" else args.action
plan = build_plan(
inventory,
action=action,
service_names=service_names,
raw_hosts=args.hosts,
tag=args.tag,
overrides=overrides,
no_clb=args.no_clb,
)
if args.action == "plan":
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)
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)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 1
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@ -0,0 +1,304 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import sys
import time
from pathlib import Path
from typing import Any
DEFAULT_REGION = "ap-jakarta"
DEFAULT_INSTANCE_ID = "lhins-q0m38zc6"
DEFAULT_ROOT = "/opt/hyapp-server-test"
DEFAULT_SERVICES = (
"gateway-service",
"room-service",
"wallet-service",
"user-service",
"activity-service",
"cron-service",
"game-service",
"notice-service",
"admin-server",
)
SERVICE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
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 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 require_credentials() -> tuple[str, str]:
sid = secret_id()
skey = secret_key()
missing = []
if not sid:
missing.append("TENCENTCLOUD_SECRET_ID")
if not skey:
missing.append("TENCENTCLOUD_SECRET_KEY")
if missing:
raise RuntimeError("missing Tencent Cloud credentials: " + ", ".join(missing))
return sid, skey
def csv_values(text: str) -> list[str]:
return [item.strip() for item in str(text or "").split(",") if item.strip()]
def parse_services(raw_services: str) -> list[str]:
services = csv_values(raw_services) or list(DEFAULT_SERVICES)
invalid = [service for service in services if not SERVICE_NAME_RE.match(service)]
if invalid:
raise RuntimeError(f"invalid service names: {', '.join(invalid)}")
return list(dict.fromkeys(services))
def build_tat_client(region: str) -> Any:
sid, skey = require_credentials()
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),
region,
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 remote_config_script(*, root: str, services: list[str], redact: bool, max_bytes: int) -> str:
payload = {
"maxBytes": max_bytes,
"redact": redact,
"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 re
import subprocess
payload = json.loads(base64.b64decode({blob!r}).decode("utf-8"))
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)
def service_path(service):
if service == "admin-server":
return os.path.join(root, "admin-server", "config.yaml")
return os.path.join(root, "source", "services", service, "configs", "config.docker.yaml")
def redact_yaml(text):
if not redact:
return text
lines = []
for line in text.splitlines():
match = re.match(r"^(\\s*[-\\w.]+\\s*:\\s*)(.*)$", line)
if match and secret_key_re.search(match.group(1)):
lines.append(match.group(1) + '"***"')
else:
lines.append(line)
return "\\n".join(lines) + ("\\n" if text.endswith("\\n") else "")
files = []
for service in services:
path = service_path(service)
item = {{
"content": "",
"exists": os.path.exists(path),
"path": path,
"service": service,
"truncated": False,
}}
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"] = redact_yaml(content[:max_bytes])
files.append(item)
def command_output(args):
try:
return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=8).strip()
except Exception as exc:
return str(exc)
result = {{
"deployedCommit": command_output(["bash", "-lc", f"cat {{root}}/.deployed_commit 2>/dev/null || true"]),
"files": files,
"ok": True,
"redacted": redact,
"target": {{
"instanceId": "lhins-q0m38zc6",
"privateIp": "10.11.0.2",
"publicIp": "43.165.195.39",
"root": root,
"timer": "hyapp-server-test-deploy.timer",
}},
"timerState": command_output(["systemctl", "is-active", "hyapp-server-test-deploy.timer"]),
}}
print(json.dumps(result, ensure_ascii=False))
PY
"""
def read_configs(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_config_script(root=args.root, services=services, redact=not args.raw, max_bytes=args.max_bytes),
command_name="hyapp-testbox-read-config",
timeout_seconds=args.timeout_seconds,
poll_seconds=args.poll_seconds,
)
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 build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Read HYApp testbox deployment config via Tencent TAT.")
parser.add_argument("action", choices=("configs",))
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("--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("--poll-seconds", type=int, default=2)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
load_env_file(args.env_file)
payload = read_configs(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

@ -0,0 +1,43 @@
{
"region": "me-saudi-arabia",
"registry": "10.2.1.3:18082/hyapp",
"tat_timeout_seconds": 900,
"tat_poll_seconds": 2,
"clb_task_timeout_seconds": 180,
"clb_poll_seconds": 2,
"service_health_timeout_seconds": 120,
"stabilize_seconds": 5,
"deploy_server": {
"name": "deploy",
"instance_id": "ins-ntmpcd3a",
"public_ip": "43.164.75.199",
"private_ip": "10.2.1.3",
"role": "build/push deploy-platform image and call Tencent TAT/CLB APIs"
},
"hosts": {
"deploy-platform-1": {
"instance_id": "REPLACE_INSTANCE_ID",
"private_ip": "REPLACE_PRIVATE_IP"
}
},
"services": {
"deploy-platform": {
"unit": "hyapp-deploy-platform",
"container": "hyapp-deploy-platform",
"env_file": "/etc/hyapp/deploy-platform/docker.env",
"image_template": "${REGISTRY}/deploy-platform:${TAG}",
"target_port": 29200,
"hosts": [
"deploy-platform-1"
],
"clb": {
"enabled": false,
"load_balancer_id": "AUTO",
"listener_ids": [
"AUTO"
],
"drain_seconds": 5
}
}
}
}

View File

@ -0,0 +1,825 @@
# 液态玻璃后台 UI 设计规范
适用产品:运维管理平台、微服务管理平台、发布/重启/回滚控制台、数据库资源管理后台。
设计目标:在保持截图中的“液态玻璃”视觉质感的同时,保证后台系统需要的信息密度、可读性、风险控制和操作效率。
## 1. 设计定位
整体风格定义为:浅色冰蓝液态玻璃后台。
它不是营销页、不是展示型大屏,而是一个高频操作的运维控制台。界面第一优先级是让用户快速回答以下问题:
- 当前项目和环境是否健康
- 哪些服务异常
- 最近谁发布了什么
- 是否可以安全执行发布、重启、回滚
- MySQL、MongoDB、Redis、MQ 等公共资源是否正常
## 2. 核心设计原则
### 2.1 信息优先
- 首页核心区域优先展示微服务列表,而不是装饰性卡片。
- 服务状态、版本、实例数、CPU/内存、最近发布、负责人、操作入口必须在同一行内可扫描。
- 高风险动作必须可见但不应过于顺手,生产环境操作必须经过二次确认。
### 2.2 玻璃感服务于层级
- 玻璃效果主要用于页面壳、侧边栏、顶部筛选区、统计区、表格容器、详情面板和发布抽屉。
- 不要把每一小块内容都做成强玻璃卡片,避免界面碎片化。
- 同一页面最多使用 3 个玻璃层级:页面容器、内容容器、浮层/抽屉。
### 2.3 克制的视觉强度
- 主色使用冰蓝和白色透明层。
- 状态色只用于语义:健康、警告、严重、未知、执行中。
- 图表可以使用蓝、绿、红、紫、橙作为指标区分,避免整个平台只有一种蓝色。
### 2.4 风险可控
- 发布、重启、回滚、数据库恢复、权限变更必须有影响范围说明。
- 生产环境必须显示环境提醒和审计提示。
- 所有关键操作都要在事件流和审计日志中可追踪。
## 3. 页面布局规范
### 3.1 全局框架
推荐结构:
- 左侧导航:项目运维台主导航
- 顶部工具栏:项目、环境、集群、告警、用户角色
- 主内容区:当前页面的核心任务
- 右侧抽屉:发布、重启、配置变更等流程型操作
桌面端基础尺寸:
| 区域 | 尺寸 |
| --- | --- |
| 页面最小宽度 | 1280px |
| 左侧导航宽度 | 216px |
| 收起导航宽度 | 72px |
| 顶部栏高度 | 64px |
| 内容区外边距 | 20px |
| 页面模块间距 | 16px / 20px |
| 右侧抽屉宽度 | 420px - 460px |
### 3.2 首页布局
首页名称建议:项目运维台。
从上到下:
1. 顶部筛选栏:项目、环境、集群、当前告警、当前用户、角色。
2. 状态总览:运行中服务数、异常服务数、最近发布、活跃告警、数据库状态。
3. 微服务列表服务、状态、版本、实例数、CPU/内存、最近发布、负责人、操作。
4. 下方可选区域:最近事件、资源状态、告警摘要。
首页不使用大 Hero不放宣传说明不做低密度卡片瀑布。
### 3.3 服务详情页布局
服务详情页建议结构:
- 顶部:返回、面包屑、服务名称、状态、命名空间、负责人、创建时间。
- 标签页:概览、实例、发布、日志、监控、配置、事件。
- 概览页:基础状态、依赖服务、监控概览、最近事件、实例列表。
- 右侧抽屉:发布、重启、回滚等操作流程。
## 4. 设计 Token
### 4.1 颜色
基础色:
| Token | 色值 | 用途 |
| --- | --- | --- |
| `--color-bg-page` | `#edf6ff` | 页面底色 |
| `--color-bg-page-soft` | `#f7fbff` | 页面浅色过渡 |
| `--color-glass` | `rgba(247, 251, 255, 0.58)` | 默认玻璃面 |
| `--color-glass-strong` | `rgba(255, 255, 255, 0.72)` | 强玻璃面 |
| `--color-glass-muted` | `rgba(232, 244, 255, 0.42)` | 弱玻璃面 |
| `--color-glass-active` | `rgba(219, 236, 255, 0.76)` | 导航/标签激活背景 |
| `--color-border-light` | `rgba(255, 255, 255, 0.82)` | 高光边 |
| `--color-border-soft` | `rgba(114, 151, 188, 0.22)` | 分割线 |
文字色:
| Token | 色值 | 用途 |
| --- | --- | --- |
| `--color-text-primary` | `#1d2b3a` | 标题、关键数字 |
| `--color-text-secondary` | `#52677f` | 正文、表格内容 |
| `--color-text-tertiary` | `#7b8da3` | 辅助信息 |
| `--color-text-disabled` | `#a8b7c8` | 禁用文本 |
| `--color-text-inverse` | `#ffffff` | 深色按钮文字 |
品牌与操作色:
| Token | 色值 | 用途 |
| --- | --- | --- |
| `--color-brand` | `#2f7df6` | 主按钮、激活态 |
| `--color-brand-hover` | `#1f6ee8` | 主按钮 hover |
| `--color-brand-soft` | `#e6f1ff` | 品牌浅背景 |
| `--color-brand-ring` | `rgba(47, 125, 246, 0.28)` | 聚焦环 |
语义色:
| 状态 | 主色 | 背景 | 用途 |
| --- | --- | --- | --- |
| Healthy | `#18a957` | `rgba(24, 169, 87, 0.12)` | 健康、通过 |
| Warning | `#f5a524` | `rgba(245, 165, 36, 0.14)` | 警告、需关注 |
| Critical | `#e5484d` | `rgba(229, 72, 77, 0.14)` | 严重、失败 |
| Running | `#2f7df6` | `rgba(47, 125, 246, 0.14)` | 执行中、发布中 |
| Unknown | `#8b98a9` | `rgba(139, 152, 169, 0.14)` | 未知、未连接 |
图表色:
| 指标 | 色值 |
| --- | --- |
| CPU / QPS | `#4f83ff` |
| 内存 | `#37b66a` |
| 错误率 | `#ff5a57` |
| 平均延迟 | `#9b72f2` |
| P95 延迟 | `#ff9f43` |
### 4.2 背景
页面背景使用柔和的大面积渐变,不使用离散装饰圆球、漂浮光斑或强装饰图形。
推荐 CSS
```css
:root {
--page-bg:
linear-gradient(135deg, rgba(255, 255, 255, 0.76), rgba(221, 239, 255, 0.72)),
radial-gradient(1200px 720px at 12% 8%, rgba(255, 255, 255, 0.82), transparent 62%),
radial-gradient(1000px 680px at 88% 0%, rgba(255, 232, 224, 0.36), transparent 58%),
linear-gradient(180deg, #f7fbff 0%, #eaf5ff 100%);
}
```
背景可以有轻微的折射感,但必须保证表格、表单、抽屉背后的区域足够干净。
### 4.3 玻璃材质
默认玻璃面:
```css
.glass-surface {
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.68), rgba(232, 244, 255, 0.46));
border: 1px solid rgba(255, 255, 255, 0.82);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.88),
inset 0 -1px 0 rgba(122, 158, 192, 0.12),
0 18px 48px rgba(55, 101, 145, 0.16);
backdrop-filter: blur(24px) saturate(145%);
-webkit-backdrop-filter: blur(24px) saturate(145%);
}
```
强玻璃面用于右侧抽屉、弹窗、顶部关键容器:
```css
.glass-surface-strong {
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(236, 246, 255, 0.58));
border: 1px solid rgba(255, 255, 255, 0.9);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.95),
0 24px 64px rgba(48, 92, 132, 0.2);
backdrop-filter: blur(30px) saturate(150%);
-webkit-backdrop-filter: blur(30px) saturate(150%);
}
```
弱玻璃面用于表格行、输入框、标签、依赖服务 Chip
```css
.glass-subtle {
background: rgba(255, 255, 255, 0.48);
border: 1px solid rgba(255, 255, 255, 0.66);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
}
```
降级要求:
- 不支持 `backdrop-filter` 时,玻璃背景透明度提高到 `0.88`
- 表格、表单和抽屉必须保持可读,不允许依赖模糊效果本身提供对比度。
### 4.4 圆角
| 类型 | 圆角 |
| --- | --- |
| 页面主壳、侧边栏、右侧抽屉 | 20px |
| 一级内容容器 | 16px |
| 表格容器、图表容器 | 12px |
| 数据卡片、菜单、输入框 | 8px |
| 状态 Badge、Chip | 6px |
| 图标按钮 | 8px |
原则:只有框架级玻璃容器可以使用较大圆角;高密度数据卡片和表格单元保持 8px 左右,避免后台界面过度柔化。
### 4.5 阴影
| Token | 值 | 用途 |
| --- | --- | --- |
| `--shadow-glass-sm` | `0 8px 24px rgba(55, 101, 145, 0.12)` | 小控件 |
| `--shadow-glass-md` | `0 18px 48px rgba(55, 101, 145, 0.16)` | 卡片/表格容器 |
| `--shadow-glass-lg` | `0 24px 64px rgba(48, 92, 132, 0.2)` | 抽屉/弹窗 |
| `--shadow-focus` | `0 0 0 4px rgba(47, 125, 246, 0.22)` | 聚焦态 |
### 4.6 字体
字体栈:
```css
font-family: Inter, "SF Pro Display", "SF Pro Text", "PingFang SC", "Microsoft YaHei", sans-serif;
```
字号:
| 场景 | 字号 | 行高 | 字重 |
| --- | --- | --- | --- |
| 页面标题 | 20px | 28px | 700 |
| 面板标题 | 16px | 24px | 700 |
| 表格正文 | 13px | 20px | 500 |
| 正文 | 14px | 22px | 500 |
| 辅助文本 | 12px | 18px | 500 |
| 关键数字 | 28px | 36px | 700 |
| 状态 Badge | 12px | 18px | 600 |
规范:
- 不使用负字距。
- 表格和表单内不要使用过大的标题字。
- 数字指标可以使用 `font-variant-numeric: tabular-nums;`,方便对齐。
### 4.7 间距
基础间距:`4 / 8 / 12 / 16 / 20 / 24 / 32`
常用规则:
- 页面外边距20px。
- 模块间距16px。
- 表格单元格左右 padding16px。
- 表单项垂直间距12px。
- 抽屉步骤块间距16px。
- 按钮内边距:水平 16px垂直 9px。
## 5. 组件规范
### 5.1 侧边导航
用途:承载平台一级功能,不放业务详情。
规格:
- 宽度216px。
- 外边距16px。
- 导航项高度44px。
- 图标18px。
- 激活态:左侧 3px 蓝色指示条 + 浅蓝玻璃背景。
- 文案:总览、微服务、发布记录、数据漫游、配置中心、日志与事件、监控告警、权限与审计。
交互:
- Hover背景透明度提升边框高光增强。
- Active图标和文字使用品牌蓝。
- 收起状态只显示图标hover 显示 Tooltip。
### 5.2 顶部工具栏
用途:表达当前操作上下文。
必须包含:
- 项目选择器
- 环境切换dev / test / staging / prod
- 集群选择器
- 当前告警入口
- 当前用户
- 当前角色
环境切换规范:
- `prod` 激活时使用蓝色强调,但旁边的危险操作流程必须出现生产环境提醒。
- 不同环境切换后,页面数据和操作权限必须同步变化。
### 5.3 状态总览卡
规格:
- 高度104px - 112px。
- 图标容器44px。
- 标题12px 辅助文本。
- 主数字28px。
- 单卡最多展示一个主指标。
推荐卡片:
- 运行中服务数
- 异常服务数
- 最近发布
- 活跃告警
- 数据库状态
### 5.4 表格
表格是运维平台的核心组件。
规格:
- 表头高度44px。
- 行高56px。
- 单元格左右间距16px。
- 表格容器使用一级玻璃面。
- 表头使用弱玻璃背景。
- 行分割线使用 `rgba(114, 151, 188, 0.16)`
服务列表字段:
| 字段 | 说明 |
| --- | --- |
| 服务 | 服务名、可选命名空间 |
| 状态 | Healthy / Warning / Critical / Unknown |
| 当前版本 | 镜像 Tag 或语义化版本 |
| 实例数 | 当前可用/期望实例 |
| CPU / 内存 | 数字 + 细进度条 |
| 最近发布 | 相对时间 |
| 负责人 | 团队或人员 |
| 操作 | 日志、详情、更多 |
操作列规范:
- 高频安全操作直接展示:日志、详情。
- 发布、重启、配置、回滚放入更多菜单。
- 危险操作使用红色文本,并触发二次确认或流程抽屉。
### 5.5 状态 Badge
规格:
- 高度24px。
- 内边距:`0 8px`
- 圆角6px。
- 左侧可带 6px 状态点。
文案固定:
- `Healthy`
- `Warning`
- `Critical`
- `Running`
- `Unknown`
状态不能只依赖颜色,必须保留文字。
### 5.6 按钮
按钮类型:
| 类型 | 用途 |
| --- | --- |
| Primary | 开始发布、确认执行 |
| Secondary | 取消、返回 |
| Ghost | 表格内轻量操作 |
| Danger | 删除、强制重启、回滚生产 |
| Icon | 更多、关闭、刷新、返回 |
规格:
- 默认高度36px。
- 大按钮高度40px。
- 图标按钮32px x 32px。
- 主按钮可以使用蓝色轻微渐变和柔和阴影。
Primary 示例:
```css
.button-primary {
color: #fff;
background: linear-gradient(180deg, #3f8cff 0%, #1f6ee8 100%);
box-shadow: 0 10px 24px rgba(47, 125, 246, 0.34);
}
```
### 5.7 表单和选择器
规格:
- 高度36px。
- 圆角8px。
- 背景:弱玻璃面。
- Focus品牌蓝边框 + 聚焦环。
- Disabled降低透明度文字使用禁用色。
下拉选择器用于:
- 项目
- 集群
- 镜像 Tag
- 命名空间
- 数据库实例
### 5.8 Tabs
服务详情页使用 Tabs。
规格:
- 高度48px。
- 激活态:蓝色文字 + 底部 2px 指示线。
- 标签数量超过 7 个时,后续项目进入更多菜单。
推荐顺序:
概览、实例、发布、日志、监控、配置、事件。
### 5.9 右侧流程抽屉
用途:发布、重启、回滚、配置变更。
规格:
- 宽度420px - 460px。
- 高度:全高或内容区全高。
- 背景:强玻璃面。
- 顶部固定:标题 + 关闭按钮。
- 底部固定:取消 + 主操作按钮。
- 中间内容滚动。
发布抽屉步骤:
1. 选择版本或镜像 Tag。
2. 选择发布策略:滚动发布、灰度发布、全量发布。
3. 查看变更内容。
4. 预检查。
5. 确认执行。
6. 发布中。
7. 完成。
生产环境提醒:
- 放在抽屉顶部。
- 使用 Warning 语义色。
- 明确展示当前环境、影响实例数、是否需要二次确认。
### 5.10 Stepper
规格:
- 圆点尺寸22px。
- 当前步骤:品牌蓝圆点 + 白色数字。
- 完成步骤Success 色 + 对勾。
- 未开始步骤:灰色圆点。
- 每个步骤块使用弱玻璃面。
步骤内容必须简洁,避免长段说明。
### 5.11 图表
适用于服务详情、监控概览、数据库资源页。
规范:
- 使用轻量折线图。
- 网格线透明度低。
- 坐标轴文字使用辅助文本色。
- 图表高度120px - 180px。
- 小图表不放过多图例。
- 图表颜色固定使用指标色,不随机。
监控概览推荐:
- CPU 使用率
- 内存使用率
- QPS
- 错误率
- 平均响应延迟
- P95 响应延迟
### 5.12 菜单
规格:
- 宽度120px - 180px。
- 背景:强玻璃面。
- 菜单项高度36px。
- 危险操作放底部,红色文字,必要时加分割线。
服务行更多菜单:
- 发布
- 重启
- 配置
- 回滚
### 5.13 Toast 和通知
位置:
- 默认右上角。
- 发布流程内的状态反馈优先显示在抽屉内部,不只依赖 Toast。
类型:
- 成功:发布完成、重启完成。
- 警告:预检查有风险。
- 错误:发布失败、连接失败。
- 信息:任务已提交。
### 5.14 空状态和异常状态
空状态保持克制:
- 不使用大插画。
- 使用图标 + 一句话 + 主操作按钮。
示例:
- 暂无服务
- 暂无发布记录
- 当前环境未接入数据库
异常状态必须给出下一步:
- 重试
- 查看日志
- 查看事件
- 联系负责人
## 6. 运维业务页面规范
### 6.1 微服务页
核心能力:
- 服务搜索
- 状态筛选
- 负责人筛选
- 环境和集群继承顶部上下文
- 批量操作仅允许安全操作,危险操作默认不批量开放
列表优先,不使用卡片墙。
### 6.2 发布记录页
字段:
- 时间
- 服务
- 环境
- 版本变更
- 发布策略
- 操作人
- 状态
- 耗时
- 操作入口
状态:
- 成功
- 失败
- 执行中
- 已回滚
### 6.3 数据资源页
不要默认做成在线数据库客户端。第一版以运维视角为主。
资源类型:
- MySQL
- MongoDB
- Redis
- MQ / Kafka
每个资源展示:
- 连接状态
- 主从/副本状态
- 容量
- 连接数
- 慢查询/错误摘要
- 备份状态
- 所属服务
- 负责人
生产环境查询能力必须加权限、审批、脱敏和审计。
### 6.4 日志与事件页
日志规范:
- 时间范围
- 服务筛选
- 级别筛选
- 关键词搜索
- 实时跟随开关
事件规范:
- 发布事件
- 重启事件
- 健康检查事件
- 配置变更事件
- 数据库连接异常事件
### 6.5 权限与审计页
必须展示:
- 操作人
- 操作对象
- 操作类型
- 环境
- 操作时间
- 执行结果
- 关联工单或审批记录
## 7. 交互状态
### 7.1 Hover
- 玻璃面亮度轻微增加。
- 边框高光增强。
- 可点击区域必须显示指针。
- 不使用明显位移,避免表格扫描时跳动。
### 7.2 Active
- 导航、Tabs、环境切换使用品牌蓝。
- 按钮 Active 可以降低亮度。
### 7.3 Focus
所有可交互元素必须有键盘 Focus 状态。
推荐:
```css
:focus-visible {
outline: none;
box-shadow: 0 0 0 4px rgba(47, 125, 246, 0.22);
}
```
### 7.4 Loading
场景:
- 页面首次加载:骨架屏。
- 表格局部刷新:表格内 loading不遮挡整个页面。
- 发布执行:抽屉内步骤状态实时更新。
### 7.5 Disabled
- 降低透明度。
- 保留 Tooltip 或说明,解释为什么不可操作。
- 对危险操作,权限不足时不要隐藏入口,建议展示禁用态并说明权限要求。
## 8. 动效规范
动效只用于反馈,不用于装饰。
时间:
| 场景 | 时长 |
| --- | --- |
| Hover / Press | 120ms |
| 菜单展开 | 160ms |
| 抽屉进入 | 220ms |
| Toast 出现 | 180ms |
| 步骤状态变化 | 180ms |
缓动:
```css
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
```
禁止:
- 背景长期大幅动画。
- 表格行持续闪烁。
- 告警使用高频闪烁。
## 9. 可访问性和可读性
最低要求:
- 正文和背景对比度不低于 4.5:1。
- 大号关键数字对比度不低于 3:1。
- 状态不能只靠颜色表达,必须有文字或图标。
- 点击目标不小于 32px x 32px。
- 抽屉、弹窗、菜单支持键盘关闭。
- 用户开启减少动态效果时,禁用非必要过渡。
玻璃面可读性规则:
- 承载正文的玻璃层透明度不低于 0.58。
- 承载表格和表单的玻璃层透明度不低于 0.68。
- 背景折射纹理不能穿过表格文字密集区域。
## 10. 文案规范
操作文案:
- 使用动词 + 对象:发布服务、重启实例、回滚版本。
- 危险操作必须说明影响:将重启 3 个实例,期间可能出现短暂连接波动。
- 避免模糊文案:确定、提交、处理。
状态文案:
- 发布中
- 发布成功
- 发布失败
- 预检查通过
- 预检查未通过
- 等待确认
生产环境确认文案示例:
> 当前环境为 prod本次操作将影响 3 个实例。请确认已评估影响范围,并已记录审计日志。
## 11. 全局实现建议
建议在前端项目中建立以下基础层:
```text
styles/
tokens.css
glass.css
layout.css
components.css
```
基础 CSS 变量示例:
```css
:root {
color-scheme: light;
--color-bg-page: #edf6ff;
--color-text-primary: #1d2b3a;
--color-text-secondary: #52677f;
--color-text-tertiary: #7b8da3;
--color-brand: #2f7df6;
--color-success: #18a957;
--color-warning: #f5a524;
--color-danger: #e5484d;
--radius-shell: 20px;
--radius-panel: 16px;
--radius-card: 8px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--shadow-glass-sm: 0 8px 24px rgba(55, 101, 145, 0.12);
--shadow-glass-md: 0 18px 48px rgba(55, 101, 145, 0.16);
--shadow-glass-lg: 0 24px 64px rgba(48, 92, 132, 0.2);
}
```
## 12. 禁止项
- 不要做营销型 Hero 首页。
- 不要把后台做成大面积宣传卡片。
- 不要让危险操作裸露成显眼的大按钮。
- 不要在表格密集区域使用过强背景纹理。
- 不要只用颜色表达服务状态。
- 不要在玻璃容器中再嵌套多层卡片。
- 不要把生产环境操作和测试环境操作做成完全一样的确认流程。
- 不要默认开放生产数据库查询入口。
- 不要隐藏审计记录。
## 13. 页面优先级
MVP 推荐顺序:
1. 项目运维台首页。
2. 微服务列表。
3. 服务详情页。
4. 发布抽屉。
5. 重启确认流程。
6. 发布记录。
7. 数据资源状态页。
8. 日志与事件。
9. 权限与审计。
判断一个页面是否符合规范,可以用三句话检查:
- 第一眼能不能看出当前环境和服务健康情况。
- 第二步能不能定位异常服务和关键指标。
- 第三步执行危险操作时,是否有明确影响范围、确认流程和审计记录。

View File

@ -0,0 +1,921 @@
# 运维管理平台模块化开发规范
依据:
- [液态玻璃后台 UI 设计规范](./liquid-glass-admin-ui-guideline.md)
- [运维管理平台第一版开发内容](./v1-development-scope.md)
目标:建立一套可长期维护的开发规范,让第一版能快速落地,同时避免后续接入真实 Kubernetes、日志、监控、数据库资源、审批流时推倒重来。
## 1. 技术方向
第一版默认按前后端分离设计,前端先使用 Mock 数据完成完整业务闭环,后端接口后续按相同契约接入。
推荐前端技术栈:
| 类型 | 推荐 |
| --- | --- |
| 语言 | TypeScript |
| UI 框架 | React |
| 构建工具 | Vite |
| 路由 | React Router |
| 服务端状态 | TanStack Query |
| 客户端状态 | Zustand 或 React Context |
| 表单 | React Hook Form |
| 校验 | Zod |
| Mock | MSW |
| 单元测试 | Vitest |
| 组件测试 | Testing Library |
| E2E | Playwright |
| 样式 | CSS Variables + CSS Modules |
| 图标 | lucide-react |
| 图表 | Recharts 或 ECharts |
技术选择原则:
- 服务端数据用 Query 管理,不放进全局 Store。
- 项目、环境、集群这类 UI 上下文可以放全局 Store。
- UI Token 必须通过 CSS 变量统一管理。
- Mock 层必须和真实 API 共用类型定义。
- 业务模块优先纵向切分,不按文件类型横向堆叠。
## 2. 模块化架构
推荐使用“业务垂直切片 + 分层依赖”的结构。
目录结构:
```text
src/
app/
App.tsx
router.tsx
providers/
QueryProvider.tsx
AppStateProvider.tsx
styles/
tokens.css
glass.css
layout.css
components.css
pages/
overview/
services/
service-detail/
releases/
resources/
logs-events/
audit/
widgets/
app-shell/
top-context-bar/
service-table/
release-drawer/
restart-dialog/
metric-dashboard/
event-timeline/
features/
switch-project-context/
filter-services/
release-service/
restart-service/
search-logs/
filter-audits/
entities/
project/
cluster/
service/
release/
resource/
event/
audit/
user/
shared/
api/
config/
constants/
lib/
types/
ui/
hooks/
mocks/
assets/
```
分层职责:
| 层级 | 职责 | 示例 |
| --- | --- | --- |
| `app` | 应用启动、路由、全局 Provider、全局样式 | `router.tsx``QueryProvider` |
| `pages` | 页面编排,只组合 widgets/features/entities | `OverviewPage` |
| `widgets` | 页面级业务区块,可包含多个 feature | `ServiceTable``ReleaseDrawer` |
| `features` | 用户可执行的业务动作 | 发布服务、重启服务、筛选服务 |
| `entities` | 业务实体模型、实体 API、实体展示组件 | `service``release` |
| `shared` | 无业务含义的通用能力 | Button、Table、fetcher、formatDate |
依赖方向:
```text
app -> pages -> widgets -> features -> entities -> shared
```
规则:
- 下层不能 import 上层。
- `shared` 不能 import 任何业务模块。
- `entities` 之间尽量不直接互相 import跨实体组合放在 `features``widgets`
- 页面不能直接写复杂业务逻辑,页面只做布局和组合。
- 同一业务动作只允许有一个入口模块,例如发布服务统一放在 `features/release-service`
## 3. 模块目录规范
每个业务模块使用相同结构,便于查找。
```text
features/release-service/
api/
createRelease.ts
model/
schema.ts
types.ts
useReleaseService.ts
ui/
ReleaseForm.tsx
ReleaseSteps.tsx
lib/
buildReleasePayload.ts
index.ts
```
`index.ts` 只导出模块的公开 API。
允许导出:
- 页面或组件需要使用的 UI。
- Hook。
- 类型。
- 纯工具函数。
禁止导出:
- 内部私有组件。
- 临时常量。
- Mock 实现细节。
- 只为绕过依赖规则而暴露的对象。
## 4. 命名规范
文件命名:
| 类型 | 规范 | 示例 |
| --- | --- | --- |
| React 组件 | PascalCase | `ServiceTable.tsx` |
| Hook | camelCase`use` 开头 | `useServicesQuery.ts` |
| 工具函数 | camelCase | `formatDuration.ts` |
| 类型文件 | `types.ts` | `entities/service/model/types.ts` |
| Schema | `schema.ts` | `release/model/schema.ts` |
| 常量 | `constants.ts` | `shared/constants/status.ts` |
| 样式模块 | 同组件名 | `ServiceTable.module.css` |
| 测试 | 同文件名 + `.test` | `formatDuration.test.ts` |
类型命名:
- 业务实体:`Service``ReleaseRecord``DataResource`
- API 请求:`CreateReleaseRequest`
- API 响应:`ServiceListResponse`
- 表单值:`ReleaseFormValues`
- 组件 Props`ServiceTableProps`
状态枚举:
- TypeScript 使用字符串联合类型优先。
- 后端返回值必须稳定,不使用中文作为状态码。
示例:
```ts
export type ServiceStatus = "healthy" | "warning" | "critical" | "unknown" | "running";
```
## 5. TypeScript 规范
必须开启:
```json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
```
规则:
- 禁止使用 `any`,确实无法确定时使用 `unknown` 并收窄。
- API 返回值必须有类型。
- 表单提交值必须有 Zod Schema 校验。
- 组件 Props 必须显式定义。
- 复杂类型放在 `model/types.ts`
- 跨模块共享类型只能从模块 `index.ts` 导入。
- 不在组件里直接拼接后端数据结构,使用 mapper 转成前端 ViewModel。
推荐:
```ts
type ServiceTableRow = {
id: string;
name: string;
status: ServiceStatus;
version: string;
instanceText: string;
cpuUsage: number;
memoryUsage: number;
owner: string;
};
```
## 6. API 层规范
统一入口:
```text
shared/api/
httpClient.ts
queryKeys.ts
errors.ts
```
实体 API
```text
entities/service/api/
getServices.ts
getServiceDetail.ts
getServiceInstances.ts
getServiceMetrics.ts
```
规则:
- 所有请求必须经过 `shared/api/httpClient.ts`
- API 函数只负责请求和数据转换,不处理 UI Toast。
- 请求参数统一使用对象,不使用多个位置参数。
- 列表接口必须支持 `projectId``environment``clusterId`
- 写操作必须返回可用于刷新缓存的业务 ID。
- API 错误统一转成标准错误对象。
标准错误:
```ts
type ApiError = {
code: string;
message: string;
requestId?: string;
details?: unknown;
};
```
Query Key 规范:
```ts
export const queryKeys = {
services: (params: ServiceListParams) => ["services", params] as const,
serviceDetail: (id: string) => ["service", id] as const,
releases: (params: ReleaseListParams) => ["releases", params] as const,
};
```
## 7. 状态管理规范
状态分三类:
| 类型 | 管理方式 | 示例 |
| --- | --- | --- |
| 服务端状态 | TanStack Query | 服务列表、发布记录、数据资源 |
| 全局 UI 上下文 | Zustand / Context | 项目、环境、集群、当前用户 |
| 局部 UI 状态 | React `useState` | 抽屉开关、当前 Tab、表单中间态 |
全局 Store 只放:
- 当前项目。
- 当前环境。
- 当前集群。
- 当前用户和角色。
- 全局布局状态。
禁止放入全局 Store
- 服务列表。
- 发布记录。
- 日志列表。
- 监控图表数据。
- 临时表单值。
原因:这些数据属于服务端状态,应该由 Query 缓存、刷新和失效控制。
## 8. UI 组件规范
组件分三类:
| 类型 | 目录 | 说明 |
| --- | --- | --- |
| 通用 UI | `shared/ui` | Button、Table、Badge、Drawer |
| 实体组件 | `entities/*/ui` | ServiceStatusBadge、ResourceTypeIcon |
| 业务组件 | `features` / `widgets` | ReleaseDrawer、ServiceTable |
通用 UI 规则:
- 不包含业务字段。
- 不直接请求接口。
- 不读取全局业务 Store。
- 通过 Props 接收数据。
- 支持 `className`
- 支持键盘焦点。
- 支持 disabled、loading、error 状态。
业务组件规则:
- 可以组合实体组件和通用 UI。
- 可以调用 feature 内 Hook。
- 不直接写全局样式。
- 危险操作必须显式传入 `environment`
组件文件结构:
```text
Button/
Button.tsx
Button.module.css
Button.test.tsx
index.ts
```
## 9. 样式规范
样式分层:
```text
app/styles/
tokens.css
glass.css
layout.css
components.css
```
规则:
- 颜色、圆角、阴影、字号、间距必须使用 Token。
- 组件局部样式使用 CSS Modules。
- 禁止在业务组件里硬编码主色和状态色。
- 禁止使用行内样式写核心布局。
- 不使用负字距。
- 不使用大面积单色主题覆盖液态玻璃风格。
- 图表颜色从 Token 或统一常量中读取。
CSS 命名:
```css
.root {}
.header {}
.body {}
.footer {}
.title {}
.meta {}
.actions {}
.isActive {}
.isDisabled {}
```
玻璃样式统一使用类:
- `.glass-surface`
- `.glass-surface-strong`
- `.glass-subtle`
不要在每个组件里重复实现玻璃效果。
## 10. 页面开发规范
页面只做三件事:
- 读取路由参数。
- 组合 widgets/features。
- 控制页面级布局。
页面不做:
- 直接调用底层 HTTP。
- 写复杂数据转换。
- 处理发布和重启流程细节。
- 写超过 80 行的业务逻辑。
页面示例:
```tsx
export function ServicesPage() {
return (
<PageLayout title="微服务">
<ServiceFilters />
<ServiceTable />
</PageLayout>
);
}
```
## 11. 业务模块规范
### 11.1 服务模块
目录:
```text
entities/service/
api/
model/
ui/
lib/
index.ts
```
职责:
- 服务类型定义。
- 服务列表接口。
- 服务详情接口。
- 服务状态展示。
- 服务数据格式化。
不负责:
- 发布服务。
- 重启服务。
- 服务筛选表单。
### 11.2 发布模块
目录:
```text
features/release-service/
api/
model/
ui/
lib/
index.ts
```
职责:
- 版本选择。
- 发布策略选择。
- 预检查展示。
- 发布提交。
- 发布状态反馈。
规则:
- `prod` 必须要求二次确认。
- 发布完成后必须刷新服务详情和发布记录。
- 发布成功或失败都必须生成事件和审计记录。
### 11.3 重启模块
职责:
- 单实例重启。
- 全部实例重启。
- 滚动重启和立即重启。
- 影响范围展示。
规则:
- `prod` 默认滚动重启。
- 立即重启必须二次确认。
- 操作完成后必须生成事件和审计记录。
### 11.4 数据资源模块
职责:
- 展示 MySQL、MongoDB、Redis、Kafka 状态。
- 展示容量、连接数、备份、关联服务。
- 展示异常事件。
规则:
- 第一版不开放查询入口。
- 第一版不开放恢复入口。
- 生产环境不显示任何直接执行数据库命令的能力。
## 12. 表单规范
所有表单必须:
- 使用 Schema 校验。
- 提交前做客户端校验。
- 提交中禁用提交按钮。
- 提交失败展示错误原因。
- 危险操作展示影响范围。
发布表单必须校验:
- 目标版本不能为空。
- 发布策略不能为空。
- `prod` 环境必须勾选确认。
重启表单必须校验:
- 重启范围不能为空。
- 立即重启必须二次确认。
- 单实例重启必须有实例 ID。
## 13. 权限规范
前端权限用于控制体验,后端权限用于保证安全。第一版即使没有真实后端,也要按这个结构设计。
权限点:
```ts
type Permission =
| "service:read"
| "service:release"
| "service:restart"
| "release:read"
| "resource:read"
| "logs:read"
| "audit:read";
```
规则:
- 权限不足的危险操作显示禁用态,不直接隐藏。
- 禁用态必须说明原因。
- 生产环境操作必须额外判断角色和确认状态。
- 审计页面只对有 `audit:read` 权限的角色开放。
## 14. Mock 规范
Mock 使用 MSW 或等价方案。
目录:
```text
shared/mocks/
browser.ts
handlers.ts
data/
projects.ts
services.ts
releases.ts
resources.ts
events.ts
audits.ts
```
规则:
- Mock 数据结构必须复用实体类型。
- Mock 行为要模拟真实写操作。
- 发布成功后更新服务版本。
- 重启成功后新增事件和审计。
- 支持模拟失败场景。
- 支持模拟空状态。
必须覆盖:
- Healthy、Warning、Critical 服务。
- 发布成功、失败、执行中。
- MySQL、MongoDB、Redis、Kafka 多种状态。
- `prod` 环境二次确认。
- 权限不足禁用操作。
## 15. 错误处理规范
错误分三层:
| 层级 | 处理 |
| --- | --- |
| API 层 | 转换为 `ApiError` |
| 业务层 | 判断是否可重试、是否需要刷新 |
| UI 层 | 展示错误状态、Toast、下一步操作 |
页面错误态必须提供:
- 错误原因。
- 重试按钮。
- 可选的查看事件入口。
写操作失败必须提供:
- 失败原因。
- requestId如果有。
- 是否可以重试。
- 是否需要查看事件。
禁止:
- 只显示“操作失败”。
- 把原始异常对象直接渲染到页面。
- 静默吞掉接口错误。
## 16. 日志和审计规范
前端事件日志用于排查,审计记录用于追责。
发布、重启、回滚必须产生审计记录。
审计记录字段:
- 操作时间。
- 操作人。
- 角色。
- 操作对象。
- 操作类型。
- 环境。
- 集群。
- 执行结果。
- 关联记录 ID。
生产环境操作必须有明显标识。
## 17. 测试规范
测试分层:
| 类型 | 工具 | 覆盖内容 |
| --- | --- | --- |
| 单元测试 | Vitest | 工具函数、mapper、权限判断、表单校验 |
| 组件测试 | Testing Library | Button、Table、Badge、ReleaseDrawer |
| 集成测试 | Testing Library + MSW | 发布流程、重启流程、筛选流程 |
| E2E | Playwright | 核心用户路径 |
必须测试:
- 服务状态 Badge 文案和颜色类。
- 服务搜索和筛选。
- `prod` 发布确认。
- 发布成功后版本更新。
- 发布失败后错误反馈。
- 重启成功后事件和审计新增。
- 权限不足时按钮禁用。
- Secret 不显示明文。
E2E 核心路径:
1. 进入总览页。
2. 切换到 `prod`
3. 打开 `user-service` 详情。
4. 打开发版抽屉。
5. 选择目标版本。
6. 勾选生产确认。
7. 执行发布。
8. 验证发布记录和审计记录。
## 18. 代码质量规范
必须配置:
- ESLint。
- Prettier。
- TypeScript strict。
- lint-staged。
- 单元测试命令。
- 构建命令。
建议脚本:
```json
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test",
"check": "npm run lint && npm run test && npm run build"
}
}
```
提交前最低要求:
- TypeScript 无错误。
- ESLint 无错误。
- 单元测试通过。
- 构建通过。
涉及 UI 的改动还要:
- 浏览器预览。
- 检查桌面宽度 1280px。
- 检查表格文字不重叠。
- 检查抽屉内容可滚动。
- 检查 `prod` 危险操作二次确认。
## 19. Git 和提交规范
分支命名:
```text
feature/overview-page
feature/release-drawer
fix/prod-release-confirm
chore/setup-eslint
```
提交信息:
```text
feat: add service release drawer
fix: require confirmation before prod restart
chore: setup testing pipeline
docs: add modular development guideline
```
提交类型:
| 类型 | 含义 |
| --- | --- |
| `feat` | 新功能 |
| `fix` | 修复 |
| `docs` | 文档 |
| `style` | 格式和样式调整 |
| `refactor` | 重构 |
| `test` | 测试 |
| `chore` | 工程配置 |
规则:
- 一个提交只做一类事情。
- 不把大范围格式化混进功能提交。
- 不提交本地环境文件。
- 不提交密钥、Token、数据库连接串。
## 20. 环境变量规范
命名:
```text
VITE_API_BASE_URL=
VITE_ENABLE_MOCK=
VITE_APP_ENV=
```
规则:
- 前端环境变量必须以 `VITE_` 开头。
- `.env.example` 必须维护。
- `.env.local` 不提交。
- 不在前端存储真实密钥。
- 生产环境配置由部署平台注入。
## 21. 性能规范
第一版性能目标:
- 首屏可交互时间控制在合理范围。
- 表格 100 条数据内交互不卡顿。
- 路由页面按需加载。
- 图表组件按页面懒加载。
- 日志列表避免一次渲染过多行。
规则:
- 路由级代码分割。
- 大图表库懒加载。
- 表格筛选优先使用 memoized selector。
- 不在 render 中做重型计算。
- 日志列表超过 500 行时考虑虚拟列表。
## 22. 安全规范
第一版必须遵守:
- 不展示 Secret 明文。
- 不在 localStorage 存储敏感 Token。
- 所有危险操作必须检查权限。
- 所有生产环境操作必须二次确认。
- 后端接入后必须校验权限,不能只依赖前端。
- 日志内容渲染要避免 XSS。
- 用户输入不能直接拼接到 URL path必须 encode。
数据库资源限制:
- 第一版不做 SQL 查询。
- 第一版不做 Mongo 查询。
- 第一版不做备份恢复。
- 生产环境不显示直接执行命令入口。
## 23. 可访问性规范
最低要求:
- 所有按钮可键盘访问。
- 抽屉和弹窗可按 Esc 关闭。
- 菜单可键盘导航。
- 状态 Badge 必须有文字。
- 表单错误信息和字段绑定。
- 点击目标不小于 32px x 32px。
- 颜色不能作为唯一状态表达。
## 24. 开发顺序
推荐顺序:
1. 初始化项目和质量工具。
2. 建立 Token、玻璃样式、App Shell。
3. 建立 Mock 数据和 API 适配层。
4. 开发通用 UI 组件。
5. 开发实体模块service、release、resource、event、audit。
6. 开发首页和微服务列表。
7. 开发服务详情页。
8. 开发发布抽屉和重启流程。
9. 开发发布记录、数据资源、日志事件、审计页。
10. 补齐测试、空状态、错误状态。
11. 浏览器视觉验收。
12. 构建验收。
## 25. Definition of Done
一个功能完成必须满足:
- 代码按模块边界放置。
- 类型完整,无 `any`
- 样式使用全局 Token。
- Loading、Empty、Error 状态完整。
- 权限不足有禁用态和原因。
- `prod` 危险操作有二次确认。
- 写操作成功和失败都有反馈。
- 相关事件和审计记录更新。
- 单元测试或集成测试覆盖关键逻辑。
- `lint``test``build` 通过。
- 浏览器中验证 UI 不重叠、不溢出、抽屉可滚动。
## 26. 禁止项
- 禁止页面直接调用 `fetch`
- 禁止在页面里堆业务逻辑。
- 禁止业务模块跨层反向依赖。
- 禁止在组件里硬编码设计 Token。
- 禁止把服务端状态放进全局 Store。
- 禁止生产环境危险操作绕过确认。
- 禁止展示 Secret 明文。
- 禁止生产数据库直接查询入口。
- 禁止只做成功态,不做失败态和空状态。
- 禁止没有审计记录的发布、重启、回滚。
## 27. 第一版推荐任务拆分
### 基础工程
- 初始化 Vite + React + TypeScript。
- 配置 ESLint、Prettier、Vitest。
- 配置路由和 Query Provider。
- 建立 `src` 分层目录。
- 建立全局样式 Token。
### UI 基础
- Button。
- IconButton。
- StatusBadge。
- GlassSurface。
- Table。
- Select。
- Tabs。
- Drawer。
- Dialog。
- Toast。
- EmptyState。
### 业务闭环
- Mock 数据。
- 总览页。
- 微服务页。
- 服务详情页。
- 发布抽屉。
- 重启确认。
- 发布记录页。
- 数据资源页。
- 日志与事件页。
- 权限与审计页。
### 验收
- 单元测试。
- 发布流程集成测试。
- 重启流程集成测试。
- Playwright 核心路径。
- 构建验证。
- 视觉检查。

View File

@ -0,0 +1,902 @@
# 运维管理平台第一版开发内容
依据:[液态玻璃后台 UI 设计规范](./liquid-glass-admin-ui-guideline.md)
第一版目标:先做一个可用的运维控制台闭环,让用户可以在同一个平台里查看项目环境状态、管理微服务、执行发布/重启、查看公共数据资源状态,并追踪操作记录。
第一版不做成数据库客户端,也不引入复杂审批流。生产环境相关操作先通过二次确认、影响范围提示和审计记录来控制风险。
## 1. 第一版范围
### 1.1 必须完成
- 全局液态玻璃 UI 基础层。
- 项目运维台首页。
- 微服务列表。
- 服务详情页。
- 发布服务抽屉。
- 重启服务确认流程。
- 发布记录页。
- 数据资源状态页。
- 日志与事件页。
- 权限与审计页。
- Mock 数据层或接口适配层。
### 1.2 第一版暂不做
- 生产数据库在线查询。
- 数据库恢复操作。
- 完整审批流。
- 多租户计费和组织管理。
- 自动扩缩容策略配置。
- 复杂灰度规则编排。
- 实时 WebSocket 日志流,第一版可用轮询或静态刷新替代。
- Kubernetes YAML 在线编辑。
## 2. 用户角色
| 角色 | 第一版权限 |
| --- | --- |
| SRE Admin | 查看全部页面,执行发布、重启、回滚入口可见,查看审计 |
| Developer | 查看服务、日志、事件,非生产环境可发版,生产环境只读 |
| Viewer | 只读查看首页、服务、资源、发布记录 |
权限第一版可以先前端状态控制,但操作接口必须预留服务端校验字段。
## 3. 全局信息架构
左侧导航:
- 总览
- 微服务
- 发布记录
- 数据漫游
- 配置中心
- 日志与事件
- 监控告警
- 权限与审计
第一版页面实现优先级:
1. 总览
2. 微服务
3. 服务详情
4. 发布记录
5. 数据漫游
6. 日志与事件
7. 权限与审计
配置中心和监控告警第一版保留导航入口,可以先显示建设中空状态。
## 4. 全局框架开发
### 4.1 App Shell
开发内容:
- 左侧玻璃导航。
- 顶部上下文工具栏。
- 主内容区。
- 右侧操作抽屉容器。
- 全局 Toast 容器。
顶部工具栏字段:
- 项目:`Project A`
- 环境:`dev / test / staging / prod`
- 集群:`cluster-cn-1`
- 当前告警数
- 当前用户
- 当前角色
验收标准:
- 切换项目、环境、集群后,当前页面列表和统计数据同步刷新。
- `prod` 环境下,发布和重启流程显示生产环境提醒。
- 侧边导航、顶部栏、主内容区都符合液态玻璃层级规范。
### 4.2 全局样式基础
开发内容:
- `tokens.css`:颜色、圆角、阴影、字号、间距。
- `glass.css`:玻璃容器、强玻璃面、弱玻璃面。
- `layout.css`:后台布局、页面间距、响应式约束。
- `components.css`按钮、Badge、表格、输入框、菜单、Tabs。
验收标准:
- 所有页面共用同一套 Token。
- 禁止页面内随意写新的主色、状态色和玻璃阴影。
- 不支持 `backdrop-filter` 时,表格和表单仍然可读。
## 5. 页面开发内容
## 5.1 总览页
页面目标:用户进入平台后第一眼看到当前项目环境是否健康。
模块:
- 顶部筛选栏。
- 状态总览卡。
- 微服务列表。
- 最近事件。
- 数据资源摘要。
状态总览卡:
| 卡片 | 数据 |
| --- | --- |
| 运行中服务数 | healthy 服务数量 |
| 异常服务数 | warning + critical 服务数量 |
| 最近发布 | 服务名、版本、时间 |
| 活跃告警 | 当前未恢复告警数量 |
| 数据库状态 | 正常资源数 / 总资源数 |
微服务列表字段:
- 服务名
- 状态
- 当前版本
- 实例数
- CPU / 内存
- 最近发布
- 负责人
- 操作:日志、详情、更多
更多菜单:
- 发布
- 重启
- 配置
- 回滚
验收标准:
- 首页主体是微服务表格,不是卡片墙。
- 异常服务在表格中有清晰状态。
- 点击发布打开发布抽屉。
- 点击重启打开重启确认流程。
- 点击详情进入服务详情页。
## 5.2 微服务页
页面目标:集中检索和管理所有服务。
筛选能力:
- 服务名搜索。
- 状态筛选。
- 负责人筛选。
- 命名空间筛选。
列表字段:
- 服务名
- 命名空间
- 状态
- 当前版本
- 实例数
- CPU
- 内存
- 最近发布
- 负责人
- 操作
验收标准:
- 搜索和筛选可以组合使用。
- 表格支持空状态、加载状态、错误状态。
- 危险操作不直接暴露为大按钮。
## 5.3 服务详情页
页面目标:围绕单个服务完成状态排查和操作决策。
顶部信息:
- 返回按钮。
- 面包屑:项目 / 环境 / 集群 / 服务。
- 服务名。
- 状态 Badge。
- 命名空间。
- 负责人。
- 创建时间。
Tabs
- 概览
- 实例
- 发布
- 日志
- 监控
- 配置
- 事件
概览 Tab
- 状态摘要。
- 依赖服务MySQL、Redis、MongoDB、MQ。
- 监控概览CPU、内存、QPS、错误率、平均响应延迟、P95 响应延迟。
- 最近事件。
- 实例列表。
实例 Tab
- Pod / 实例名。
- 节点。
- 重启次数。
- 健康状态。
- 运行时长。
- 操作:查看日志、重启单实例。
发布 Tab
- 当前版本。
- 历史版本。
- 发布策略。
- 操作人。
- 发布时间。
- 状态。
日志 Tab
- 时间范围。
- 日志级别。
- 关键词搜索。
- 服务实例筛选。
- 日志列表。
监控 Tab
- CPU。
- 内存。
- QPS。
- 错误率。
- 延迟。
配置 Tab
- 环境变量只读展示。
- 配置项只读展示。
- Secret 只显示引用名,不显示明文。
事件 Tab
- 健康检查失败。
- 发布事件。
- 重启事件。
- 配置变更事件。
- 依赖连接异常。
验收标准:
- 所有 Tabs 有稳定布局,不因为数据为空而塌陷。
- 日志、监控、事件可以按当前服务过滤。
- Secret 不展示明文。
- 从详情页也可以打开发布抽屉和重启确认流程。
## 5.4 发布服务抽屉
页面目标:完成一次可控、可追踪的服务发布。
入口:
- 首页服务行更多菜单。
- 微服务页服务行更多菜单。
- 服务详情页主操作。
流程:
1. 选择版本或镜像 Tag。
2. 选择发布策略。
3. 查看变更内容。
4. 预检查。
5. 确认执行。
6. 发布中。
7. 完成。
发布策略第一版:
| 策略 | 第一版能力 |
| --- | --- |
| 滚动发布 | 可选择,默认推荐 |
| 灰度发布 | 可展示为禁用或基础选项 |
| 全量发布 | 可选择,但显示高风险提示 |
预检查项:
- 实例状态。
- 依赖服务。
- 配置变更。
- 镜像拉取。
生产环境规则:
- 显示当前环境 `prod`
- 显示影响实例数。
- 要求勾选“我已阅读并确认以上信息”。
- 主按钮在勾选前禁用。
- 操作完成后生成审计记录。
验收标准:
- 发布抽屉顶部和底部固定,中间可滚动。
- 发布前能看到当前版本和目标版本。
- 发布执行中显示步骤状态。
- 发布成功后服务当前版本更新。
- 发布失败时显示失败原因和查看事件入口。
## 5.5 重启确认流程
页面目标:安全执行单实例或全部实例重启。
入口:
- 服务行更多菜单。
- 服务详情页实例列表。
流程:
1. 选择重启范围:单实例 / 全部实例。
2. 选择重启方式:滚动重启 / 立即重启。
3. 展示影响范围。
4. 确认执行。
5. 展示执行结果。
生产环境规则:
- 默认选择滚动重启。
- 立即重启需要二次确认。
- 显示预计影响实例数。
验收标准:
- 全部实例重启必须展示影响范围。
- 单实例重启必须展示实例名和节点。
- 操作完成后生成事件和审计记录。
## 5.6 发布记录页
页面目标:追踪所有发布行为。
筛选能力:
- 时间范围。
- 服务。
- 环境。
- 状态。
- 操作人。
表格字段:
- 时间。
- 服务。
- 环境。
- 版本变更。
- 发布策略。
- 操作人。
- 状态。
- 耗时。
- 操作:详情、查看事件。
发布详情:
- 基本信息。
- 预检查结果。
- 步骤日志。
- 关联审计记录。
验收标准:
- 发布成功、失败、执行中、已回滚有清晰状态。
- 点击记录可以查看发布详情。
- 首页和服务详情页的发布记录数据一致。
## 5.7 数据资源状态页
页面目标:从运维视角查看公共数据资源状态。
资源类型:
- MySQL。
- MongoDB。
- Redis。
- MQ / Kafka。
列表字段:
- 资源名。
- 类型。
- 状态。
- 主从/副本状态。
- 容量。
- 连接数。
- 慢查询/错误摘要。
- 备份状态。
- 关联服务。
- 负责人。
资源详情:
- 基础信息。
- 连接状态。
- 容量趋势。
- 关联服务列表。
- 最近异常事件。
- 最近备份记录。
第一版限制:
- 不开放 SQL 查询。
- 不开放 Mongo 查询。
- 不开放备份恢复。
- 可以展示“申请权限”或“建设中”入口,但不执行真实操作。
验收标准:
- 用户可以看出 MySQL、MongoDB、Redis、MQ 是否正常。
- 能看出资源和服务之间的关联关系。
- 生产环境不出现直接查询入口。
## 5.8 日志与事件页
页面目标:集中排查问题。
日志筛选:
- 时间范围。
- 服务。
- 实例。
- 级别。
- 关键词。
事件筛选:
- 时间范围。
- 服务。
- 类型。
- 状态。
事件类型:
- 发布。
- 重启。
- 健康检查。
- 配置变更。
- 数据库连接异常。
验收标准:
- 从服务详情跳转过来时自动带上服务过滤条件。
- 错误事件可以跳转到服务详情。
- 日志区支持空状态和错误状态。
## 5.9 权限与审计页
页面目标:追踪谁在什么环境操作了什么。
审计字段:
- 操作时间。
- 操作人。
- 角色。
- 操作对象。
- 操作类型。
- 环境。
- 集群。
- 执行结果。
- 关联发布记录。
- 关联事件。
操作类型:
- 发布服务。
- 重启服务。
- 重启实例。
- 回滚版本。
- 查看配置。
- 查看日志。
验收标准:
- 发布、重启操作完成后必须出现审计记录。
- 生产环境操作有明显标识。
- 审计列表支持按时间、操作人、环境筛选。
## 6. 核心组件清单
### 6.1 基础组件
- `GlassSurface`
- `GlassPanel`
- `GlassDrawer`
- `Button`
- `IconButton`
- `StatusBadge`
- `MetricCard`
- `ProgressMeter`
- `Tabs`
- `Select`
- `SegmentedControl`
- `SearchInput`
- `DropdownMenu`
- `Toast`
- `Skeleton`
- `EmptyState`
### 6.2 业务组件
- `AppSidebar`
- `TopContextBar`
- `ServiceTable`
- `ServiceActionMenu`
- `ServiceHeader`
- `MetricsGrid`
- `MetricLineChart`
- `InstanceTable`
- `ReleaseDrawer`
- `RestartDialog`
- `ReleaseRecordTable`
- `DataResourceTable`
- `EventTimeline`
- `AuditTable`
## 7. 数据结构
### 7.1 Project
```ts
type Project = {
id: string;
name: string;
};
```
### 7.2 Environment
```ts
type Environment = "dev" | "test" | "staging" | "prod";
```
### 7.3 Cluster
```ts
type Cluster = {
id: string;
name: string;
region: string;
};
```
### 7.4 Service
```ts
type ServiceStatus = "healthy" | "warning" | "critical" | "unknown" | "running";
type Service = {
id: string;
name: string;
namespace: string;
status: ServiceStatus;
version: string;
desiredInstances: number;
readyInstances: number;
cpuUsage: number;
memoryUsage: number;
lastReleaseAt: string;
owner: string;
dependencies: string[];
};
```
### 7.5 ServiceInstance
```ts
type ServiceInstance = {
id: string;
name: string;
node: string;
status: ServiceStatus;
restartCount: number;
uptime: string;
};
```
### 7.6 ReleaseRecord
```ts
type ReleaseStatus = "success" | "failed" | "running" | "rolled_back";
type ReleaseStrategy = "rolling" | "canary" | "full";
type ReleaseRecord = {
id: string;
serviceId: string;
serviceName: string;
environment: Environment;
fromVersion: string;
toVersion: string;
strategy: ReleaseStrategy;
operator: string;
status: ReleaseStatus;
durationSeconds: number;
createdAt: string;
};
```
### 7.7 DataResource
```ts
type DataResourceType = "mysql" | "mongodb" | "redis" | "kafka";
type DataResourceStatus = "healthy" | "warning" | "critical" | "unknown";
type DataResource = {
id: string;
name: string;
type: DataResourceType;
status: DataResourceStatus;
topology: string;
capacityUsedPercent: number;
connections: number;
slowQueryCount: number;
backupStatus: string;
relatedServices: string[];
owner: string;
};
```
### 7.8 EventRecord
```ts
type EventType = "release" | "restart" | "health_check" | "config_change" | "database_connection";
type EventRecord = {
id: string;
type: EventType;
serviceId?: string;
resourceId?: string;
level: "info" | "warning" | "error";
message: string;
createdAt: string;
};
```
### 7.9 AuditRecord
```ts
type AuditRecord = {
id: string;
operator: string;
role: string;
action: string;
targetType: "service" | "instance" | "resource" | "config";
targetName: string;
environment: Environment;
cluster: string;
result: "success" | "failed";
relatedId?: string;
createdAt: string;
};
```
## 8. 接口草案
第一版可以先用 Mock 接口,真实后端接入时保持路径和字段稳定。
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/projects` | 项目列表 |
| GET | `/api/clusters` | 集群列表 |
| GET | `/api/dashboard/summary` | 首页统计 |
| GET | `/api/services` | 服务列表 |
| GET | `/api/services/:id` | 服务详情 |
| GET | `/api/services/:id/instances` | 服务实例 |
| GET | `/api/services/:id/metrics` | 服务监控 |
| GET | `/api/services/:id/logs` | 服务日志 |
| GET | `/api/services/:id/events` | 服务事件 |
| POST | `/api/services/:id/releases` | 创建发布任务 |
| POST | `/api/services/:id/restart` | 重启服务 |
| GET | `/api/releases` | 发布记录 |
| GET | `/api/releases/:id` | 发布详情 |
| GET | `/api/resources` | 数据资源列表 |
| GET | `/api/resources/:id` | 数据资源详情 |
| GET | `/api/events` | 事件列表 |
| GET | `/api/audits` | 审计列表 |
所有列表接口都需要支持:
- `projectId`
- `environment`
- `clusterId`
- `page`
- `pageSize`
## 9. Mock 数据要求
第一版 Mock 数据必须覆盖以下状态:
- Healthy 服务。
- Warning 服务。
- Critical 服务。
- 实例数不满足期望值。
- CPU / 内存较高。
- 发布成功。
- 发布失败。
- 发布中。
- MySQL 正常。
- MongoDB 警告。
- Redis 正常。
- Kafka 异常。
- prod 环境发布确认。
- 权限不足禁用操作。
验收标准:
- 不依赖真实后端也能完整走通页面和操作流程。
- Mock 发布成功后,当前服务版本和发布记录同步变化。
- Mock 重启成功后,事件列表和审计列表同步新增记录。
## 10. 路由建议
```text
/overview
/services
/services/:serviceId
/releases
/resources
/resources/:resourceId
/logs-events
/audit
/settings
/alerts
```
第一版默认进入 `/overview`
## 11. 开发里程碑
### 11.1 M1视觉基础和全局框架
交付:
- Token。
- 玻璃样式。
- App Shell。
- 侧边导航。
- 顶部上下文工具栏。
- 基础按钮、Badge、表格、输入框。
验收:
- 页面整体接近 UI 规范中的液态玻璃风格。
- 基础组件有 hover、active、focus、disabled 状态。
### 11.2 M2首页和微服务列表
交付:
- 总览页。
- 微服务列表页。
- 服务表格。
- 状态总览卡。
- 搜索和筛选。
验收:
- 可以通过首页定位异常服务。
- 可以进入服务详情。
- 可以打开发布和重启入口。
### 11.3 M3服务详情和操作流程
交付:
- 服务详情页。
- Tabs。
- 监控概览。
- 实例列表。
- 发布抽屉。
- 重启确认。
验收:
- 发布流程可完整走完。
- 重启流程可完整走完。
- prod 环境必须出现确认约束。
### 11.4 M4记录、资源和审计
交付:
- 发布记录页。
- 数据资源状态页。
- 日志与事件页。
- 权限与审计页。
验收:
- 发布和重启后有事件记录。
- 发布和重启后有审计记录。
- 数据资源页不暴露生产查询入口。
### 11.5 M5联调和验收
交付:
- Mock 数据完整。
- 接口适配层稳定。
- 空、加载、错误状态补齐。
- 响应式和可访问性检查。
验收:
- 所有第一版页面可通过导航访问。
- 所有第一版操作可从入口到反馈闭环。
- UI 视觉符合液态玻璃规范。
## 12. 验收清单
### 12.1 视觉验收
- 页面背景是浅色冰蓝液态玻璃风格。
- 主界面信息密度足够,不是营销页。
- 表格、抽屉、状态卡、菜单的玻璃层级清晰。
- 状态色只用于语义,不滥用。
- 文本不和背景纹理冲突。
### 12.2 功能验收
- 能切换项目、环境、集群。
- 能查看服务状态。
- 能搜索和筛选服务。
- 能进入服务详情。
- 能执行发布流程。
- 能执行重启流程。
- 能查看发布记录。
- 能查看数据资源状态。
- 能查看日志和事件。
- 能查看审计记录。
### 12.3 风险验收
- prod 发布必须二次确认。
- prod 重启必须二次确认。
- 危险操作不裸露成主按钮。
- Secret 不显示明文。
- 生产数据库没有直接查询入口。
- 发布和重启都有审计记录。
### 12.4 状态验收
- 页面有 Loading 状态。
- 页面有 Empty 状态。
- 页面有 Error 状态。
- 操作有 Success 反馈。
- 操作有 Failed 反馈。
- 禁用按钮有原因说明。
## 13. 第一版最终交付物
- 可运行的前端页面。
- 全局 UI Token 和玻璃样式。
- 第一版 Mock 数据。
- 第一版接口适配层。
- 核心业务组件。
- 发布和重启操作闭环。
- 发布、事件、审计记录闭环。
- README 中补充启动、构建、预览和验证方式。
## 14. 后续版本方向
第二版建议补充:
- 接入真实 Kubernetes / Docker 部署状态。
- 接入真实日志系统。
- 接入 Prometheus 或其他监控数据。
- 增加灰度发布策略。
- 增加回滚流程。
- 增加审批流。
- 增加数据库备份恢复。
- 增加告警规则管理。
- 增加团队和权限模型。

34
eslint.config.js Normal file
View File

@ -0,0 +1,34 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "node_modules"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-explicit-any": "error",
},
},
{
files: ["deploy/**/*.mjs"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.node,
},
},
);

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#edf6ff" />
<title>项目运维台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4595
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "deploy-platform",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"deploy:run": "npm run build && node deploy/server.mjs",
"deploy:serve": "node deploy/server.mjs",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"check": "npm run lint && npm run test && npm run build"
},
"dependencies": {
"@tanstack/react-query": "^5.90.11",
"clsx": "^2.1.1",
"lucide-react": "^0.475.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.29.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"jsdom": "^26.0.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.22.0",
"vite": "^6.1.0",
"vitest": "^3.0.4"
}
}

10
src/app/App.tsx Normal file
View File

@ -0,0 +1,10 @@
import { Outlet } from "react-router-dom";
import { AppShell } from "../widgets/app-shell";
export function App() {
return (
<AppShell>
<Outlet />
</AppShell>
);
}

View File

@ -0,0 +1,23 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useState } from "react";
type QueryProviderProps = {
children: ReactNode;
};
export function QueryProvider({ children }: QueryProviderProps) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

30
src/app/router.tsx Normal file
View File

@ -0,0 +1,30 @@
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";
export const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
{ index: true, element: <Navigate to="/overview" replace /> },
{ path: "overview", element: <OverviewPage /> },
{ path: "services", element: <ServicesPage /> },
{ path: "services/:serviceId", element: <ServiceDetailPage /> },
{ 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,31 @@
import { create } from "zustand";
export type Environment = "dev" | "test" | "staging" | "prod";
type AppContextState = {
projectId: string;
projectName: string;
environment: Environment;
clusterId: string;
clusterName: string;
alertCount: number;
userName: string;
roleName: string;
setProject: (project: { id: string; name: string }) => void;
setEnvironment: (environment: Environment) => void;
setCluster: (cluster: { id: string; name: string }) => void;
};
export const useAppContextStore = create<AppContextState>((set) => ({
projectId: "hyapp",
projectName: "HyApp",
environment: "prod",
clusterId: "me-saudi-prod",
clusterName: "me-saudi-prod",
alertCount: 12,
userName: "Alex",
roleName: "SRE Admin",
setProject: (project) => set({ projectId: project.id, projectName: project.name }),
setEnvironment: (environment) => set({ environment }),
setCluster: (cluster) => set({ clusterId: cluster.id, clusterName: cluster.name }),
}));

View File

@ -0,0 +1,140 @@
.status-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 0 0 0 currentColor;
transition:
box-shadow var(--duration-normal) var(--ease-standard),
transform var(--duration-normal) var(--ease-standard);
}
.text-muted {
color: var(--color-text-tertiary);
}
.liquid-hairline {
border: 1px solid rgba(255, 255, 255, 0.72);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.78),
inset 0 -1px 0 rgba(90, 126, 168, 0.08);
}
input[type="radio"]:not([data-liquid-hidden]),
input[type="checkbox"]:not([data-liquid-hidden]) {
position: relative;
display: inline-grid;
width: 20px;
height: 20px;
place-items: center;
margin: 0;
border: 1px solid rgba(255, 255, 255, 0.74);
color: #fff;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.64), rgba(241, 248, 255, 0.24)),
rgba(255, 255, 255, 0.28);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.86),
inset 0 -1px 0 rgba(75, 108, 148, 0.1);
vertical-align: -4px;
appearance: none;
-webkit-appearance: none;
backdrop-filter: blur(18px) saturate(170%);
-webkit-backdrop-filter: blur(18px) saturate(170%);
transition:
background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
input[type="radio"]:not([data-liquid-hidden]) {
border-radius: 999px;
}
input[type="checkbox"]:not([data-liquid-hidden]) {
border-radius: 7px;
}
input[type="radio"]:not([data-liquid-hidden])::after,
input[type="checkbox"]:not([data-liquid-hidden])::after {
display: block;
opacity: 0;
transform: scale(0.55);
transition:
opacity var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
content: "";
}
input[type="radio"]:not([data-liquid-hidden])::after {
width: 8px;
height: 8px;
border-radius: 999px;
background: #fff;
}
input[type="checkbox"]:not([data-liquid-hidden])::after {
width: 9px;
height: 5px;
border-bottom: 2px solid currentColor;
border-left: 2px solid currentColor;
transform: translateY(-1px) rotate(-45deg) scale(0.55);
}
input[type="radio"]:not([data-liquid-hidden]):hover,
input[type="checkbox"]:not([data-liquid-hidden]):hover {
border-color: rgba(255, 255, 255, 0.86);
box-shadow: var(--shadow-liquid-control-hover);
transform: translateY(-1px);
}
input[type="radio"]:not([data-liquid-hidden]):focus-visible,
input[type="checkbox"]:not([data-liquid-hidden]):focus-visible {
box-shadow: var(--shadow-focus);
}
input[type="radio"]:not([data-liquid-hidden]):checked,
input[type="checkbox"]:not([data-liquid-hidden]):checked {
border-color: rgba(47, 125, 246, 0.28);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.38), transparent 44%),
linear-gradient(180deg, #5fa2ff, var(--color-brand));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.62),
0 8px 18px rgba(47, 125, 246, 0.24);
animation: liquid-control-check 180ms var(--ease-press);
}
input[type="radio"]:not([data-liquid-hidden]):checked::after,
input[type="checkbox"]:not([data-liquid-hidden]):checked::after {
opacity: 1;
}
input[type="radio"]:not([data-liquid-hidden]):checked::after {
transform: scale(1);
}
input[type="checkbox"]:not([data-liquid-hidden]):checked::after {
transform: translateY(-1px) rotate(-45deg) scale(1);
}
input[type="radio"]:not([data-liquid-hidden]):disabled,
input[type="checkbox"]:not([data-liquid-hidden]):disabled {
cursor: not-allowed;
opacity: 0.58;
}
@keyframes liquid-control-check {
0% {
transform: scale(0.9);
}
70% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}

182
src/app/styles/layout.css Normal file
View File

@ -0,0 +1,182 @@
* {
box-sizing: border-box;
}
html {
height: 100%;
min-width: 1180px;
min-height: 100%;
background: var(--color-bg-page);
}
body {
position: relative;
height: 100%;
min-height: 100vh;
margin: 0;
overflow: hidden;
color: var(--color-text-primary);
font-family: var(--font-sans);
font-size: 14px;
font-variant-numeric: tabular-nums;
background: var(--page-bg);
}
body::before {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
linear-gradient(
108deg,
transparent 0 18%,
rgba(255, 255, 255, 0.36) 18% 19%,
transparent 19% 62%,
rgba(117, 158, 208, 0.08) 62% 63%,
transparent 63%
),
linear-gradient(
156deg,
transparent 0 36%,
rgba(255, 255, 255, 0.28) 36% 37%,
transparent 37% 100%
);
opacity: 0.7;
content: "";
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
a {
color: inherit;
text-decoration: none;
}
:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}
#root {
height: 100%;
min-height: 0;
}
.page {
display: flex;
flex-direction: column;
gap: var(--space-4);
animation: page-enter var(--duration-page) var(--ease-liquid) both;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
animation: content-rise 320ms var(--ease-liquid) both;
}
.page-title {
margin: 0;
color: var(--color-text-primary);
font-size: 20px;
font-weight: 700;
line-height: 28px;
}
.page-subtitle {
margin: 4px 0 0;
color: var(--color-text-tertiary);
font-size: 12px;
line-height: 18px;
}
@keyframes page-enter {
from {
opacity: 0;
transform: translateY(10px) scale(0.992);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes content-rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes liquid-pop {
0% {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes liquid-slide-left {
from {
opacity: 0;
transform: translateX(28px) scale(0.985);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes liquid-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes liquid-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 1ms !important;
}
}

96
src/app/styles/tokens.css Normal file
View File

@ -0,0 +1,96 @@
:root {
color-scheme: light;
--page-bg:
linear-gradient(125deg, rgba(255, 255, 255, 0.82), rgba(226, 239, 255, 0.64) 42%, rgba(246, 250, 255, 0.86)),
conic-gradient(from 178deg at 22% 18%, rgba(255, 255, 255, 0.72), rgba(207, 229, 255, 0.4), rgba(255, 241, 232, 0.32), rgba(255, 255, 255, 0.68)),
linear-gradient(180deg, #f8fbff 0%, #edf6ff 52%, #f7fbff 100%);
--color-bg-page: #f2f8ff;
--color-bg-page-soft: #fbfdff;
--color-glass: rgba(255, 255, 255, 0.46);
--color-glass-strong: rgba(255, 255, 255, 0.62);
--color-glass-muted: rgba(255, 255, 255, 0.28);
--color-glass-active: rgba(255, 255, 255, 0.7);
--color-border-light: rgba(255, 255, 255, 0.86);
--color-border-soft: rgba(96, 130, 168, 0.18);
--color-text-primary: #1d2b3a;
--color-text-secondary: #52677f;
--color-text-tertiary: #7b8da3;
--color-text-disabled: #a8b7c8;
--color-text-inverse: #ffffff;
--color-brand: #2f7df6;
--color-brand-hover: #1f6ee8;
--color-brand-soft: #e6f1ff;
--color-brand-ring: rgba(47, 125, 246, 0.28);
--color-success: #18a957;
--color-success-bg: rgba(24, 169, 87, 0.12);
--color-warning: #f5a524;
--color-warning-bg: rgba(245, 165, 36, 0.14);
--color-danger: #e5484d;
--color-danger-bg: rgba(229, 72, 77, 0.14);
--color-running: #2f7df6;
--color-running-bg: rgba(47, 125, 246, 0.14);
--color-unknown: #8b98a9;
--color-unknown-bg: rgba(139, 152, 169, 0.14);
--color-chart-blue: #4f83ff;
--color-chart-green: #37b66a;
--color-chart-red: #ff5a57;
--color-chart-purple: #9b72f2;
--color-chart-orange: #ff9f43;
--radius-shell: 28px;
--radius-panel: 22px;
--radius-chart: 16px;
--radius-card: 12px;
--radius-badge: 999px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--shadow-glass-sm:
0 1px 1px rgba(255, 255, 255, 0.75) inset,
0 10px 28px rgba(52, 84, 124, 0.1);
--shadow-glass-md:
0 1px 1px rgba(255, 255, 255, 0.86) inset,
0 -1px 1px rgba(57, 91, 124, 0.1) inset,
0 18px 54px rgba(52, 84, 124, 0.16);
--shadow-glass-lg:
0 1px 1px rgba(255, 255, 255, 0.9) inset,
0 -1px 1px rgba(57, 91, 124, 0.12) inset,
0 26px 72px rgba(38, 74, 112, 0.22);
--shadow-focus: 0 0 0 4px rgba(47, 125, 246, 0.22);
--shadow-liquid-control:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
inset 0 -1px 0 rgba(75, 108, 148, 0.1),
0 8px 22px rgba(52, 84, 124, 0.1);
--shadow-liquid-control-hover:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
inset 0 -1px 0 rgba(75, 108, 148, 0.08),
0 12px 30px rgba(52, 84, 124, 0.15);
--shadow-liquid-edge:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 1px 0 0 rgba(255, 255, 255, 0.58),
inset -1px 0 0 rgba(119, 151, 190, 0.1),
inset 0 -1px 0 rgba(82, 114, 153, 0.12);
--font-sans:
Inter, "SF Pro Display", "SF Pro Text", "PingFang SC", "Microsoft YaHei", sans-serif;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-liquid: cubic-bezier(0.16, 1, 0.3, 1);
--ease-press: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 120ms;
--duration-normal: 180ms;
--duration-slow: 260ms;
--duration-page: 360ms;
}

View File

@ -0,0 +1,128 @@
export type DeployApiInventoryResponse = {
hosts: Record<string, unknown>;
managedDependencies: Record<string, string>;
ok: boolean;
region: string;
registry: string;
services: Record<string, unknown>;
};
async function readJsonResponse<T>(response: Response): Promise<T> {
const contentType = response.headers.get("content-type") ?? "";
if (!response.ok) {
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 getDeployApiInventory(): Promise<DeployApiInventoryResponse> {
const response = await fetch("/deploy/api/hyapp/inventory", {
headers: {
accept: "application/json",
},
});
return readJsonResponse<DeployApiInventoryResponse>(response);
}
export type DeployApiPlanResponse = {
dry_run?: boolean;
ok: boolean;
steps: Array<{
action: string;
clb_enabled: boolean;
host: string;
private_ip: string;
service: string;
target_port: number;
unit: string;
}>;
};
export async function getDeployApiPlan(services: string[]): Promise<DeployApiPlanResponse> {
const params = new URLSearchParams({
services: services.join(","),
});
const response = await fetch(`/deploy/api/hyapp/plan?${params.toString()}`, {
headers: {
accept: "application/json",
},
});
return readJsonResponse<DeployApiPlanResponse>(response);
}
export type DeployApiDiscoverResponse = {
clb?: Array<{
bindings?: Array<{
port: number;
weight: number;
}>;
error?: string;
host: string;
load_balancer_id?: string;
service: string;
}>;
hosts?: Array<{
error?: string;
host: string;
output?: string;
private_ip: string;
status: string;
}>;
ok: boolean;
};
export async function getDeployApiDiscover(services: string[], remote: boolean): Promise<DeployApiDiscoverResponse> {
const params = new URLSearchParams({
remote: remote ? "1" : "0",
services: services.join(","),
});
const response = await fetch(`/deploy/api/hyapp/discover?${params.toString()}`, {
headers: {
accept: "application/json",
},
});
return readJsonResponse<DeployApiDiscoverResponse>(response);
}
export type DeployApiTestboxConfigResponse = {
deployedCommit?: string;
files: Array<{
content: string;
exists: boolean;
path: string;
service: string;
truncated: boolean;
}>;
ok: boolean;
redacted: boolean;
target?: {
instanceId: string;
privateIp: string;
publicIp: string;
root: string;
timer: string;
};
timerState?: string;
};
export async function getDeployApiTestboxConfigs(services: string[]): Promise<DeployApiTestboxConfigResponse> {
const params = new URLSearchParams({
services: services.join(","),
});
const response = await fetch(`/deploy/api/hyapp/testbox/configs?${params.toString()}`, {
headers: {
accept: "application/json",
},
});
return readJsonResponse<DeployApiTestboxConfigResponse>(response);
}

View File

@ -0,0 +1,41 @@
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "../../../shared/api/queryKeys";
import { getDeployApiDiscover, getDeployApiInventory, getDeployApiPlan, getDeployApiTestboxConfigs } from "./getDeployApiInventory";
const STATUS_REFRESH_INTERVAL_MS = 15_000;
export function useDeployApiInventoryQuery() {
return useQuery({
queryFn: getDeployApiInventory,
queryKey: queryKeys.deployApiInventory(),
refetchInterval: STATUS_REFRESH_INTERVAL_MS,
retry: 0,
});
}
export function useDeployApiPlanQuery(services: string[]) {
return useQuery({
queryFn: () => getDeployApiPlan(services),
queryKey: queryKeys.deployApiPlan(services),
retry: 0,
});
}
export function useDeployApiDiscoverQuery(services: string[], enabled: boolean) {
return useQuery({
enabled,
queryFn: () => getDeployApiDiscover(services, true),
queryKey: queryKeys.deployApiDiscover(services),
refetchInterval: enabled ? STATUS_REFRESH_INTERVAL_MS : false,
retry: 0,
});
}
export function useDeployApiTestboxConfigsQuery(services: string[]) {
return useQuery({
queryFn: () => getDeployApiTestboxConfigs(services),
queryKey: queryKeys.deployApiTestboxConfigs(services),
refetchInterval: 60_000,
retry: 0,
});
}

View File

@ -0,0 +1,28 @@
export { hyappDeploymentPlan } from "./mock/hyappDeploymentPlan";
export {
getHyappDeploymentHosts,
getHyappDeploymentServiceById,
getHyappDeploymentServiceByName,
} from "./lib/getHyappDeploymentService";
export {
useDeployApiDiscoverQuery,
useDeployApiInventoryQuery,
useDeployApiPlanQuery,
useDeployApiTestboxConfigsQuery,
} from "./api/useDeployApiQueries";
export type {
DeployApiDiscoverResponse,
DeployApiInventoryResponse,
DeployApiPlanResponse,
DeployApiTestboxConfigResponse,
} from "./api/getDeployApiInventory";
export type {
DeploymentCommand,
DeploymentHost,
DeploymentMode,
DeploymentStep,
HyappDeploymentPlan,
HyappDeploymentService,
ManagedDependency,
TestboxDeployTarget,
} from "./model/types";

View File

@ -0,0 +1,24 @@
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

@ -0,0 +1,575 @@
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

@ -0,0 +1,171 @@
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,367 @@
import { mockServices } from "../mock/services";
import {
getHyappDeploymentHosts,
getHyappDeploymentServiceByName,
hyappDeploymentPlan,
} from "../../deployment-plan";
import type {
Service,
ServiceDependency,
ServiceDetail,
ServiceEvent,
ServiceInstance,
ServiceInstanceConfigItem,
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,
}));
}
const dependencyTypeMap: Record<string, ServiceDependency["type"]> = {
mq: "message-queue",
mysql: "database",
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;
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,
]),
unit: "%",
value: `${service.cpuUsage}%`,
},
{
color: "green",
id: "memory",
label: "内存使用率",
max: 100,
points: createSeries([
55,
52,
56,
51,
53,
58,
59,
61,
57,
55,
58,
54,
service.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",
},
];
}
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}`;
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",
},
];
}
function createInstances(service: Service): ServiceInstance[] {
const deploymentService = getHyappDeploymentServiceByName(service.name);
const hosts = deploymentService ? getHyappDeploymentHosts(deploymentService) : [];
const instanceCount = Math.max(hosts.length, service.desiredInstances);
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}`;
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",
};
});
}
function createEvents(service: Service): ServiceEvent[] {
return [
{
id: `${service.id}-event-1`,
message: `${service.readyInstances} 个实例保持健康`,
time: "14:39",
title: "健康检查",
},
{
id: `${service.id}-event-2`,
message: "CLB 权重已恢复",
time: "14:25",
title: "恢复接流",
},
{
id: `${service.id}-event-3`,
message: `${service.version} 已发布成功`,
time: "14:18",
title: "发布成功",
},
{
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: "实例启动",
},
];
}
function createServiceDetail(service: Service): ServiceDetail {
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 小时",
};
}
export async function getServiceDetail(
serviceId: string,
): Promise<ServiceDetail | null> {
await new Promise((resolve) =>
globalThis.setTimeout(resolve, MOCK_LATENCY_MS),
);
const service = mockServices.find((item) => item.id === serviceId);
return service ? createServiceDetail(service) : null;
}

View File

@ -0,0 +1,68 @@
import { mockServices } from "../mock/services";
import { ServiceListParams, ServiceListResponse } from "../model/types";
const MOCK_LATENCY_MS = 160;
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",
};
});
}
export async function getServices(params: ServiceListParams): Promise<ServiceListResponse> {
await new Promise((resolve) => globalThis.setTimeout(resolve, MOCK_LATENCY_MS));
let items;
try {
items = await getServicesFromDeployApi(params);
} catch {
items = mockServices.filter((service) => service.namespace === params.environment);
}
return {
items,
total: items.length,
};
}

View File

@ -0,0 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "../../../shared/api/queryKeys";
import { getServiceDetail } from "./getServiceDetail";
const STATUS_REFRESH_INTERVAL_MS = 15_000;
export function useServiceDetailQuery(serviceId: string) {
return useQuery({
enabled: serviceId.length > 0,
queryFn: () => getServiceDetail(serviceId),
queryKey: queryKeys.serviceDetail(serviceId),
refetchInterval: serviceId.length > 0 ? STATUS_REFRESH_INTERVAL_MS : false,
});
}

View File

@ -0,0 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "../../../shared/api/queryKeys";
import { getServices } from "./getServices";
import { ServiceListParams } from "../model/types";
const STATUS_REFRESH_INTERVAL_MS = 15_000;
export function useServicesQuery(params: ServiceListParams) {
return useQuery({
queryFn: () => getServices(params),
queryKey: queryKeys.services(params),
refetchInterval: STATUS_REFRESH_INTERVAL_MS,
});
}

View File

@ -0,0 +1,19 @@
export { getServiceDetail } from "./api/getServiceDetail";
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,
ServiceDetail,
ServiceEvent,
ServiceInstance,
ServiceInstanceConfigItem,
ServiceListParams,
ServiceListResponse,
ServiceMetric,
ServiceMetricPoint,
ServiceStatus,
} from "./model/types";

View File

@ -0,0 +1,31 @@
import { Service } from "../model/types";
export type ServiceSummary = {
abnormalCount: number;
activeAlerts: number;
databaseHealthyText: string;
healthyCount: number;
latestRelease: {
serviceName: string;
version: string;
};
};
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 ?? "-",
},
};
}

View File

@ -0,0 +1,16 @@
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

@ -0,0 +1,91 @@
import { StatusBadgeStatus } from "../../../shared/ui/StatusBadge";
export type ServiceStatus = StatusBadgeStatus;
export type Service = {
id: string;
name: string;
namespace: string;
status: ServiceStatus;
version: string;
desiredInstances: number;
readyInstances: number;
cpuUsage: number;
memoryUsage: number;
lastReleaseText: string;
owner: string;
};
export type ServiceDependency = {
id: string;
name: string;
status: ServiceStatus;
type: "cache" | "database" | "message-queue" | "storage";
};
export type ServiceMetricPoint = {
time: string;
value: number;
};
export type ServiceMetric = {
color: "blue" | "green" | "orange" | "purple" | "red";
id: string;
label: string;
max: number;
points: ServiceMetricPoint[];
unit: string;
value: string;
};
export type ServiceInstanceConfigItem = {
description: string;
id: string;
key: string;
source: "config-map" | "env" | "secret" | "system";
updatedAt: string;
value: string;
};
export type ServiceInstance = {
configItems: ServiceInstanceConfigItem[];
id: string;
image: string;
name: string;
podIp: string;
node: string;
restartCount: number;
resourceLimit: string;
runningTime: string;
status: ServiceStatus;
zone: string;
};
export type ServiceEvent = {
id: string;
message: string;
time: string;
title: string;
};
export type ServiceDetail = Service & {
createdAt: string;
dependencies: ServiceDependency[];
errorRate: string;
events: ServiceEvent[];
instances: ServiceInstance[];
metrics: ServiceMetric[];
qps: string;
runningTime: string;
};
export type ServiceListParams = {
clusterId: string;
environment: string;
projectId: string;
};
export type ServiceListResponse = {
items: Service[];
total: number;
};

View File

@ -0,0 +1,56 @@
import { mockServices } from "../../../entities/service";
import { CreateReleaseRequest, CreateReleaseResult } from "../model/types";
type DeployApiResult = {
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],
tag: request.targetVersion,
}),
headers: {
"content-type": "application/json",
},
method: "POST",
});
const result = (await response.json()) as DeployApiResult;
if (!response.ok || result.ok === false) {
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,
status: "success",
targetVersion: request.targetVersion,
};
if (typeof result.steps?.length === "number") {
releaseResult.remoteSteps = result.steps.length;
}
return releaseResult;
}

View File

@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createRelease } from "./createRelease";
export function useCreateReleaseMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createRelease,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["services"] });
void queryClient.invalidateQueries({ queryKey: ["service-detail"] });
},
});
}

View File

@ -0,0 +1,6 @@
export { ReleaseLayer } from "./ui/ReleaseLayer";
export { ReleaseDrawer } from "./ui/ReleaseDrawer";
export { createRelease } from "./api/createRelease";
export { useCreateReleaseMutation } from "./api/useCreateReleaseMutation";
export { useReleaseDrawerStore } from "./model/useReleaseDrawerStore";
export type { CreateReleaseRequest, CreateReleaseResult, ReleaseStrategy } from "./model/types";

View File

@ -0,0 +1,45 @@
import { Service } from "../../../entities/service";
import { getHyappDeploymentServiceById, hyappDeploymentPlan } from "../../../entities/deployment-plan";
import { 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);
return [
{
id: "instances",
label: "实例状态",
result: service.readyInstances === service.desiredInstances ? "passed" : "warning",
detail: `${service.readyInstances}/${service.desiredInstances} 可用`,
},
{
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("、") + " 不随发布重启",
},
{
id: "config",
label: "配置变更",
result: "passed",
detail: deploymentService?.prod?.configPath ?? deploymentService?.local.configPath ?? "按服务配置模板校验",
},
{
id: "image",
label: "镜像拉取",
result: "passed",
detail: `${hyappDeploymentPlan.registry}/${service.name}:${service.version}`,
},
];
}

View File

@ -0,0 +1,39 @@
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

@ -0,0 +1,27 @@
export type ReleaseStrategy = "rolling" | "canary" | "full";
export type ReleasePrecheck = {
id: string;
label: string;
result: "passed" | "warning";
detail: string;
};
export type CreateReleaseRequest = {
confirmed?: boolean;
environment: string;
operator: string;
serviceId: string;
strategy: ReleaseStrategy;
targetVersion: string;
};
export type CreateReleaseResult = {
id: string;
completedAt: string;
fromVersion: string;
remoteSteps?: number;
serviceId: string;
status: "success";
targetVersion: string;
};

View File

@ -0,0 +1,14 @@
import { create } from "zustand";
import type { Service } from "../../../entities/service";
type ReleaseDrawerState = {
service: Service | null;
closeRelease: () => void;
openRelease: (service: Service) => void;
};
export const useReleaseDrawerStore = create<ReleaseDrawerState>((set) => ({
service: null,
closeRelease: () => set({ service: null }),
openRelease: (service) => set({ service }),
}));

View File

@ -0,0 +1,385 @@
.overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: stretch;
justify-content: flex-end;
padding: var(--space-5);
pointer-events: none;
animation: liquid-fade var(--duration-normal) var(--ease-standard) both;
}
.drawer {
position: relative;
isolation: isolate;
overflow: hidden;
display: grid;
width: 448px;
height: calc(100dvh - 40px);
max-width: calc(100vw - 40px);
max-height: calc(100dvh - 40px);
grid-template-rows: auto minmax(0, 1fr) auto;
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: var(--radius-shell);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.7), rgba(241, 248, 255, 0.44)),
rgba(255, 255, 255, 0.52);
box-shadow: var(--shadow-liquid-edge), var(--shadow-glass-lg);
pointer-events: auto;
backdrop-filter: blur(42px) saturate(190%) brightness(1.1);
-webkit-backdrop-filter: blur(42px) saturate(190%) brightness(1.1);
animation: liquid-slide-left 380ms var(--ease-liquid) both;
}
.drawer::before,
.drawer::after {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
border-radius: inherit;
content: "";
}
.drawer::before {
background:
radial-gradient(90% 60% at 16% 0%, rgba(255, 255, 255, 0.82), transparent 48%),
linear-gradient(145deg, rgba(255, 255, 255, 0.32), transparent 38%);
}
.drawer::after {
inset: 1px;
background: linear-gradient(315deg, rgba(68, 110, 158, 0.1), transparent 32%);
mix-blend-mode: screen;
}
.header,
.body,
.footer {
position: relative;
z-index: 1;
}
.header,
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
}
.header {
border-bottom: 1px solid rgba(96, 130, 168, 0.12);
}
.eyebrow {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 800;
}
.header h2 {
margin: 2px 0 0;
color: var(--color-text-primary);
font-size: 18px;
line-height: 24px;
}
.body {
display: grid;
align-content: start;
gap: var(--space-4);
min-height: 0;
overflow-y: auto;
padding: var(--space-4);
}
.prodWarning,
.section,
.result,
.error {
display: flex;
gap: var(--space-3);
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 18px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.56), rgba(241, 248, 255, 0.26)),
rgba(255, 255, 255, 0.24);
box-shadow: var(--shadow-liquid-control);
padding: var(--space-3);
animation: drawer-section-enter 280ms var(--ease-liquid) both;
}
.prodWarning {
animation-delay: 80ms;
}
.section:nth-of-type(1) {
animation-delay: 110ms;
}
.section:nth-of-type(2) {
animation-delay: 140ms;
}
.section:nth-of-type(3) {
animation-delay: 170ms;
}
.section:nth-of-type(4) {
animation-delay: 200ms;
}
.section:nth-of-type(5) {
animation-delay: 230ms;
}
.section:nth-of-type(6) {
animation-delay: 260ms;
}
.prodWarning {
color: var(--color-warning);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.68), rgba(255, 246, 225, 0.42)),
var(--color-warning-bg);
}
.prodWarning div,
.result div,
.error div {
display: grid;
gap: 2px;
}
.prodWarning span,
.result span,
.error span {
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.step {
display: inline-flex;
width: 22px;
height: 22px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 999px;
color: #fff;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.34), transparent 40%),
linear-gradient(180deg, #61a3ff, var(--color-brand));
box-shadow: 0 8px 18px rgba(47, 125, 246, 0.24);
font-size: 12px;
font-weight: 900;
transition: transform var(--duration-normal) var(--ease-press);
}
.section:hover .step {
transform: scale(1.08);
}
.sectionContent {
display: grid;
flex: 1;
gap: var(--space-3);
}
.sectionContent h3 {
margin: 0;
color: var(--color-text-primary);
font-size: 14px;
line-height: 20px;
}
.sectionContent p,
.warningText {
margin: 0;
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.strategyList,
.precheckList,
.summary {
display: grid;
gap: var(--space-2);
}
.targetSummary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
}
.targetSummary span {
display: grid;
min-width: 0;
gap: 3px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.56);
border-radius: 14px;
background: rgba(255, 255, 255, 0.22);
}
.targetSummary strong {
color: var(--color-text-primary);
font-size: 12px;
}
.targetSummary small {
overflow: hidden;
color: var(--color-text-tertiary);
font-size: 11px;
font-weight: 800;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.commandBlock {
max-height: 148px;
margin: 0;
overflow: auto;
padding: 12px;
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);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.commandBlock code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 11px;
line-height: 1.65;
white-space: pre;
}
.precheckItem {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-secondary);
animation: precheck-enter 220ms var(--ease-liquid) both;
}
.precheckItem:nth-child(2) {
animation-delay: 40ms;
}
.precheckItem:nth-child(3) {
animation-delay: 70ms;
}
.precheckItem:nth-child(4) {
animation-delay: 100ms;
}
.precheckItem span {
display: grid;
gap: 1px;
}
.precheckItem strong {
color: var(--color-text-primary);
font-size: 12px;
}
.precheckItem small {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 700;
}
.precheckPassed {
color: var(--color-success);
}
.precheckWarning {
color: var(--color-warning);
}
.warningText {
color: var(--color-warning);
}
.summary {
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 800;
}
.result {
color: var(--color-success);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.68), rgba(230, 250, 239, 0.42)),
var(--color-success-bg);
animation: release-result-enter 260ms var(--ease-press) both;
}
.error {
color: var(--color-danger);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.68), rgba(255, 232, 232, 0.42)),
var(--color-danger-bg);
animation: release-result-enter 260ms var(--ease-press) both;
}
.footer {
border-top: 1px solid rgba(96, 130, 168, 0.12);
}
.spin {
animation: liquid-spin 900ms linear infinite;
}
@keyframes drawer-section-enter {
from {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes precheck-enter {
from {
opacity: 0;
transform: translateX(8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes release-result-enter {
0% {
opacity: 0;
transform: scale(0.96);
}
70% {
transform: scale(1.02);
}
100% {
opacity: 1;
transform: scale(1);
}
}

View File

@ -0,0 +1,264 @@
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";
type ReleaseDrawerProps = {
clusterName: string;
environment: Environment;
onClose: () => void;
service: Service | null;
userName: string;
};
const strategies: Array<{
description: string;
label: string;
value: ReleaseStrategy;
}> = [
{
description: "按 CLB 摘流、TAT 重启、健康恢复逐实例执行",
label: "TAT 滚动发布",
value: "rolling",
},
];
export function ReleaseDrawer({ clusterName, environment, onClose, service, userName }: ReleaseDrawerProps) {
const [strategy, setStrategy] = useState<ReleaseStrategy>("rolling");
const [targetVersion, setTargetVersion] = useState("");
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) {
return;
}
setStrategy("rolling");
setTargetVersion(versionOptions[0]?.value ?? service.version);
setConfirmed(false);
releaseMutation.reset();
}, [releaseMutation, service, versionOptions]);
if (!service) {
return null;
}
const isProd = environment === "prod";
const hasExecutableTarget = !isProd || Boolean(deployTarget?.prod);
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;
async function handleSubmit() {
if (!service || !canSubmit) {
return;
}
await releaseMutation.mutateAsync({
environment,
confirmed,
operator: userName,
serviceId: service.id,
strategy,
targetVersion,
});
}
return (
<div className={styles.overlay} role="presentation">
<aside aria-label={`部署 ${service.name}`} className={styles.drawer}>
<header className={styles.header}>
<div>
<span className={styles.eyebrow}></span>
<h2>{service.name}</h2>
</div>
<IconButton label="关闭部署抽屉" onClick={onClose}>
<X size={16} />
</IconButton>
</header>
<div className={styles.body}>
{isProd ? (
<section className={styles.prodWarning}>
<AlertTriangle size={18} />
<div>
<strong>prod</strong>
<span> {service.desiredInstances} </span>
</div>
</section>
) : null}
<section className={styles.section}>
<span className={styles.step}>1</span>
<div className={styles.sectionContent}>
<h3></h3>
<Select aria-label="选择目标版本" onValueChange={setTargetVersion} options={versionOptions} value={targetVersion} />
<p>
<Tag tone="brand">{service.version}</Tag>
</p>
</div>
</section>
<section className={styles.section}>
<span className={styles.step}>2</span>
<div className={styles.sectionContent}>
<h3></h3>
<div className={styles.strategyList}>
{strategies.map((item) => (
<Radio
checked={strategy === item.value}
description={item.description}
key={item.value}
label={item.label}
name="release-strategy"
onChange={() => setStrategy(item.value)}
/>
))}
</div>
</div>
</section>
<section className={styles.section}>
<span className={styles.step}>3</span>
<div className={styles.sectionContent}>
<h3></h3>
<div className={styles.targetSummary}>
<span>
<strong></strong>
<small>{targetSummary}</small>
</span>
<span>
<strong></strong>
<small>
{deployHosts.length > 0
? deployHosts.map((host) => host.id).join("、")
: "local-compose"}
</small>
</span>
</div>
<pre className={styles.commandBlock}>
<code>{deployCommand}</code>
</pre>
</div>
</section>
<section className={styles.section}>
<span className={styles.step}>4</span>
<div className={styles.sectionContent}>
<h3></h3>
<div className={styles.precheckList}>
{prechecks.map((item) => (
<div className={styles.precheckItem} key={item.id}>
{item.result === "passed" ? (
<CheckCircle2 className={styles.precheckPassed} size={16} />
) : (
<AlertTriangle className={styles.precheckWarning} size={16} />
)}
<span>
<strong>{item.label}</strong>
<small>{item.detail}</small>
</span>
</div>
))}
</div>
{hasWarning ? <p className={styles.warningText}></p> : null}
</div>
</section>
<section className={styles.section}>
<span className={styles.step}>5</span>
<div className={styles.sectionContent}>
<h3></h3>
<div className={styles.summary}>
<span>{clusterName}</span>
<span>
{service.version} {"->"} {targetVersion}
</span>
<span>{userName}</span>
</div>
{isProd ? (
<Checkbox
checked={confirmed}
label="我已阅读并确认影响范围"
onChange={(event) => setConfirmed(event.target.checked)}
/>
) : null}
</div>
</section>
{releaseMutation.isSuccess ? (
<section className={styles.result}>
<Rocket size={18} />
<div>
<strong></strong>
<span> {releaseMutation.data.targetVersion}</span>
</div>
</section>
) : null}
{releaseMutation.isError ? (
<section className={styles.error}>
<AlertTriangle size={18} />
<div>
<strong></strong>
<span>{releaseMutation.error.message}</span>
</div>
</section>
) : null}
</div>
<footer className={styles.footer}>
<Button onClick={onClose} variant="secondary">
</Button>
<Button disabled={!canSubmit || releaseMutation.isSuccess} isLoading={releaseMutation.isPending} onClick={handleSubmit} variant="primary">
{releaseMutation.isPending ? (
<>
<Loader2 className={styles.spin} size={15} />
</>
) : (
"开始部署"
)}
</Button>
</footer>
</aside>
</div>
);
}

View File

@ -0,0 +1,18 @@
import { useAppContextStore } from "../../../app/store/useAppContextStore";
import { useReleaseDrawerStore } from "../model/useReleaseDrawerStore";
import { ReleaseDrawer } from "./ReleaseDrawer";
export function ReleaseLayer() {
const { clusterName, environment, userName } = useAppContextStore();
const { closeRelease, service } = useReleaseDrawerStore();
return (
<ReleaseDrawer
clusterName={clusterName}
environment={environment}
onClose={closeRelease}
service={service}
userName={userName}
/>
);
}

16
src/main.tsx Normal file
View File

@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { QueryProvider } from "./app/providers/QueryProvider";
import { router } from "./app/router";
import "./app/styles/tokens.css";
import "./app/styles/layout.css";
import "./app/styles/components.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryProvider>
<RouterProvider router={router} />
</QueryProvider>
</React.StrictMode>,
);

View File

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

1
src/pages/audit/index.ts Normal file
View File

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

View File

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

View File

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

View File

@ -0,0 +1,57 @@
.metrics {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-4);
}
.metrics > * {
animation: liquid-pop 360ms var(--ease-liquid) both;
}
.metrics > *:nth-child(1) {
animation-delay: 70ms;
}
.metrics > *:nth-child(2) {
animation-delay: 100ms;
}
.metrics > *:nth-child(3) {
animation-delay: 130ms;
}
.metrics > *:nth-child(4) {
animation-delay: 160ms;
}
.metrics > *:nth-child(5) {
animation-delay: 190ms;
}
.panel {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
animation-delay: 160ms;
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.panelHeader h2 {
margin: 0;
color: var(--color-text-primary);
font-size: 16px;
font-weight: 800;
line-height: 24px;
}
.panelHeader span {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 600;
}

View File

@ -0,0 +1,88 @@
import { AlertTriangle, Bell, Database, Layers3, Rocket } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { getServiceSummary, useServicesQuery } from "../../entities/service";
import { useAppContextStore } from "../../app/store/useAppContextStore";
import { useReleaseDrawerStore } from "../../features/release-service";
import { Card } from "../../shared/ui/Card";
import { MetricCard } from "../../shared/ui/MetricCard";
import { ServiceTable } from "../../widgets/service-table";
import styles from "./OverviewPage.module.css";
export function OverviewPage() {
const navigate = useNavigate();
const { clusterId, clusterName, environment, projectId } =
useAppContextStore();
const openRelease = useReleaseDrawerStore((state) => state.openRelease);
const servicesQuery = useServicesQuery({
clusterId,
environment,
projectId,
});
const services = servicesQuery.data?.items ?? [];
const summary = getServiceSummary(services);
return (
<div className="page">
<div className="page-header">
<div>
<h1 className="page-title"></h1>
<p className="page-subtitle">
</p>
</div>
</div>
<section className={styles.metrics}>
<MetricCard
icon={<Layers3 size={24} />}
label="运行中服务数"
meta="Healthy"
tone="green"
value={summary.healthyCount}
/>
<MetricCard
icon={<AlertTriangle size={24} />}
label="异常服务数"
meta="Warning + Critical"
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="正常资源"
tone="green"
value={summary.databaseHealthyText}
/>
</section>
<Card className={styles.panel} padded={false}>
<div className={styles.panelHeader}>
<h2></h2>
<span>
{environment} / {clusterName}
</span>
</div>
<ServiceTable
isLoading={servicesQuery.isLoading}
onRelease={openRelease}
onViewDetails={(service) => navigate(`/services/${service.id}`)}
services={services}
/>
</Card>
</div>
);
}

View File

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

View File

@ -0,0 +1,23 @@
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

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

View File

@ -0,0 +1,630 @@
.heroGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
}
.heroGrid > * {
animation-delay: 80ms;
}
.heroCard {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--space-3);
min-height: 104px;
padding: 16px;
}
.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: 15px;
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 {
display: grid;
min-width: 0;
gap: 6px;
}
.heroCard span {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 800;
}
.heroCard strong {
overflow: hidden;
color: var(--color-text-primary);
font-size: 14px;
font-weight: 900;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.modeGrid,
.contentGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.modeCard {
min-height: 118px;
padding: 18px;
}
.modeHeader {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: 10px;
color: var(--color-brand);
}
.modeHeader h2,
.panelHeader h2 {
margin: 0;
color: var(--color-text-primary);
font-size: 16px;
font-weight: 900;
line-height: 24px;
}
.modeCard p {
margin: 0;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 700;
line-height: 21px;
}
.panel {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.apiPanel {
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 {
display: grid;
gap: 4px;
}
.panelHeader span {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 700;
}
.panelActions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.adminProdPanel,
.testboxPanel,
.yamlPanel {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.adminProdGrid,
.testboxGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
}
.adminProdGrid > div,
.testboxGrid > div {
display: grid;
min-width: 0;
gap: 7px;
min-height: 92px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 16px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.48), rgba(230, 250, 239, 0.18)),
rgba(255, 255, 255, 0.2);
}
.adminRouteList {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.adminRouteList > div {
display: grid;
min-width: 0;
gap: 6px;
min-height: 82px;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.54);
border-radius: 14px;
background: rgba(255, 255, 255, 0.22);
}
.adminProdGrid span,
.adminProdGrid small,
.adminRouteList span,
.adminRouteList small,
.testboxGrid span,
.testboxGrid small,
.endpointList span,
.yamlItem small {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.adminProdGrid strong,
.adminRouteList strong,
.testboxGrid strong,
.endpointList strong {
overflow: hidden;
color: var(--color-text-primary);
font-size: 13px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.adminProdGrid small,
.adminRouteList span,
.adminRouteList small,
.testboxGrid small,
.endpointList span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.endpointList {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.endpointList a {
display: grid;
min-width: 0;
gap: 6px;
padding: 12px 14px;
color: inherit;
border: 1px solid rgba(255, 255, 255, 0.54);
border-radius: 14px;
background: rgba(255, 255, 255, 0.22);
text-decoration: none;
}
.yamlList {
display: grid;
gap: var(--space-3);
}
.yamlItem {
display: grid;
gap: 8px;
min-width: 0;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.54);
border-radius: 16px;
background: rgba(255, 255, 255, 0.22);
}
.yamlItem > div {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.yamlItem svg {
flex: 0 0 auto;
color: var(--color-brand);
}
.yamlItem strong {
color: var(--color-text-primary);
font-size: 13px;
font-weight: 900;
white-space: nowrap;
}
.yamlItem small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.yamlItem pre {
max-height: 360px;
margin: 0;
overflow: auto;
padding: 12px;
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);
}
.yamlItem code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 11px;
line-height: 1.65;
white-space: pre;
}
.apiGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
}
.apiGrid > div {
display: grid;
min-width: 0;
gap: 7px;
min-height: 74px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 16px;
background: rgba(255, 255, 255, 0.22);
}
.apiGrid span {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 800;
}
.apiGrid strong {
overflow: hidden;
color: var(--color-text-primary);
font-size: 14px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.apiActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: 58px;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.56);
border-radius: 16px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.48), rgba(224, 240, 255, 0.2)),
rgba(255, 255, 255, 0.2);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
inset 0 -1px 0 rgba(75, 108, 148, 0.08);
}
.apiActions > div {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.apiActions > div:first-child {
display: grid;
gap: 4px;
}
.apiActions strong,
.discoveryGrid strong {
overflow: hidden;
color: var(--color-text-primary);
font-size: 14px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.apiActions span,
.apiNotice,
.discoveryGrid span,
.discoveryGrid small {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.apiNotice {
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);
}
.discoveryGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.discoveryGrid > div {
display: grid;
min-width: 0;
gap: 6px;
min-height: 96px;
padding: 13px;
border: 1px solid rgba(255, 255, 255, 0.52);
border-radius: 16px;
background: rgba(255, 255, 255, 0.2);
}
.discoveryGrid small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.serviceCell,
.stackText {
display: grid;
min-width: 0;
gap: 3px;
}
.serviceCell strong,
.stackText strong {
overflow: hidden;
color: var(--color-text-primary);
font-size: 13px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.serviceCell small,
.stackText small {
overflow: hidden;
color: var(--color-text-tertiary);
font-size: 11px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.inlineTags,
.hostList {
display: flex;
max-width: 220px;
flex-wrap: wrap;
gap: 6px;
}
.actions {
width: 92px;
}
.stepList,
.dependencyList,
.commandList,
.guardList {
display: grid;
gap: var(--space-3);
}
.stepItem {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.54);
border-radius: 16px;
background: rgba(255, 255, 255, 0.22);
animation: content-rise 280ms var(--ease-liquid) both;
}
.stepItem > span {
display: grid;
width: 24px;
height: 24px;
place-items: center;
color: #fff;
border-radius: 999px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.34), transparent 42%),
linear-gradient(180deg, #61a3ff, var(--color-brand));
box-shadow: 0 8px 18px rgba(47, 125, 246, 0.24);
font-size: 12px;
font-weight: 900;
}
.stepItem div,
.dependencyItem div {
display: grid;
min-width: 0;
gap: 4px;
}
.stepItem strong,
.dependencyItem strong,
.guardItem strong,
.commandItem strong {
color: var(--color-text-primary);
font-size: 13px;
font-weight: 900;
line-height: 20px;
}
.stepItem small,
.dependencyItem small {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 700;
line-height: 19px;
}
.dependencyItem {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
min-height: 92px;
padding: 13px;
border: 1px solid rgba(255, 255, 255, 0.54);
border-radius: 16px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.48), rgba(230, 250, 239, 0.22)),
rgba(24, 169, 87, 0.08);
}
.dependencyItem svg {
color: var(--color-success);
}
.dependencyItem span {
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 800;
}
.commandItem {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.54);
border-radius: 16px;
background: rgba(255, 255, 255, 0.22);
}
.commandItem > div {
display: flex;
align-items: center;
gap: var(--space-2);
}
.commandItem svg {
color: var(--color-brand);
}
.commandItem pre {
margin: 0;
overflow: auto;
padding: 12px;
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);
}
.commandItem code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 11px;
line-height: 1.65;
white-space: pre;
}
.guardItem {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--space-3);
min-height: 44px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.54);
border-radius: 14px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.58), rgba(255, 246, 225, 0.28)),
var(--color-warning-bg);
}
.guardItem span {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--color-warning);
box-shadow: 0 0 0 4px rgba(245, 165, 36, 0.12);
}
@media (max-width: 1320px) {
.heroGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.contentGrid {
grid-template-columns: 1fr;
}
.apiGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.adminRouteList,
.endpointList,
.adminProdGrid,
.testboxGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.discoveryGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.apiActions {
align-items: stretch;
flex-direction: column;
}
.apiActions > div {
justify-content: space-between;
}
.adminRouteList,
.endpointList,
.adminProdGrid,
.testboxGrid {
grid-template-columns: 1fr;
}
.panelActions {
align-items: flex-end;
flex-direction: column;
}
}

View File

@ -0,0 +1,587 @@
import {
CloudCog,
Database,
FileText,
GitBranch,
HardDrive,
Network,
PlayCircle,
ServerCog,
ShieldAlert,
TerminalSquare,
} from "lucide-react";
import {
getHyappDeploymentHosts,
hyappDeploymentPlan,
type HyappDeploymentService,
useDeployApiDiscoverQuery,
useDeployApiInventoryQuery,
useDeployApiPlanQuery,
useDeployApiTestboxConfigsQuery,
} from "../../entities/deployment-plan";
import { mockServices } from "../../entities/service";
import { useReleaseDrawerStore } from "../../features/release-service";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
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",
];
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 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 remoteStatusTone = apiDiscoverQuery.isFetching && !apiDiscoverQuery.data
? "running"
: apiDiscoverQuery.isError || remoteErrorCount > 0
? "warning"
: apiDiscoverQuery.isSuccess
? "success"
: "neutral";
const remoteStatusText = apiDiscoverQuery.isFetching
? apiDiscoverQuery.data
? "刷新中"
: "查询中"
: apiDiscoverQuery.isError
? "查询失败"
: 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>[] = [
{
key: "service",
title: "服务",
render: (service) => (
<span className={styles.serviceCell}>
<strong>{service.name}</strong>
<small>{service.domain}</small>
</span>
),
},
{
key: "status",
title: "状态",
render: (service) => <StatusBadge status={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>
</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: "unit",
title: "Unit / 端口",
render: (service) => (
<span className={styles.stackText}>
<strong>{service.prod?.unit ?? service.local.composeService}</strong>
<small>{service.prod?.targetPort ?? service.local.grpcPort ?? service.local.httpPort}</small>
</span>
),
},
{
key: "clb",
title: "CLB",
render: (service) =>
service.prod ? (
<span className={styles.stackText}>
<strong>{service.prod.clb.loadBalancerId}</strong>
<small>{service.prod.clb.entrypoint}</small>
</span>
) : (
<span className="text-muted">local only</span>
),
},
{
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>
);
},
},
];
return (
<div className="page">
<div className="page-header">
<div>
<h1 className="page-title">HyApp </h1>
<p className="page-subtitle">
{hyappDeploymentPlan.repositoryPath} Composesystemd Tencent TAT
</p>
</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>
</div>
</Card>
<Card className={styles.heroCard} padded={false}>
<span className={styles.heroIcon}>
<HardDrive size={22} />
</span>
<div>
<span></span>
<strong>{hyappDeploymentPlan.registry}</strong>
</div>
</Card>
<Card className={styles.heroCard} padded={false}>
<span className={styles.heroIcon}>
<ServerCog size={22} />
</span>
<div>
<span></span>
<strong>
{hyappDeploymentPlan.deployServer.id} / {hyappDeploymentPlan.deployServer.privateIp}
</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>
</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>
</div>
<div>
<Tag tone={remoteStatusTone} size="md">
{remoteStatusText}
</Tag>
<Button
disabled={apiDiscoverQuery.isFetching}
isLoading={apiDiscoverQuery.isLoading}
onClick={handleRemoteDiscover}
size="sm"
variant="secondary"
>
</Button>
</div>
</div>
{apiDiscoverQuery.isError ? (
<div className={styles.apiNotice}>{apiDiscoverQuery.error instanceof Error ? apiDiscoverQuery.error.message : "真实状态查询失败"}</div>
) : null}
{apiDiscoverQuery.isSuccess ? (
<div className={styles.discoveryGrid}>
<div>
<span></span>
<strong>
{remoteHostSuccessCount}/{remoteHosts.length}
</strong>
<small>{remoteHosts.map((host) => `${host.host} ${host.status}`).join(" · ")}</small>
</div>
<div>
<span>CLB </span>
<strong>
{remoteClbSuccessCount}/{remoteClbSnapshots.length}
</strong>
<small>
{remoteClbSnapshots
.slice(0, 6)
.map((snapshot) => {
const weights = snapshot.bindings?.map((binding) => binding.weight).join("/") || "异常";
return `${snapshot.service}:${snapshot.host} ${weights}`;
})
.join(" · ")}
</small>
</div>
<div>
<span></span>
<strong>{remoteErrorCount}</strong>
<small>{remoteErrorCount === 0 ? "TAT 与 CLB 查询均成功" : "存在实例或 CLB 查询异常"}</small>
</div>
</div>
) : null}
</Card>
<Card className={styles.panel} padded={false}>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<span>Compose systemd unitCLB </span>
</div>
<Tag tone="brand" size="md">
{hyappDeploymentPlan.services.length}
</Tag>
</div>
<Table columns={serviceColumns} data={hyappDeploymentPlan.services} getRowKey={(service) => service.id} />
</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>
</div>
</div>
<div className={styles.dependencyList}>
{hyappDeploymentPlan.managedDependencies.map((dependency) => (
<div className={styles.dependencyItem} key={dependency.id}>
<Database size={18} />
<div>
<strong>{dependency.name}</strong>
<span>{dependency.provider}</span>
<small>{dependency.boundary}</small>
</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>
</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>
</Card>
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,5 @@
import { PlaceholderPage } from "../placeholder";
export function ResourcesPage() {
return <PlaceholderPage title="数据漫游" />;
}

View File

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

View File

@ -0,0 +1,783 @@
.breadcrumb {
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-tertiary);
font-size: 13px;
animation: content-rise 320ms var(--ease-liquid) both;
}
.breadcrumb strong {
color: var(--color-text-primary);
}
.hero {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-4);
min-height: 118px;
padding: 20px;
}
.serviceMark {
display: grid;
width: 62px;
height: 62px;
place-items: center;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 20px;
background:
linear-gradient(145deg, rgba(82, 221, 144, 0.95), rgba(21, 176, 96, 0.98)),
rgba(24, 169, 87, 0.8);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.58),
0 16px 36px rgba(24, 169, 87, 0.22);
}
.heroMain {
display: flex;
min-width: 0;
flex-direction: column;
gap: 14px;
}
.titleRow {
display: flex;
align-items: center;
gap: var(--space-3);
}
.titleRow h1 {
margin: 0;
color: var(--color-text-primary);
font-size: 24px;
font-weight: 800;
line-height: 30px;
}
.metaList {
display: flex;
flex-wrap: wrap;
gap: 12px 22px;
color: var(--color-text-secondary);
font-size: 13px;
}
.tabsCard {
min-height: 58px;
padding: 6px;
}
.tabs {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.tab {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 46px;
color: var(--color-text-secondary);
border: 1px solid transparent;
border-radius: 16px;
background: transparent;
transition:
color var(--duration-normal) var(--ease-standard),
border-color var(--duration-normal) var(--ease-standard),
background var(--duration-normal) var(--ease-standard),
box-shadow var(--duration-normal) var(--ease-standard),
transform var(--duration-fast) var(--ease-press);
}
.tab:hover {
color: var(--color-brand);
border-color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.32);
box-shadow: var(--shadow-liquid-control);
transform: translateY(-1px);
}
.tab[data-active="true"] {
color: var(--color-brand);
border-color: rgba(255, 255, 255, 0.82);
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.66),
rgba(226, 240, 255, 0.36)
),
rgba(255, 255, 255, 0.52);
box-shadow: var(--shadow-liquid-control-hover);
}
.tab[data-active="true"]::after {
position: absolute;
right: 16px;
bottom: 5px;
left: 16px;
height: 3px;
border-radius: 999px;
background: linear-gradient(
90deg,
rgba(47, 125, 246, 0.28),
var(--color-brand),
rgba(47, 125, 246, 0.28)
);
content: "";
}
.content {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.section {
padding: 18px;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: 16px;
}
.sectionHeader h2 {
margin: 0;
color: var(--color-text-primary);
font-size: 15px;
font-weight: 800;
line-height: 22px;
}
.sectionHeader span {
color: var(--color-text-tertiary);
font-size: 12px;
}
.summaryGrid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.46);
border-radius: 18px;
background: rgba(116, 150, 190, 0.12);
}
.summaryItem {
display: flex;
min-height: 82px;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 14px;
background: rgba(255, 255, 255, 0.24);
}
.summaryItem span {
color: var(--color-text-tertiary);
font-size: 12px;
}
.summaryValue {
color: var(--color-text-primary);
font-size: 16px;
font-weight: 800;
}
.dependencyBlock {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: 18px;
}
.dependencyBlock h3 {
margin: 0;
color: var(--color-text-primary);
font-size: 14px;
font-weight: 800;
}
.dependencyList {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.metricGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.metricPanel {
padding: 14px;
}
.metricHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: 6px;
}
.metricHeader span {
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 800;
}
.metricHeader strong {
color: var(--color-text-primary);
font-size: 16px;
font-weight: 800;
}
.chartWrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart {
width: 100%;
height: 128px;
}
.gridLine {
stroke: rgba(86, 120, 158, 0.18);
stroke-width: 1;
}
.chartAxis {
display: flex;
justify-content: space-between;
color: var(--color-text-tertiary);
font-size: 11px;
}
.bottomGrid {
display: grid;
grid-template-columns: minmax(300px, 0.9fr) minmax(480px, 1.4fr);
gap: var(--space-4);
}
.eventList {
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.46);
border-radius: 18px;
background: rgba(116, 150, 190, 0.12);
}
.eventItem {
display: grid;
grid-template-columns: 56px 88px 1fr;
align-items: center;
gap: var(--space-3);
min-height: 48px;
padding: 0 14px;
color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.24);
}
.eventItem strong {
color: var(--color-text-primary);
font-size: 13px;
}
.eventTime {
color: var(--color-text-tertiary);
font-size: 12px;
}
.textAction {
color: var(--color-brand);
border: 0;
background: transparent;
font-size: 12px;
font-weight: 800;
}
.textAction:hover {
color: var(--color-brand-hover);
}
.instanceName {
color: var(--color-text-primary);
font-size: 12px;
}
.instanceLayout {
display: grid;
grid-template-columns: minmax(0, 0.92fr) minmax(460px, 1.08fr);
gap: var(--space-4);
align-items: start;
}
.instanceDetail {
padding: 18px;
}
.instanceSelector {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
margin-bottom: 16px;
}
.instancePill {
display: flex;
min-width: 0;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 0 12px;
color: var(--color-text-secondary);
border: 1px solid rgba(255, 255, 255, 0.52);
border-radius: 16px;
background: rgba(255, 255, 255, 0.2);
box-shadow: var(--shadow-liquid-control);
transition:
border-color var(--duration-normal) var(--ease-standard),
background var(--duration-normal) var(--ease-standard),
box-shadow var(--duration-normal) var(--ease-standard),
transform var(--duration-fast) var(--ease-press);
}
.instancePill:hover {
border-color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.38);
box-shadow: var(--shadow-liquid-control-hover);
transform: translateY(-1px);
}
.instancePill[data-active="true"] {
color: var(--color-brand);
border-color: rgba(47, 125, 246, 0.3);
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.68),
rgba(226, 240, 255, 0.38)
),
rgba(255, 255, 255, 0.46);
box-shadow: var(--shadow-liquid-control-hover);
}
.instancePillName {
overflow: hidden;
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.instanceMetaGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin-bottom: 14px;
}
.instanceMetaGrid > div {
display: flex;
min-height: 76px;
flex-direction: column;
justify-content: center;
gap: 7px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.48);
border-radius: 16px;
background: rgba(255, 255, 255, 0.24);
}
.instanceMetaGrid span,
.imageBlock span {
color: var(--color-text-tertiary);
font-size: 12px;
}
.instanceMetaGrid strong {
overflow: hidden;
color: var(--color-text-primary);
font-size: 14px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.imageBlock {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 18px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.48);
border-radius: 16px;
background: rgba(255, 255, 255, 0.22);
}
.imageBlock code,
.configValue {
color: var(--color-text-primary);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 18px;
word-break: break-all;
}
.configSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: 12px;
}
.configSectionHeader h3 {
margin: 0 0 4px;
color: var(--color-text-primary);
font-size: 14px;
font-weight: 800;
}
.configSectionHeader span {
color: var(--color-text-tertiary);
font-size: 12px;
}
.configPreview {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.52);
border-radius: 18px;
background:
linear-gradient(
145deg,
rgba(255, 255, 255, 0.38),
rgba(229, 241, 255, 0.22)
),
rgba(255, 255, 255, 0.18);
box-shadow: var(--shadow-liquid-control);
}
.configPreviewList {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 8px;
}
.configKey {
display: flex;
flex-direction: column;
gap: 3px;
}
.configKey strong {
color: var(--color-text-primary);
font-size: 12px;
font-weight: 800;
}
.configKey span {
color: var(--color-text-tertiary);
font-size: 11px;
}
.modalOverlay {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 28px;
background:
radial-gradient(
60% 70% at 50% 20%,
rgba(255, 255, 255, 0.24),
transparent 64%
),
rgba(29, 43, 58, 0.22);
backdrop-filter: blur(18px) saturate(150%);
-webkit-backdrop-filter: blur(18px) saturate(150%);
animation: liquid-fade var(--duration-normal) var(--ease-standard) both;
}
.yamlModal {
position: relative;
isolation: isolate;
display: flex;
width: min(920px, calc(100vw - 56px));
max-height: min(760px, 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);
animation: liquid-pop var(--duration-page) var(--ease-liquid) both;
}
.yamlModal::before {
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(
70% 45% at 16% 0%,
rgba(255, 255, 255, 0.82),
transparent 60%
),
linear-gradient(135deg, rgba(255, 255, 255, 0.42), transparent 44%);
content: "";
}
.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;
}
.yamlBlock {
margin: 0;
padding: 18px;
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);
}
.yamlBlock code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.7;
white-space: pre;
}
.releasePanel {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-4);
min-height: 140px;
color: var(--color-text-secondary);
}
.releasePanel > svg {
color: var(--color-brand);
}
.releasePanel h2 {
margin: 0 0 6px;
color: var(--color-text-primary);
font-size: 18px;
}
.releasePanel p {
margin: 0;
}
.releaseGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
margin-top: 16px;
}
.releaseGrid > div {
display: flex;
min-height: 86px;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 16px;
background: rgba(255, 255, 255, 0.24);
}
.releaseGrid span,
.releaseHosts > span {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 800;
}
.releaseGrid strong,
.releaseGrid code {
overflow: hidden;
color: var(--color-text-primary);
font-size: 12px;
font-weight: 900;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.releaseGrid code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.releaseHosts {
display: grid;
gap: var(--space-2);
margin-top: 14px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 16px;
background: rgba(255, 255, 255, 0.2);
}
.releaseHosts > div {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.logPanel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
color: #d7f7ff;
border: 1px solid rgba(255, 255, 255, 0.38);
border-radius: 18px;
background:
linear-gradient(145deg, rgba(27, 49, 73, 0.92), rgba(23, 34, 52, 0.88)),
rgba(26, 40, 58, 0.9);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.logPanel code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.configGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
}
.configGrid > div {
display: flex;
min-height: 86px;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 18px;
background: rgba(255, 255, 255, 0.24);
}
.configGrid span {
color: var(--color-text-tertiary);
font-size: 12px;
}
.configGrid strong {
color: var(--color-text-primary);
font-size: 15px;
}
.floatingStatus {
position: sticky;
bottom: 16px;
display: inline-flex;
align-self: center;
align-items: center;
gap: 8px;
max-width: max-content;
min-height: 38px;
padding: 0 14px;
color: var(--color-text-secondary);
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 999px;
background: rgba(255, 255, 255, 0.56);
box-shadow: var(--shadow-liquid-control-hover);
backdrop-filter: blur(24px) saturate(170%);
-webkit-backdrop-filter: blur(24px) saturate(170%);
}
.floatingStatus svg {
color: var(--color-brand);
}
.loadingGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.loadingBlock,
.loadingTall {
min-height: 160px;
}
.loadingTall {
grid-column: 1 / -1;
min-height: 360px;
}
@media (max-width: 1320px) {
.summaryGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.bottomGrid {
grid-template-columns: 1fr;
}
.instanceLayout {
grid-template-columns: 1fr;
}
.releaseGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@ -0,0 +1,762 @@
import { useEffect, useMemo, useState } from "react";
import {
Activity,
ArrowLeft,
Box,
CheckCircle2,
Clock3,
Database,
FileText,
GitBranch,
PackageCheck,
Rocket,
Server,
Settings,
TerminalSquare,
X,
} 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,
type ServiceMetric,
useServiceDetailQuery,
} 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 { StatusBadge } from "../../shared/ui/StatusBadge";
import { Table, type TableColumn } from "../../shared/ui/Table";
import { Tag } from "../../shared/ui/Tag";
import styles from "./ServiceDetailPage.module.css";
const detailTabs = [
{ id: "overview", icon: Activity, label: "概览" },
{ id: "instances", icon: Server, label: "实例" },
{ id: "release", icon: Rocket, label: "发布" },
{ id: "logs", icon: TerminalSquare, label: "日志" },
{ id: "monitor", icon: FileText, label: "监控" },
{ id: "config", icon: Settings, label: "配置" },
{ id: "events", icon: GitBranch, label: "事件" },
] as const;
type DetailTab = (typeof detailTabs)[number]["id"];
const baseInstanceColumns: TableColumn<ServiceInstance>[] = [
{
key: "name",
title: "实例",
render: (instance) => (
<strong className={styles.instanceName}>{instance.name}</strong>
),
},
{
key: "node",
title: "节点",
render: (instance) => instance.node,
},
{
key: "restartCount",
title: "重启次数",
render: (instance) => instance.restartCount,
},
{
key: "status",
title: "健康状态",
render: (instance) => <StatusBadge status={instance.status} />,
},
{
key: "runningTime",
title: "运行时长",
render: (instance) => instance.runningTime,
},
];
function createInstanceColumns(
onViewConfig?: (instance: ServiceInstance) => void,
): TableColumn<ServiceInstance>[] {
if (!onViewConfig) {
return baseInstanceColumns;
}
return [
...baseInstanceColumns,
{
key: "actions",
title: "操作",
render: (instance) => (
<Button
onClick={() => onViewConfig(instance)}
size="sm"
variant="ghost"
>
</Button>
),
},
];
}
function formatYamlValue(value: string | number) {
if (typeof value === "number") {
return String(value);
}
if (/^[a-zA-Z0-9._/-]+$/.test(value)) {
return value;
}
return JSON.stringify(value);
}
function createInstanceConfigYaml(instance: ServiceInstance) {
const lines = [
"apiVersion: ops.deploy-platform/v1",
"kind: ServiceInstanceConfig",
"metadata:",
` name: ${formatYamlValue(instance.name)}`,
` podIp: ${formatYamlValue(instance.podIp)}`,
` node: ${formatYamlValue(instance.node)}`,
` zone: ${formatYamlValue(instance.zone)}`,
"spec:",
` image: ${formatYamlValue(instance.image)}`,
` resourceLimit: ${formatYamlValue(instance.resourceLimit)}`,
` restartCount: ${instance.restartCount}`,
` runningTime: ${formatYamlValue(instance.runningTime)}`,
" config:",
];
instance.configItems.forEach((item) => {
lines.push(
` - key: ${formatYamlValue(item.key)}`,
` value: ${formatYamlValue(item.value)}`,
` source: ${formatYamlValue(item.source)}`,
` updatedAt: ${formatYamlValue(item.updatedAt)}`,
` description: ${formatYamlValue(item.description)}`,
);
});
return lines.join("\n");
}
function metricColor(metric: ServiceMetric) {
const colors: Record<ServiceMetric["color"], string> = {
blue: "var(--color-chart-blue)",
green: "var(--color-chart-green)",
orange: "var(--color-chart-orange)",
purple: "var(--color-chart-purple)",
red: "var(--color-chart-red)",
};
return colors[metric.color];
}
function MetricChart({ metric }: { metric: ServiceMetric }) {
const color = metricColor(metric);
const width = 280;
const height = 128;
const padding = 18;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const points = metric.points.map((point, index) => {
const x = padding + (chartWidth / (metric.points.length - 1)) * index;
const y =
padding +
chartHeight -
(Math.min(point.value, metric.max) / metric.max) * chartHeight;
return { x, y };
});
const line = points.map((point) => `${point.x},${point.y}`).join(" ");
const fill = `${padding},${height - padding} ${line} ${width - padding},${height - padding}`;
const firstTime = metric.points[0]?.time ?? "";
const middleTime =
metric.points[Math.floor(metric.points.length / 2)]?.time ?? "";
const lastTime = metric.points[metric.points.length - 1]?.time ?? "";
return (
<div className={styles.chartWrap}>
<svg
aria-label={metric.label}
className={styles.chart}
role="img"
viewBox={`0 0 ${width} ${height}`}
>
<defs>
<linearGradient
id={`metric-fill-${metric.id}`}
x1="0"
x2="0"
y1="0"
y2="1"
>
<stop offset="0%" stopColor={color} stopOpacity="0.2" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<path
d={`M ${padding} ${padding} H ${width - padding}`}
className={styles.gridLine}
/>
<path
d={`M ${padding} ${padding + chartHeight / 2} H ${width - padding}`}
className={styles.gridLine}
/>
<path
d={`M ${padding} ${height - padding} H ${width - padding}`}
className={styles.gridLine}
/>
<polygon fill={`url(#metric-fill-${metric.id})`} points={fill} />
<polyline
fill="none"
points={line}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
/>
</svg>
<div className={styles.chartAxis}>
<span>{firstTime}</span>
<span>{middleTime}</span>
<span>{lastTime}</span>
</div>
</div>
);
}
function MetricPanel({ metric }: { metric: ServiceMetric }) {
return (
<Card className={styles.metricPanel} padded={false} variant="subtle">
<div className={styles.metricHeader}>
<span>{metric.label}</span>
<strong>{metric.value}</strong>
</div>
<MetricChart metric={metric} />
</Card>
);
}
function EventList({ events }: { events: ServiceEvent[] }) {
return (
<div className={styles.eventList}>
{events.map((event) => (
<div className={styles.eventItem} key={event.id}>
<span className={styles.eventTime}>{event.time}</span>
<strong>{event.title}</strong>
<span>{event.message}</span>
</div>
))}
</div>
);
}
export function ServiceDetailPage() {
const navigate = useNavigate();
const { serviceId = "" } = useParams();
const { clusterName, environment, projectName } = useAppContextStore();
const openRelease = useReleaseDrawerStore((state) => state.openRelease);
const [activeTab, setActiveTab] = useState<DetailTab>("overview");
const [selectedInstanceId, setSelectedInstanceId] = useState("");
const [configModalInstanceId, setConfigModalInstanceId] = useState("");
const detailQuery = useServiceDetailQuery(serviceId);
const service = detailQuery.data;
const deploymentService = service ? getHyappDeploymentServiceByName(service.name) : undefined;
const deploymentHosts = deploymentService ? getHyappDeploymentHosts(deploymentService) : [];
const summaryItems = useMemo(() => {
if (!service) {
return [];
}
return [
{ label: "状态", value: <StatusBadge status={service.status} /> },
{ label: "当前版本", value: <strong>{service.version}</strong> },
{
label: "实例数",
value: `${service.readyInstances} / ${service.desiredInstances}`,
},
{ label: "运行时间", value: service.runningTime },
{ label: "请求量 (QPS)", value: service.qps },
{ label: "错误率", value: service.errorRate },
];
}, [service]);
const selectedInstance = useMemo(() => {
if (!service) {
return undefined;
}
return (
service.instances.find(
(instance) => instance.id === selectedInstanceId,
) ?? service.instances[0]
);
}, [selectedInstanceId, service]);
const configModalInstance = useMemo(() => {
if (!service || !configModalInstanceId) {
return undefined;
}
return service.instances.find(
(instance) => instance.id === configModalInstanceId,
);
}, [configModalInstanceId, service]);
const configYaml = useMemo(() => {
return configModalInstance
? createInstanceConfigYaml(configModalInstance)
: "";
}, [configModalInstance]);
useEffect(() => {
if (!configModalInstanceId) {
return undefined;
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setConfigModalInstanceId("");
}
};
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [configModalInstanceId]);
const openConfigModal = (instance: ServiceInstance) => {
setSelectedInstanceId(instance.id);
setConfigModalInstanceId(instance.id);
};
if (detailQuery.isLoading) {
return (
<div className="page">
<div className={styles.loadingGrid}>
<Card className={styles.loadingBlock}>
<span />
</Card>
<Card className={styles.loadingBlock}>
<span />
</Card>
<Card className={styles.loadingTall}>
<span />
</Card>
</div>
</div>
);
}
if (!service) {
return (
<div className="page">
<Card>
<EmptyState
title="服务不存在"
description="当前项目环境下没有找到这个服务。"
/>
</Card>
</div>
);
}
const monitorSection = (
<Card className={styles.section} padded={false}>
<div className={styles.sectionHeader}>
<h2></h2>
<span> 1 </span>
</div>
<div className={styles.metricGrid}>
{service.metrics.map((metric) => (
<MetricPanel key={metric.id} metric={metric} />
))}
</div>
</Card>
);
const overviewContent = (
<>
<Card className={styles.section} padded={false}>
<div className={styles.sectionHeader}>
<h2></h2>
<span></span>
</div>
<div className={styles.summaryGrid}>
{summaryItems.map((item) => (
<div className={styles.summaryItem} key={item.label}>
<span>{item.label}</span>
<div className={styles.summaryValue}>{item.value}</div>
</div>
))}
</div>
<div className={styles.dependencyBlock}>
<h3></h3>
<div className={styles.dependencyList}>
{service.dependencies.map((dependency) => (
<Tag
key={dependency.id}
tone={dependency.status === "healthy" ? "success" : "running"}
>
{dependency.name}
</Tag>
))}
</div>
</div>
</Card>
{monitorSection}
<div className={styles.bottomGrid}>
<Card className={styles.section} padded={false}>
<div className={styles.sectionHeader}>
<h2></h2>
<button
className={styles.textAction}
onClick={() => setActiveTab("events")}
type="button"
>
</button>
</div>
<EventList events={service.events.slice(0, 5)} />
</Card>
<Card className={styles.section} padded={false}>
<div className={styles.sectionHeader}>
<h2></h2>
<span>{service.readyInstances} </span>
</div>
<Table
columns={baseInstanceColumns}
data={service.instances}
getRowKey={(instance) => instance.id}
/>
</Card>
</div>
</>
);
const tabContent: Record<DetailTab, JSX.Element> = {
config: (
<Card className={styles.section} padded={false}>
<div className={styles.sectionHeader}>
<h2></h2>
<span></span>
</div>
<div className={styles.configGrid}>
<div>
<span></span>
<strong>{service.namespace}</strong>
</div>
<div>
<span></span>
<strong>{service.owner}</strong>
</div>
<div>
<span></span>
<strong>2C / 4Gi</strong>
</div>
<div>
<span></span>
<strong></strong>
</div>
</div>
</Card>
),
events: (
<Card className={styles.section} padded={false}>
<div className={styles.sectionHeader}>
<h2></h2>
<span></span>
</div>
<EventList events={service.events} />
</Card>
),
instances: (
<div className={styles.instanceLayout}>
<Card className={styles.section} padded={false}>
<div className={styles.sectionHeader}>
<h2></h2>
<span>
{service.readyInstances} / {service.desiredInstances}
</span>
</div>
<Table
columns={createInstanceColumns(openConfigModal)}
data={service.instances}
getRowKey={(instance) => instance.id}
/>
</Card>
{selectedInstance ? (
<Card className={styles.instanceDetail} padded={false}>
<div className={styles.sectionHeader}>
<h2></h2>
<span>{selectedInstance.name}</span>
</div>
<div className={styles.instanceSelector}>
{service.instances.map((instance) => (
<button
className={styles.instancePill}
data-active={selectedInstance.id === instance.id}
key={instance.id}
onClick={() => setSelectedInstanceId(instance.id)}
type="button"
>
<span className={styles.instancePillName}>
{instance.name}
</span>
<StatusBadge status={instance.status} />
</button>
))}
</div>
<div className={styles.instanceMetaGrid}>
<div>
<span> IP</span>
<strong>{selectedInstance.podIp}</strong>
</div>
<div>
<span></span>
<strong>{selectedInstance.node}</strong>
</div>
<div>
<span></span>
<strong>{selectedInstance.zone}</strong>
</div>
<div>
<span></span>
<strong>{selectedInstance.resourceLimit}</strong>
</div>
<div>
<span></span>
<strong>{selectedInstance.restartCount}</strong>
</div>
<div>
<span></span>
<strong>{selectedInstance.runningTime}</strong>
</div>
</div>
<div className={styles.imageBlock}>
<span></span>
<code>{selectedInstance.image}</code>
</div>
<div className={styles.configSectionHeader}>
<div>
<h3></h3>
<span>
{selectedInstance.configItems.length}
</span>
</div>
<Tag tone="success"></Tag>
</div>
<div className={styles.configPreview}>
<div className={styles.configPreviewList}>
{selectedInstance.configItems.slice(0, 4).map((item) => (
<Tag key={item.id} tone="brand">
{item.key}
</Tag>
))}
{selectedInstance.configItems.length > 4 ? (
<Tag tone="neutral">
+{selectedInstance.configItems.length - 4}
</Tag>
) : null}
</div>
<Button
onClick={() => openConfigModal(selectedInstance)}
variant="secondary"
>
YAML
</Button>
</div>
</Card>
) : null}
</div>
),
logs: (
<Card className={styles.section} padded={false}>
<div className={styles.sectionHeader}>
<h2></h2>
<span></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>
</div>
</Card>
),
monitor: monitorSection,
overview: overviewContent,
release: (
<Card className={styles.section} padded={false}>
<div className={styles.releasePanel}>
<Rocket size={26} />
<div>
<h2> {service.name}</h2>
<p> {service.version} TAT </p>
</div>
<Button onClick={() => openRelease(service)} variant="primary">
</Button>
</div>
<div className={styles.releaseGrid}>
<div>
<span></span>
<code>{deploymentService?.local.commands.rebuild ?? "未绑定"}</code>
</div>
<div>
<span> Unit</span>
<strong>{deploymentService?.prod?.unit ?? "未进 TAT inventory"}</strong>
</div>
<div>
<span>CLB </span>
<strong>{deploymentService?.prod?.clb.entrypoint ?? "local only"}</strong>
</div>
<div>
<span></span>
<code>{deploymentService?.prod?.configPath ?? deploymentService?.local.configPath ?? "未绑定"}</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}
</Tag>
))
) : (
<Tag tone="warning">local-compose</Tag>
)}
</div>
</div>
</Card>
),
};
return (
<div className="page">
<div className={styles.breadcrumb}>
<IconButton label="返回微服务" onClick={() => navigate("/services")}>
<ArrowLeft size={17} />
</IconButton>
<span>{projectName}</span>
<span>/</span>
<span>{environment}</span>
<span>/</span>
<span>{clusterName}</span>
<span>/</span>
<strong>{service.name}</strong>
</div>
<Card className={styles.hero} padded={false}>
<div className={styles.serviceMark}>
<PackageCheck size={30} />
</div>
<div className={styles.heroMain}>
<div className={styles.titleRow}>
<h1>{service.name}</h1>
<StatusBadge status={service.status} />
</div>
<div className={styles.metaList}>
<span> {service.namespace}</span>
<span> {service.owner}</span>
<span> {service.createdAt}</span>
</div>
</div>
<Button onClick={() => openRelease(service)} variant="primary">
</Button>
</Card>
<Card className={styles.tabsCard} padded={false}>
<div className={styles.tabs} role="tablist">
{detailTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
aria-selected={isActive}
className={styles.tab}
data-active={isActive}
key={tab.id}
onClick={() => setActiveTab(tab.id)}
role="tab"
type="button"
>
<Icon size={15} />
<span>{tab.label}</span>
</button>
);
})}
</div>
</Card>
<div className={styles.content}>{tabContent[activeTab]}</div>
<div className={styles.floatingStatus}>
<CheckCircle2 size={15} />
<span></span>
<Clock3 size={14} />
<span></span>
<Database size={15} />
<span>{service.dependencies.length} </span>
<Box size={15} />
<span>{service.instances.length} </span>
</div>
{configModalInstance ? (
<div
className={styles.modalOverlay}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setConfigModalInstanceId("");
}
}}
role="presentation"
>
<section
aria-labelledby="instance-config-yaml-title"
aria-modal="true"
className={styles.yamlModal}
role="dialog"
>
<div className={styles.modalHeader}>
<div>
<h2 id="instance-config-yaml-title"> YAML</h2>
<span>{configModalInstance.name}</span>
</div>
<IconButton
label="关闭配置弹窗"
onClick={() => setConfigModalInstanceId("")}
>
<X size={18} />
</IconButton>
</div>
<pre className={styles.yamlBlock}>
<code>{configYaml}</code>
</pre>
</section>
</div>
) : null}
</div>
);
}

View File

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

View File

@ -0,0 +1,21 @@
.panel {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
animation-delay: 90ms;
}
.toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-4);
min-height: 36px;
}
.count {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}

View File

@ -0,0 +1,49 @@
import { useMemo } from "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 { useReleaseDrawerStore } from "../../features/release-service";
import { Card } from "../../shared/ui/Card";
import { ServiceTable } from "../../widgets/service-table";
import styles from "./ServicesPage.module.css";
export function ServicesPage() {
const navigate = useNavigate();
const { clusterId, environment, projectId } = useAppContextStore();
const openRelease = useReleaseDrawerStore((state) => state.openRelease);
const serviceListParams = useMemo<ServiceListParams>(() => {
return {
clusterId,
environment,
projectId,
};
}, [clusterId, environment, projectId]);
const servicesQuery = useServicesQuery(serviceListParams);
const services = servicesQuery.data?.items ?? [];
return (
<div className="page">
<div className="page-header">
<div>
<h1 className="page-title"></h1>
<p className="page-subtitle"></p>
</div>
</div>
<Card className={styles.panel} padded={false}>
<div className={styles.toolbar}>
<span className={styles.count}>
{servicesQuery.data?.total ?? 0}
</span>
</div>
<ServiceTable
isLoading={servicesQuery.isLoading}
onRelease={openRelease}
onViewDetails={(service) => navigate(`/services/${service.id}`)}
services={services}
/>
</Card>
</div>
);
}

View File

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

View File

@ -0,0 +1,10 @@
import type { ServiceListParams } from "../../entities/service";
export const queryKeys = {
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,
services: (params: ServiceListParams) => ["services", params] as const,
};

View File

@ -0,0 +1,108 @@
.root {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-width: 84px;
border: 1px solid transparent;
border-radius: 999px;
font-weight: 700;
line-height: 18px;
white-space: nowrap;
transition:
background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-press);
}
.root:hover:not(:disabled) {
transform: translateY(-1px);
}
.root:active:not(:disabled) {
transform: translateY(0) scale(0.98);
}
.sm {
height: 32px;
padding: 0 12px;
font-size: 12px;
}
.md {
height: 36px;
padding: 0 16px;
font-size: 13px;
}
.lg {
height: 40px;
padding: 0 18px;
font-size: 14px;
}
.primary {
color: var(--color-text-inverse);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.32), transparent 42%),
linear-gradient(180deg, #3f8cff 0%, #1f6ee8 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
0 10px 24px rgba(47, 125, 246, 0.34);
}
.primary:hover:not(:disabled) {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.42), transparent 42%),
linear-gradient(180deg, #4b95ff 0%, var(--color-brand-hover) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.64),
0 14px 30px rgba(47, 125, 246, 0.38);
}
.secondary {
color: var(--color-text-secondary);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.58), rgba(241, 248, 255, 0.3)),
rgba(255, 255, 255, 0.28);
border-color: rgba(255, 255, 255, 0.72);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.76),
inset 0 -1px 0 rgba(91, 125, 166, 0.08);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
}
.secondary:hover:not(:disabled),
.ghost:hover:not(:disabled) {
color: var(--color-brand);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.72), rgba(226, 241, 255, 0.44)),
rgba(255, 255, 255, 0.42);
border-color: rgba(255, 255, 255, 0.82);
box-shadow: var(--shadow-liquid-control-hover);
}
.ghost {
min-width: auto;
color: var(--color-brand);
background: transparent;
}
.danger {
color: var(--color-danger);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.46), transparent),
var(--color-danger-bg);
border-color: rgba(229, 72, 77, 0.22);
}
.root:disabled {
color: var(--color-text-disabled);
background: rgba(255, 255, 255, 0.28);
border-color: rgba(255, 255, 255, 0.5);
box-shadow: none;
transform: none;
}

View File

@ -0,0 +1,35 @@
import { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import styles from "./Button.module.css";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
type ButtonSize = "sm" | "md" | "lg";
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
children: ReactNode;
isLoading?: boolean;
size?: ButtonSize;
variant?: ButtonVariant;
};
export function Button({
children,
className,
disabled,
isLoading = false,
size = "md",
type = "button",
variant = "secondary",
...props
}: ButtonProps) {
return (
<button
className={clsx(styles.root, styles[variant], styles[size], className)}
disabled={disabled || isLoading}
type={type}
{...props}
>
{isLoading ? "处理中" : children}
</button>
);
}

View File

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

View File

@ -0,0 +1,75 @@
.root {
position: relative;
isolation: isolate;
overflow: hidden;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-liquid-edge), var(--shadow-glass-md);
backdrop-filter: blur(34px) saturate(180%) brightness(1.08);
-webkit-backdrop-filter: blur(34px) saturate(180%) brightness(1.08);
transition:
border-color var(--duration-normal) var(--ease-standard),
box-shadow var(--duration-normal) var(--ease-standard),
transform var(--duration-normal) var(--ease-standard);
animation: liquid-pop var(--duration-page) var(--ease-liquid) both;
}
.root::before,
.root::after {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
border-radius: inherit;
content: "";
}
.root::before {
background:
radial-gradient(100% 70% at 18% 0%, rgba(255, 255, 255, 0.82), transparent 42%),
linear-gradient(135deg, rgba(255, 255, 255, 0.56), rgba(241, 248, 255, 0.3) 48%, rgba(255, 255, 255, 0.42));
}
.root::after {
inset: 1px;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.36), transparent 34%),
linear-gradient(315deg, rgba(68, 110, 158, 0.1), transparent 30%);
mix-blend-mode: screen;
transition: opacity var(--duration-normal) var(--ease-standard);
}
.root > * {
position: relative;
z-index: 1;
}
.default {
background: rgba(255, 255, 255, 0.34);
}
.strong {
background: rgba(255, 255, 255, 0.48);
box-shadow: var(--shadow-liquid-edge), var(--shadow-glass-lg);
}
.subtle {
background: rgba(255, 255, 255, 0.22);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -1px 0 rgba(90, 126, 168, 0.08);
}
.padded {
padding: var(--space-4);
}
.hoverable:hover {
border-color: rgba(255, 255, 255, 0.92);
box-shadow: var(--shadow-liquid-edge), var(--shadow-glass-lg);
transform: translateY(-2px);
}
.hoverable:hover::after {
opacity: 0.84;
}

View File

@ -0,0 +1,31 @@
import { ElementType, ReactNode } from "react";
import clsx from "clsx";
import styles from "./Card.module.css";
type CardVariant = "default" | "strong" | "subtle";
type CardProps = {
as?: ElementType;
children: ReactNode;
className?: string | undefined;
hoverable?: boolean;
padded?: boolean;
variant?: CardVariant;
};
export function Card({
as: Component = "section",
children,
className,
hoverable = false,
padded = true,
variant = "default",
}: CardProps) {
return (
<Component
className={clsx(styles.root, styles[variant], hoverable && styles.hoverable, padded && styles.padded, className)}
>
{children}
</Component>
);
}

View File

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

View File

@ -0,0 +1,151 @@
.root {
position: relative;
display: inline-flex;
min-height: 36px;
align-items: center;
gap: var(--space-2);
padding: 7px 10px 7px 8px;
border: 1px solid transparent;
border-radius: 999px;
color: var(--color-text-secondary);
cursor: pointer;
transition:
background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
}
.root::before {
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.62), rgba(241, 248, 255, 0.28)),
rgba(255, 255, 255, 0.2);
opacity: 0;
content: "";
transition: opacity var(--duration-fast) var(--ease-standard);
}
.root:hover:not(.disabled) {
color: var(--color-text-primary);
border-color: rgba(255, 255, 255, 0.72);
box-shadow: var(--shadow-liquid-control-hover);
}
.root:hover:not(.disabled)::before,
.root:focus-within::before {
opacity: 1;
}
.input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.control {
display: inline-flex;
width: 20px;
height: 20px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.74);
border-radius: 7px;
color: #fff;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.64), rgba(241, 248, 255, 0.24)),
rgba(255, 255, 255, 0.28);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.86),
inset 0 -1px 0 rgba(75, 108, 148, 0.1);
backdrop-filter: blur(18px) saturate(170%);
-webkit-backdrop-filter: blur(18px) saturate(170%);
transition:
background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.icon {
opacity: 0;
transform: scale(0.72);
transition:
opacity var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.content {
display: grid;
gap: 1px;
min-width: 0;
}
.label {
font-size: 13px;
font-weight: 800;
line-height: 18px;
}
.description {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.input:focus-visible + .control {
box-shadow: var(--shadow-focus);
}
.input:checked + .control {
border-color: rgba(47, 125, 246, 0.28);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.38), transparent 44%),
linear-gradient(180deg, #5fa2ff, var(--color-brand));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.62),
0 8px 18px rgba(47, 125, 246, 0.24);
animation: checkbox-check 180ms var(--ease-press);
}
.input:checked + .control .icon {
opacity: 1;
transform: scale(1);
}
.root:hover:not(.disabled) .control {
transform: translateY(-1px);
}
.root:active:not(.disabled) .control {
transform: scale(0.92);
}
.disabled {
color: var(--color-text-disabled);
cursor: not-allowed;
opacity: 0.62;
}
@keyframes checkbox-check {
0% {
transform: scale(0.86);
}
70% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}

View File

@ -0,0 +1,11 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Checkbox } from "./Checkbox";
describe("Checkbox", () => {
it("renders the label and checkbox input", () => {
render(<Checkbox label="确认发布" />);
expect(screen.getByLabelText("确认发布")).toHaveAttribute("type", "checkbox");
});
});

View File

@ -0,0 +1,24 @@
import { InputHTMLAttributes, ReactNode } from "react";
import { Check } from "lucide-react";
import clsx from "clsx";
import styles from "./Checkbox.module.css";
type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
description?: ReactNode;
label: ReactNode;
};
export function Checkbox({ className, description, disabled, label, ...props }: CheckboxProps) {
return (
<label className={clsx(styles.root, disabled && styles.disabled, className)}>
<input className={styles.input} data-liquid-hidden disabled={disabled} type="checkbox" {...props} />
<span className={styles.control} aria-hidden="true">
<Check className={styles.icon} size={14} strokeWidth={3} />
</span>
<span className={styles.content}>
<span className={styles.label}>{label}</span>
{description ? <span className={styles.description}>{description}</span> : null}
</span>
</label>
);
}

View File

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

View File

@ -0,0 +1,51 @@
.root {
display: grid;
justify-items: center;
gap: var(--space-2);
padding: var(--space-8);
color: var(--color-text-tertiary);
text-align: center;
animation: liquid-pop var(--duration-page) var(--ease-liquid) both;
}
.root strong {
color: var(--color-text-secondary);
font-size: 14px;
}
.root p {
max-width: 360px;
margin: 0;
font-size: 12px;
line-height: 18px;
}
.icon {
display: inline-flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.68);
border-radius: 16px;
color: var(--color-brand);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.68), rgba(226, 241, 255, 0.4)),
var(--color-brand-soft);
box-shadow: var(--shadow-liquid-control);
backdrop-filter: blur(18px) saturate(165%);
-webkit-backdrop-filter: blur(18px) saturate(165%);
animation: empty-icon-pop 260ms var(--ease-press) 90ms both;
}
@keyframes empty-icon-pop {
from {
opacity: 0;
transform: scale(0.82);
}
to {
opacity: 1;
transform: scale(1);
}
}

View File

@ -0,0 +1,18 @@
import { ReactNode } from "react";
import styles from "./EmptyState.module.css";
type EmptyStateProps = {
icon?: ReactNode;
title: string;
description?: string;
};
export function EmptyState({ description, icon, title }: EmptyStateProps) {
return (
<div className={styles.root}>
{icon ? <div className={styles.icon}>{icon}</div> : null}
<strong>{title}</strong>
{description ? <p>{description}</p> : null}
</div>
);
}

View File

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

View File

@ -0,0 +1,43 @@
.root {
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.66);
border-radius: 999px;
color: var(--color-text-secondary);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.58), rgba(241, 248, 255, 0.28)),
rgba(255, 255, 255, 0.28);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
inset 0 -1px 0 rgba(91, 125, 166, 0.08);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
transition:
background var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-press);
}
.root:hover:not(:disabled) {
color: var(--color-brand);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.72), rgba(226, 241, 255, 0.44)),
rgba(255, 255, 255, 0.42);
border-color: rgba(255, 255, 255, 0.82);
box-shadow: var(--shadow-liquid-control-hover);
transform: translateY(-1px);
}
.root:active:not(:disabled) {
transform: translateY(0) scale(0.94);
}
.root:disabled {
color: var(--color-text-disabled);
background: rgba(255, 255, 255, 0.28);
}

View File

@ -0,0 +1,16 @@
import { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import styles from "./IconButton.module.css";
type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
children: ReactNode;
label: string;
};
export function IconButton({ children, className, label, type = "button", ...props }: IconButtonProps) {
return (
<button aria-label={label} className={clsx(styles.root, className)} title={label} type={type} {...props}>
{children}
</button>
);
}

View File

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

View File

@ -0,0 +1,89 @@
.root {
display: flex;
min-height: 104px;
align-items: center;
gap: var(--space-4);
padding: var(--space-4);
border-radius: var(--radius-panel);
}
.icon {
position: relative;
overflow: hidden;
display: inline-flex;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
border-radius: 17px;
color: #fff;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
0 12px 26px rgba(46, 83, 126, 0.14);
transition:
box-shadow var(--duration-normal) var(--ease-standard),
transform var(--duration-normal) var(--ease-press);
}
.icon::before {
position: absolute;
inset: 0;
background:
radial-gradient(80% 70% at 28% 8%, rgba(255, 255, 255, 0.72), transparent 48%),
linear-gradient(145deg, rgba(255, 255, 255, 0.28), transparent 42%);
content: "";
}
.icon > svg {
position: relative;
z-index: 1;
transition: transform var(--duration-normal) var(--ease-press);
}
.root:hover .icon {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.68),
0 16px 30px rgba(46, 83, 126, 0.2);
transform: translateY(-1px) scale(1.04);
}
.root:hover .icon > svg {
transform: scale(1.08);
}
.blue {
background: linear-gradient(180deg, #62a0ff 0%, var(--color-brand) 100%);
}
.green {
background: linear-gradient(180deg, #49cb82 0%, var(--color-success) 100%);
}
.orange {
background: linear-gradient(180deg, #ffbd5a 0%, var(--color-warning) 100%);
}
.red {
background: linear-gradient(180deg, #ff7d7d 0%, var(--color-danger) 100%);
}
.content {
display: grid;
gap: 2px;
min-width: 0;
}
.label,
.meta {
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 600;
line-height: 18px;
}
.value {
color: var(--color-text-primary);
font-size: 28px;
font-weight: 800;
line-height: 36px;
}

View File

@ -0,0 +1,27 @@
import { ReactNode } from "react";
import clsx from "clsx";
import { Card } from "../Card";
import styles from "./MetricCard.module.css";
type MetricCardTone = "blue" | "green" | "orange" | "red";
type MetricCardProps = {
icon: ReactNode;
label: string;
value: ReactNode;
meta?: string;
tone?: MetricCardTone;
};
export function MetricCard({ icon, label, meta, tone = "blue", value }: MetricCardProps) {
return (
<Card className={styles.root} hoverable padded={false} variant="subtle">
<span className={clsx(styles.icon, styles[tone])}>{icon}</span>
<span className={styles.content}>
<span className={styles.label}>{label}</span>
<strong className={styles.value}>{value}</strong>
{meta ? <span className={styles.meta}>{meta}</span> : null}
</span>
</Card>
);
}

View File

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

View File

@ -0,0 +1,76 @@
.root {
display: grid;
min-width: 72px;
gap: 5px;
}
.value {
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 700;
line-height: 14px;
}
.track {
width: 72px;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: rgba(114, 151, 188, 0.16);
}
.bar {
position: relative;
overflow: hidden;
display: block;
height: 100%;
border-radius: inherit;
transform-origin: left center;
animation: progress-fill 420ms var(--ease-liquid) both;
transition: width var(--duration-slow) var(--ease-liquid);
}
.bar::after {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.62), transparent);
content: "";
transform: translateX(-100%);
animation: progress-sheen 1200ms var(--ease-standard) 280ms both;
}
.green {
background: var(--color-success);
}
.orange {
background: var(--color-warning);
}
.red {
background: var(--color-danger);
}
.blue {
background: var(--color-brand);
}
@keyframes progress-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes progress-sheen {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}

View File

@ -0,0 +1,23 @@
import clsx from "clsx";
import styles from "./ProgressMeter.module.css";
type ProgressMeterTone = "green" | "orange" | "red" | "blue";
type ProgressMeterProps = {
label: string;
value: number;
tone?: ProgressMeterTone;
};
export function ProgressMeter({ label, tone = "green", value }: ProgressMeterProps) {
const safeValue = Math.min(100, Math.max(0, value));
return (
<span className={styles.root} aria-label={`${label} ${safeValue}%`}>
<span className={styles.value}>{safeValue}%</span>
<span className={styles.track}>
<span className={clsx(styles.bar, styles[tone])} style={{ width: `${safeValue}%` }} />
</span>
</span>
);
}

View File

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

Some files were not shown because too many files have changed in this diff Show More