commit 29dcce8ca00c5ef3418e3d1d80e556c4fddfee1a Author: zhx Date: Wed May 20 16:49:19 2026 +0800 Add deploy platform initial project diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a49e52a --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9719b2b --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c90dd4 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..68f9def --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +node_modules +dist +coverage +package-lock.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae044df --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8a09530 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfaab76 --- /dev/null +++ b/README.md @@ -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`。 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..1244cdb --- /dev/null +++ b/deploy/README.md @@ -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` diff --git a/deploy/server.mjs b/deploy/server.mjs new file mode 100644 index 0000000..8b54406 --- /dev/null +++ b/deploy/server.mjs @@ -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}`); +}); diff --git a/deploy/standalone/README.md b/deploy/standalone/README.md new file mode 100644 index 0000000..c249019 --- /dev/null +++ b/deploy/standalone/README.md @@ -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` 挂载注入。 diff --git a/deploy/standalone/docker/deploy-platform.env.example b/deploy/standalone/docker/deploy-platform.env.example new file mode 100644 index 0000000..d34b545 --- /dev/null +++ b/deploy/standalone/docker/deploy-platform.env.example @@ -0,0 +1,3 @@ +IMAGE=10.2.1.3:18082/hyapp/deploy-platform:REPLACE_TAG +CONTAINER_NAME=hyapp-deploy-platform +STOP_TIMEOUT_SEC=15 diff --git a/deploy/standalone/systemd/hyapp-deploy-platform.service b/deploy/standalone/systemd/hyapp-deploy-platform.service new file mode 100644 index 0000000..3051493 --- /dev/null +++ b/deploy/standalone/systemd/hyapp-deploy-platform.service @@ -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 diff --git a/deploy/tencent-tat/README.md b/deploy/tencent-tat/README.md new file mode 100644 index 0000000..1624ed9 --- /dev/null +++ b/deploy/tencent-tat/README.md @@ -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`。 diff --git a/deploy/tencent-tat/deploy_platform_tat_deploy.py b/deploy/tencent-tat/deploy_platform_tat_deploy.py new file mode 100644 index 0000000..026cafa --- /dev/null +++ b/deploy/tencent-tat/deploy_platform_tat_deploy.py @@ -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:])) diff --git a/deploy/tencent-tat/hyapp-services.inventory.prod.example.json b/deploy/tencent-tat/hyapp-services.inventory.prod.example.json new file mode 100644 index 0000000..53aec04 --- /dev/null +++ b/deploy/tencent-tat/hyapp-services.inventory.prod.example.json @@ -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 + } + } + } +} diff --git a/deploy/tencent-tat/hyapp_tat_deploy.py b/deploy/tencent-tat/hyapp_tat_deploy.py new file mode 100644 index 0000000..488cdec --- /dev/null +++ b/deploy/tencent-tat/hyapp_tat_deploy.py @@ -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:])) diff --git a/deploy/tencent-tat/hyapp_testbox_config.py b/deploy/tencent-tat/hyapp_testbox_config.py new file mode 100755 index 0000000..98fc2a2 --- /dev/null +++ b/deploy/tencent-tat/hyapp_testbox_config.py @@ -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()) diff --git a/deploy/tencent-tat/inventory.prod.example.json b/deploy/tencent-tat/inventory.prod.example.json new file mode 100644 index 0000000..fab1573 --- /dev/null +++ b/deploy/tencent-tat/inventory.prod.example.json @@ -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 + } + } + } +} diff --git a/docs/liquid-glass-admin-ui-guideline.md b/docs/liquid-glass-admin-ui-guideline.md new file mode 100644 index 0000000..e928d75 --- /dev/null +++ b/docs/liquid-glass-admin-ui-guideline.md @@ -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. 权限与审计。 + +判断一个页面是否符合规范,可以用三句话检查: + +- 第一眼能不能看出当前环境和服务健康情况。 +- 第二步能不能定位异常服务和关键指标。 +- 第三步执行危险操作时,是否有明确影响范围、确认流程和审计记录。 diff --git a/docs/modular-development-guideline.md b/docs/modular-development-guideline.md new file mode 100644 index 0000000..bc90b27 --- /dev/null +++ b/docs/modular-development-guideline.md @@ -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 ( + + + + + ); +} +``` + +## 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 核心路径。 +- 构建验证。 +- 视觉检查。 diff --git a/docs/v1-development-scope.md b/docs/v1-development-scope.md new file mode 100644 index 0000000..93e831f --- /dev/null +++ b/docs/v1-development-scope.md @@ -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 或其他监控数据。 +- 增加灰度发布策略。 +- 增加回滚流程。 +- 增加审批流。 +- 增加数据库备份恢复。 +- 增加告警规则管理。 +- 增加团队和权限模型。 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..b374d5d --- /dev/null +++ b/eslint.config.js @@ -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, + }, + }, +); diff --git a/index.html b/index.html new file mode 100644 index 0000000..0774b32 --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + + 项目运维台 + + + +
+ + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c953d87 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4595 @@ +{ + "name": "deploy-platform", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "deploy-platform", + "version": "0.1.0", + "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" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.355", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.355.tgz", + "integrity": "sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.475.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz", + "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..514f08b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..f8ec104 --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "react-router-dom"; +import { AppShell } from "../widgets/app-shell"; + +export function App() { + return ( + + + + ); +} diff --git a/src/app/providers/QueryProvider.tsx b/src/app/providers/QueryProvider.tsx new file mode 100644 index 0000000..5c21d19 --- /dev/null +++ b/src/app/providers/QueryProvider.tsx @@ -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 {children}; +} diff --git a/src/app/router.tsx b/src/app/router.tsx new file mode 100644 index 0000000..043ba6a --- /dev/null +++ b/src/app/router.tsx @@ -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: , + children: [ + { index: true, element: }, + { path: "overview", element: }, + { path: "services", element: }, + { path: "services/:serviceId", element: }, + { path: "releases", element: }, + { path: "resources", element: }, + { path: "logs-events", element: }, + { path: "audit", element: }, + { path: "settings", element: }, + { path: "alerts", element: }, + { path: "*", element: }, + ], + }, +]); diff --git a/src/app/store/useAppContextStore.ts b/src/app/store/useAppContextStore.ts new file mode 100644 index 0000000..351270e --- /dev/null +++ b/src/app/store/useAppContextStore.ts @@ -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((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 }), +})); diff --git a/src/app/styles/components.css b/src/app/styles/components.css new file mode 100644 index 0000000..c15c0fb --- /dev/null +++ b/src/app/styles/components.css @@ -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); + } +} diff --git a/src/app/styles/layout.css b/src/app/styles/layout.css new file mode 100644 index 0000000..3774fe6 --- /dev/null +++ b/src/app/styles/layout.css @@ -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; + } +} diff --git a/src/app/styles/tokens.css b/src/app/styles/tokens.css new file mode 100644 index 0000000..220e84b --- /dev/null +++ b/src/app/styles/tokens.css @@ -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; +} diff --git a/src/entities/deployment-plan/api/getDeployApiInventory.ts b/src/entities/deployment-plan/api/getDeployApiInventory.ts new file mode 100644 index 0000000..61a43b3 --- /dev/null +++ b/src/entities/deployment-plan/api/getDeployApiInventory.ts @@ -0,0 +1,128 @@ +export type DeployApiInventoryResponse = { + hosts: Record; + managedDependencies: Record; + ok: boolean; + region: string; + registry: string; + services: Record; +}; + +async function readJsonResponse(response: Response): Promise { + 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 { + const response = await fetch("/deploy/api/hyapp/inventory", { + headers: { + accept: "application/json", + }, + }); + + return readJsonResponse(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 { + const params = new URLSearchParams({ + services: services.join(","), + }); + const response = await fetch(`/deploy/api/hyapp/plan?${params.toString()}`, { + headers: { + accept: "application/json", + }, + }); + + return readJsonResponse(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 { + 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(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 { + const params = new URLSearchParams({ + services: services.join(","), + }); + const response = await fetch(`/deploy/api/hyapp/testbox/configs?${params.toString()}`, { + headers: { + accept: "application/json", + }, + }); + + return readJsonResponse(response); +} diff --git a/src/entities/deployment-plan/api/useDeployApiQueries.ts b/src/entities/deployment-plan/api/useDeployApiQueries.ts new file mode 100644 index 0000000..00abfbf --- /dev/null +++ b/src/entities/deployment-plan/api/useDeployApiQueries.ts @@ -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, + }); +} diff --git a/src/entities/deployment-plan/index.ts b/src/entities/deployment-plan/index.ts new file mode 100644 index 0000000..8325d69 --- /dev/null +++ b/src/entities/deployment-plan/index.ts @@ -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"; diff --git a/src/entities/deployment-plan/lib/getHyappDeploymentService.ts b/src/entities/deployment-plan/lib/getHyappDeploymentService.ts new file mode 100644 index 0000000..49441eb --- /dev/null +++ b/src/entities/deployment-plan/lib/getHyappDeploymentService.ts @@ -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 => Boolean(host)); +} diff --git a/src/entities/deployment-plan/mock/hyappDeploymentPlan.ts b/src/entities/deployment-plan/mock/hyappDeploymentPlan.ts new file mode 100644 index 0000000..d24a223 --- /dev/null +++ b/src/entities/deployment-plan/mock/hyappDeploymentPlan.ts @@ -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, + }, +}; diff --git a/src/entities/deployment-plan/model/types.ts b/src/entities/deployment-plan/model/types.ts new file mode 100644 index 0000000..9a7bfdc --- /dev/null +++ b/src/entities/deployment-plan/model/types.ts @@ -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; + 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; + }; +}; diff --git a/src/entities/service/api/getServiceDetail.ts b/src/entities/service/api/getServiceDetail.ts new file mode 100644 index 0000000..84b7ab3 --- /dev/null +++ b/src/entities/service/api/getServiceDetail.ts @@ -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 = { + 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 { + await new Promise((resolve) => + globalThis.setTimeout(resolve, MOCK_LATENCY_MS), + ); + + const service = mockServices.find((item) => item.id === serviceId); + + return service ? createServiceDetail(service) : null; +} diff --git a/src/entities/service/api/getServices.ts b/src/entities/service/api/getServices.ts new file mode 100644 index 0000000..cf2a81d --- /dev/null +++ b/src/entities/service/api/getServices.ts @@ -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; +}; + +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 { + 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, + }; +} diff --git a/src/entities/service/api/useServiceDetailQuery.ts b/src/entities/service/api/useServiceDetailQuery.ts new file mode 100644 index 0000000..7ad5dd7 --- /dev/null +++ b/src/entities/service/api/useServiceDetailQuery.ts @@ -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, + }); +} diff --git a/src/entities/service/api/useServicesQuery.ts b/src/entities/service/api/useServicesQuery.ts new file mode 100644 index 0000000..f5e69ff --- /dev/null +++ b/src/entities/service/api/useServicesQuery.ts @@ -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, + }); +} diff --git a/src/entities/service/index.ts b/src/entities/service/index.ts new file mode 100644 index 0000000..a513d90 --- /dev/null +++ b/src/entities/service/index.ts @@ -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"; diff --git a/src/entities/service/lib/getServiceSummary.ts b/src/entities/service/lib/getServiceSummary.ts new file mode 100644 index 0000000..4cf24a2 --- /dev/null +++ b/src/entities/service/lib/getServiceSummary.ts @@ -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 ?? "-", + }, + }; +} diff --git a/src/entities/service/mock/services.ts b/src/entities/service/mock/services.ts new file mode 100644 index 0000000..b014387 --- /dev/null +++ b/src/entities/service/mock/services.ts @@ -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, +})); diff --git a/src/entities/service/model/types.ts b/src/entities/service/model/types.ts new file mode 100644 index 0000000..f2c7e8c --- /dev/null +++ b/src/entities/service/model/types.ts @@ -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; +}; diff --git a/src/features/release-service/api/createRelease.ts b/src/features/release-service/api/createRelease.ts new file mode 100644 index 0000000..56f7f80 --- /dev/null +++ b/src/features/release-service/api/createRelease.ts @@ -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 { + 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; +} diff --git a/src/features/release-service/api/useCreateReleaseMutation.ts b/src/features/release-service/api/useCreateReleaseMutation.ts new file mode 100644 index 0000000..29720ad --- /dev/null +++ b/src/features/release-service/api/useCreateReleaseMutation.ts @@ -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"] }); + }, + }); +} diff --git a/src/features/release-service/index.ts b/src/features/release-service/index.ts new file mode 100644 index 0000000..819bb2d --- /dev/null +++ b/src/features/release-service/index.ts @@ -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"; diff --git a/src/features/release-service/lib/getReleasePrechecks.ts b/src/features/release-service/lib/getReleasePrechecks.ts new file mode 100644 index 0000000..5dafb0e --- /dev/null +++ b/src/features/release-service/lib/getReleasePrechecks.ts @@ -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}`, + }, + ]; +} diff --git a/src/features/release-service/lib/getReleaseVersionOptions.ts b/src/features/release-service/lib/getReleaseVersionOptions.ts new file mode 100644 index 0000000..868cccb --- /dev/null +++ b/src/features/release-service/lib/getReleaseVersionOptions.ts @@ -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 }, + ]; +} diff --git a/src/features/release-service/model/types.ts b/src/features/release-service/model/types.ts new file mode 100644 index 0000000..f7305de --- /dev/null +++ b/src/features/release-service/model/types.ts @@ -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; +}; diff --git a/src/features/release-service/model/useReleaseDrawerStore.ts b/src/features/release-service/model/useReleaseDrawerStore.ts new file mode 100644 index 0000000..c140e57 --- /dev/null +++ b/src/features/release-service/model/useReleaseDrawerStore.ts @@ -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((set) => ({ + service: null, + closeRelease: () => set({ service: null }), + openRelease: (service) => set({ service }), +})); diff --git a/src/features/release-service/ui/ReleaseDrawer.module.css b/src/features/release-service/ui/ReleaseDrawer.module.css new file mode 100644 index 0000000..6c8c510 --- /dev/null +++ b/src/features/release-service/ui/ReleaseDrawer.module.css @@ -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); + } +} diff --git a/src/features/release-service/ui/ReleaseDrawer.tsx b/src/features/release-service/ui/ReleaseDrawer.tsx new file mode 100644 index 0000000..5d02ab9 --- /dev/null +++ b/src/features/release-service/ui/ReleaseDrawer.tsx @@ -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("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 ( +
+