Add deploy platform initial project
This commit is contained in:
commit
29dcce8ca0
9
.dockerignore
Normal file
9
.dockerignore
Normal 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
8
.env.example
Normal 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
13
.gitignore
vendored
Normal 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
4
.prettierignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
package-lock.json
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal 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
18
Makefile
Normal 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
45
README.md
Normal 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
52
deploy/README.md
Normal 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
456
deploy/server.mjs
Normal 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}`);
|
||||||
|
});
|
||||||
52
deploy/standalone/README.md
Normal file
52
deploy/standalone/README.md
Normal 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` 挂载注入。
|
||||||
3
deploy/standalone/docker/deploy-platform.env.example
Normal file
3
deploy/standalone/docker/deploy-platform.env.example
Normal 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
|
||||||
17
deploy/standalone/systemd/hyapp-deploy-platform.service
Normal file
17
deploy/standalone/systemd/hyapp-deploy-platform.service
Normal 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
|
||||||
130
deploy/tencent-tat/README.md
Normal file
130
deploy/tencent-tat/README.md
Normal 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`。
|
||||||
703
deploy/tencent-tat/deploy_platform_tat_deploy.py
Normal file
703
deploy/tencent-tat/deploy_platform_tat_deploy.py
Normal 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:]))
|
||||||
199
deploy/tencent-tat/hyapp-services.inventory.prod.example.json
Normal file
199
deploy/tencent-tat/hyapp-services.inventory.prod.example.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
710
deploy/tencent-tat/hyapp_tat_deploy.py
Normal file
710
deploy/tencent-tat/hyapp_tat_deploy.py
Normal 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:]))
|
||||||
304
deploy/tencent-tat/hyapp_testbox_config.py
Executable file
304
deploy/tencent-tat/hyapp_testbox_config.py
Executable 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())
|
||||||
43
deploy/tencent-tat/inventory.prod.example.json
Normal file
43
deploy/tencent-tat/inventory.prod.example.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
825
docs/liquid-glass-admin-ui-guideline.md
Normal file
825
docs/liquid-glass-admin-ui-guideline.md
Normal 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。
|
||||||
|
- 表格单元格左右 padding:16px。
|
||||||
|
- 表单项垂直间距: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. 权限与审计。
|
||||||
|
|
||||||
|
判断一个页面是否符合规范,可以用三句话检查:
|
||||||
|
|
||||||
|
- 第一眼能不能看出当前环境和服务健康情况。
|
||||||
|
- 第二步能不能定位异常服务和关键指标。
|
||||||
|
- 第三步执行危险操作时,是否有明确影响范围、确认流程和审计记录。
|
||||||
921
docs/modular-development-guideline.md
Normal file
921
docs/modular-development-guideline.md
Normal 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 核心路径。
|
||||||
|
- 构建验证。
|
||||||
|
- 视觉检查。
|
||||||
902
docs/v1-development-scope.md
Normal file
902
docs/v1-development-scope.md
Normal 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
34
eslint.config.js
Normal 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
16
index.html
Normal 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
4595
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal 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
10
src/app/App.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { AppShell } from "../widgets/app-shell";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Outlet />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/app/providers/QueryProvider.tsx
Normal file
23
src/app/providers/QueryProvider.tsx
Normal 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
30
src/app/router.tsx
Normal 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 /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
31
src/app/store/useAppContextStore.ts
Normal file
31
src/app/store/useAppContextStore.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
140
src/app/styles/components.css
Normal file
140
src/app/styles/components.css
Normal 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
182
src/app/styles/layout.css
Normal 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
96
src/app/styles/tokens.css
Normal 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;
|
||||||
|
}
|
||||||
128
src/entities/deployment-plan/api/getDeployApiInventory.ts
Normal file
128
src/entities/deployment-plan/api/getDeployApiInventory.ts
Normal 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);
|
||||||
|
}
|
||||||
41
src/entities/deployment-plan/api/useDeployApiQueries.ts
Normal file
41
src/entities/deployment-plan/api/useDeployApiQueries.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
28
src/entities/deployment-plan/index.ts
Normal file
28
src/entities/deployment-plan/index.ts
Normal 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";
|
||||||
@ -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));
|
||||||
|
}
|
||||||
575
src/entities/deployment-plan/mock/hyappDeploymentPlan.ts
Normal file
575
src/entities/deployment-plan/mock/hyappDeploymentPlan.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
171
src/entities/deployment-plan/model/types.ts
Normal file
171
src/entities/deployment-plan/model/types.ts
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
367
src/entities/service/api/getServiceDetail.ts
Normal file
367
src/entities/service/api/getServiceDetail.ts
Normal 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;
|
||||||
|
}
|
||||||
68
src/entities/service/api/getServices.ts
Normal file
68
src/entities/service/api/getServices.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/entities/service/api/useServiceDetailQuery.ts
Normal file
14
src/entities/service/api/useServiceDetailQuery.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
14
src/entities/service/api/useServicesQuery.ts
Normal file
14
src/entities/service/api/useServicesQuery.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
19
src/entities/service/index.ts
Normal file
19
src/entities/service/index.ts
Normal 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";
|
||||||
31
src/entities/service/lib/getServiceSummary.ts
Normal file
31
src/entities/service/lib/getServiceSummary.ts
Normal 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 ?? "-",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/entities/service/mock/services.ts
Normal file
16
src/entities/service/mock/services.ts
Normal 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,
|
||||||
|
}));
|
||||||
91
src/entities/service/model/types.ts
Normal file
91
src/entities/service/model/types.ts
Normal 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;
|
||||||
|
};
|
||||||
56
src/features/release-service/api/createRelease.ts
Normal file
56
src/features/release-service/api/createRelease.ts
Normal 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;
|
||||||
|
}
|
||||||
14
src/features/release-service/api/useCreateReleaseMutation.ts
Normal file
14
src/features/release-service/api/useCreateReleaseMutation.ts
Normal 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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
6
src/features/release-service/index.ts
Normal file
6
src/features/release-service/index.ts
Normal 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";
|
||||||
45
src/features/release-service/lib/getReleasePrechecks.ts
Normal file
45
src/features/release-service/lib/getReleasePrechecks.ts
Normal 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}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
39
src/features/release-service/lib/getReleaseVersionOptions.ts
Normal file
39
src/features/release-service/lib/getReleaseVersionOptions.ts
Normal 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 },
|
||||||
|
];
|
||||||
|
}
|
||||||
27
src/features/release-service/model/types.ts
Normal file
27
src/features/release-service/model/types.ts
Normal 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;
|
||||||
|
};
|
||||||
14
src/features/release-service/model/useReleaseDrawerStore.ts
Normal file
14
src/features/release-service/model/useReleaseDrawerStore.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
385
src/features/release-service/ui/ReleaseDrawer.module.css
Normal file
385
src/features/release-service/ui/ReleaseDrawer.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
264
src/features/release-service/ui/ReleaseDrawer.tsx
Normal file
264
src/features/release-service/ui/ReleaseDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/features/release-service/ui/ReleaseLayer.tsx
Normal file
18
src/features/release-service/ui/ReleaseLayer.tsx
Normal 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
16
src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
5
src/pages/audit/AuditPage.tsx
Normal file
5
src/pages/audit/AuditPage.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { PlaceholderPage } from "../placeholder";
|
||||||
|
|
||||||
|
export function AuditPage() {
|
||||||
|
return <PlaceholderPage title="权限与审计" />;
|
||||||
|
}
|
||||||
1
src/pages/audit/index.ts
Normal file
1
src/pages/audit/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AuditPage } from "./AuditPage";
|
||||||
5
src/pages/logs-events/LogsEventsPage.tsx
Normal file
5
src/pages/logs-events/LogsEventsPage.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { PlaceholderPage } from "../placeholder";
|
||||||
|
|
||||||
|
export function LogsEventsPage() {
|
||||||
|
return <PlaceholderPage title="日志与事件" />;
|
||||||
|
}
|
||||||
1
src/pages/logs-events/index.ts
Normal file
1
src/pages/logs-events/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { LogsEventsPage } from "./LogsEventsPage";
|
||||||
57
src/pages/overview/OverviewPage.module.css
Normal file
57
src/pages/overview/OverviewPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
88
src/pages/overview/OverviewPage.tsx
Normal file
88
src/pages/overview/OverviewPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/pages/overview/index.ts
Normal file
1
src/pages/overview/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { OverviewPage } from "./OverviewPage";
|
||||||
23
src/pages/placeholder/PlaceholderPage.tsx
Normal file
23
src/pages/placeholder/PlaceholderPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/pages/placeholder/index.ts
Normal file
1
src/pages/placeholder/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { PlaceholderPage } from "./PlaceholderPage";
|
||||||
630
src/pages/releases/ReleasesPage.module.css
Normal file
630
src/pages/releases/ReleasesPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
587
src/pages/releases/ReleasesPage.tsx
Normal file
587
src/pages/releases/ReleasesPage.tsx
Normal 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} 的 Compose、systemd 和 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 unit、CLB 绑定和实例分布</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/pages/releases/index.ts
Normal file
1
src/pages/releases/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ReleasesPage } from "./ReleasesPage";
|
||||||
5
src/pages/resources/ResourcesPage.tsx
Normal file
5
src/pages/resources/ResourcesPage.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { PlaceholderPage } from "../placeholder";
|
||||||
|
|
||||||
|
export function ResourcesPage() {
|
||||||
|
return <PlaceholderPage title="数据漫游" />;
|
||||||
|
}
|
||||||
1
src/pages/resources/index.ts
Normal file
1
src/pages/resources/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ResourcesPage } from "./ResourcesPage";
|
||||||
783
src/pages/service-detail/ServiceDetailPage.module.css
Normal file
783
src/pages/service-detail/ServiceDetailPage.module.css
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
762
src/pages/service-detail/ServiceDetailPage.tsx
Normal file
762
src/pages/service-detail/ServiceDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/pages/service-detail/index.ts
Normal file
1
src/pages/service-detail/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ServiceDetailPage } from "./ServiceDetailPage";
|
||||||
21
src/pages/services/ServicesPage.module.css
Normal file
21
src/pages/services/ServicesPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
49
src/pages/services/ServicesPage.tsx
Normal file
49
src/pages/services/ServicesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/pages/services/index.ts
Normal file
1
src/pages/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ServicesPage } from "./ServicesPage";
|
||||||
10
src/shared/api/queryKeys.ts
Normal file
10
src/shared/api/queryKeys.ts
Normal 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,
|
||||||
|
};
|
||||||
108
src/shared/ui/Button/Button.module.css
Normal file
108
src/shared/ui/Button/Button.module.css
Normal 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;
|
||||||
|
}
|
||||||
35
src/shared/ui/Button/Button.tsx
Normal file
35
src/shared/ui/Button/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/shared/ui/Button/index.ts
Normal file
1
src/shared/ui/Button/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { Button } from "./Button";
|
||||||
75
src/shared/ui/Card/Card.module.css
Normal file
75
src/shared/ui/Card/Card.module.css
Normal 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;
|
||||||
|
}
|
||||||
31
src/shared/ui/Card/Card.tsx
Normal file
31
src/shared/ui/Card/Card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/shared/ui/Card/index.ts
Normal file
1
src/shared/ui/Card/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { Card } from "./Card";
|
||||||
151
src/shared/ui/Checkbox/Checkbox.module.css
Normal file
151
src/shared/ui/Checkbox/Checkbox.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/shared/ui/Checkbox/Checkbox.test.tsx
Normal file
11
src/shared/ui/Checkbox/Checkbox.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
24
src/shared/ui/Checkbox/Checkbox.tsx
Normal file
24
src/shared/ui/Checkbox/Checkbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/shared/ui/Checkbox/index.ts
Normal file
1
src/shared/ui/Checkbox/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { Checkbox } from "./Checkbox";
|
||||||
51
src/shared/ui/EmptyState/EmptyState.module.css
Normal file
51
src/shared/ui/EmptyState/EmptyState.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/shared/ui/EmptyState/EmptyState.tsx
Normal file
18
src/shared/ui/EmptyState/EmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/shared/ui/EmptyState/index.ts
Normal file
1
src/shared/ui/EmptyState/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { EmptyState } from "./EmptyState";
|
||||||
43
src/shared/ui/IconButton/IconButton.module.css
Normal file
43
src/shared/ui/IconButton/IconButton.module.css
Normal 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);
|
||||||
|
}
|
||||||
16
src/shared/ui/IconButton/IconButton.tsx
Normal file
16
src/shared/ui/IconButton/IconButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/shared/ui/IconButton/index.ts
Normal file
1
src/shared/ui/IconButton/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { IconButton } from "./IconButton";
|
||||||
89
src/shared/ui/MetricCard/MetricCard.module.css
Normal file
89
src/shared/ui/MetricCard/MetricCard.module.css
Normal 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;
|
||||||
|
}
|
||||||
27
src/shared/ui/MetricCard/MetricCard.tsx
Normal file
27
src/shared/ui/MetricCard/MetricCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/shared/ui/MetricCard/index.ts
Normal file
1
src/shared/ui/MetricCard/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { MetricCard } from "./MetricCard";
|
||||||
76
src/shared/ui/ProgressMeter/ProgressMeter.module.css
Normal file
76
src/shared/ui/ProgressMeter/ProgressMeter.module.css
Normal 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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/shared/ui/ProgressMeter/ProgressMeter.tsx
Normal file
23
src/shared/ui/ProgressMeter/ProgressMeter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/shared/ui/ProgressMeter/index.ts
Normal file
1
src/shared/ui/ProgressMeter/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ProgressMeter } from "./ProgressMeter";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user