From 29dcce8ca00c5ef3418e3d1d80e556c4fddfee1a Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 20 May 2026 16:49:19 +0800 Subject: [PATCH] Add deploy platform initial project --- .dockerignore | 9 + .env.example | 8 + .gitignore | 13 + .prettierignore | 4 + Dockerfile | 27 + Makefile | 18 + README.md | 45 + deploy/README.md | 52 + deploy/server.mjs | 456 ++ deploy/standalone/README.md | 52 + .../docker/deploy-platform.env.example | 3 + .../systemd/hyapp-deploy-platform.service | 17 + deploy/tencent-tat/README.md | 130 + .../tencent-tat/deploy_platform_tat_deploy.py | 703 +++ ...hyapp-services.inventory.prod.example.json | 199 + deploy/tencent-tat/hyapp_tat_deploy.py | 710 +++ deploy/tencent-tat/hyapp_testbox_config.py | 304 ++ .../tencent-tat/inventory.prod.example.json | 43 + docs/liquid-glass-admin-ui-guideline.md | 825 +++ docs/modular-development-guideline.md | 921 ++++ docs/v1-development-scope.md | 902 ++++ eslint.config.js | 34 + index.html | 16 + package-lock.json | 4595 +++++++++++++++++ package.json | 45 + src/app/App.tsx | 10 + src/app/providers/QueryProvider.tsx | 23 + src/app/router.tsx | 30 + src/app/store/useAppContextStore.ts | 31 + src/app/styles/components.css | 140 + src/app/styles/layout.css | 182 + src/app/styles/tokens.css | 96 + .../api/getDeployApiInventory.ts | 128 + .../api/useDeployApiQueries.ts | 41 + src/entities/deployment-plan/index.ts | 28 + .../lib/getHyappDeploymentService.ts | 24 + .../mock/hyappDeploymentPlan.ts | 575 +++ src/entities/deployment-plan/model/types.ts | 171 + src/entities/service/api/getServiceDetail.ts | 367 ++ src/entities/service/api/getServices.ts | 68 + .../service/api/useServiceDetailQuery.ts | 14 + src/entities/service/api/useServicesQuery.ts | 14 + src/entities/service/index.ts | 19 + src/entities/service/lib/getServiceSummary.ts | 31 + src/entities/service/mock/services.ts | 16 + src/entities/service/model/types.ts | 91 + .../release-service/api/createRelease.ts | 56 + .../api/useCreateReleaseMutation.ts | 14 + src/features/release-service/index.ts | 6 + .../lib/getReleasePrechecks.ts | 45 + .../lib/getReleaseVersionOptions.ts | 39 + src/features/release-service/model/types.ts | 27 + .../model/useReleaseDrawerStore.ts | 14 + .../ui/ReleaseDrawer.module.css | 385 ++ .../release-service/ui/ReleaseDrawer.tsx | 264 + .../release-service/ui/ReleaseLayer.tsx | 18 + src/main.tsx | 16 + src/pages/audit/AuditPage.tsx | 5 + src/pages/audit/index.ts | 1 + src/pages/logs-events/LogsEventsPage.tsx | 5 + src/pages/logs-events/index.ts | 1 + src/pages/overview/OverviewPage.module.css | 57 + src/pages/overview/OverviewPage.tsx | 88 + src/pages/overview/index.ts | 1 + src/pages/placeholder/PlaceholderPage.tsx | 23 + src/pages/placeholder/index.ts | 1 + src/pages/releases/ReleasesPage.module.css | 630 +++ src/pages/releases/ReleasesPage.tsx | 587 +++ src/pages/releases/index.ts | 1 + src/pages/resources/ResourcesPage.tsx | 5 + src/pages/resources/index.ts | 1 + .../ServiceDetailPage.module.css | 783 +++ .../service-detail/ServiceDetailPage.tsx | 762 +++ src/pages/service-detail/index.ts | 1 + src/pages/services/ServicesPage.module.css | 21 + src/pages/services/ServicesPage.tsx | 49 + src/pages/services/index.ts | 1 + src/shared/api/queryKeys.ts | 10 + src/shared/ui/Button/Button.module.css | 108 + src/shared/ui/Button/Button.tsx | 35 + src/shared/ui/Button/index.ts | 1 + src/shared/ui/Card/Card.module.css | 75 + src/shared/ui/Card/Card.tsx | 31 + src/shared/ui/Card/index.ts | 1 + src/shared/ui/Checkbox/Checkbox.module.css | 151 + src/shared/ui/Checkbox/Checkbox.test.tsx | 11 + src/shared/ui/Checkbox/Checkbox.tsx | 24 + src/shared/ui/Checkbox/index.ts | 1 + .../ui/EmptyState/EmptyState.module.css | 51 + src/shared/ui/EmptyState/EmptyState.tsx | 18 + src/shared/ui/EmptyState/index.ts | 1 + .../ui/IconButton/IconButton.module.css | 43 + src/shared/ui/IconButton/IconButton.tsx | 16 + src/shared/ui/IconButton/index.ts | 1 + .../ui/MetricCard/MetricCard.module.css | 89 + src/shared/ui/MetricCard/MetricCard.tsx | 27 + src/shared/ui/MetricCard/index.ts | 1 + .../ui/ProgressMeter/ProgressMeter.module.css | 76 + src/shared/ui/ProgressMeter/ProgressMeter.tsx | 23 + src/shared/ui/ProgressMeter/index.ts | 1 + src/shared/ui/Radio/Radio.module.css | 154 + src/shared/ui/Radio/Radio.test.tsx | 11 + src/shared/ui/Radio/Radio.tsx | 23 + src/shared/ui/Radio/index.ts | 1 + src/shared/ui/Select/Select.module.css | 192 + src/shared/ui/Select/Select.test.tsx | 26 + src/shared/ui/Select/Select.tsx | 137 + src/shared/ui/Select/index.ts | 1 + .../ui/StatusBadge/StatusBadge.module.css | 103 + .../ui/StatusBadge/StatusBadge.test.tsx | 11 + src/shared/ui/StatusBadge/StatusBadge.tsx | 25 + src/shared/ui/StatusBadge/index.ts | 2 + src/shared/ui/Table/Table.module.css | 93 + src/shared/ui/Table/Table.tsx | 55 + src/shared/ui/Table/index.ts | 2 + src/shared/ui/Tag/Tag.module.css | 111 + src/shared/ui/Tag/Tag.test.tsx | 11 + src/shared/ui/Tag/Tag.tsx | 28 + src/shared/ui/Tag/index.ts | 1 + src/test/setup.ts | 1 + src/vite-env.d.ts | 1 + src/widgets/app-shell/AppShell.module.css | 322 ++ src/widgets/app-shell/AppShell.tsx | 67 + src/widgets/app-shell/index.ts | 1 + .../service-table/ServiceTable.module.css | 28 + src/widgets/service-table/ServiceTable.tsx | 172 + src/widgets/service-table/index.ts | 1 + .../top-context-bar/TopContextBar.module.css | 154 + src/widgets/top-context-bar/TopContextBar.tsx | 85 + src/widgets/top-context-bar/index.ts | 1 + tsconfig.app.json | 22 + tsconfig.json | 4 + tsconfig.node.json | 15 + vite.config.ts | 16 + 134 files changed, 18809 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 deploy/README.md create mode 100644 deploy/server.mjs create mode 100644 deploy/standalone/README.md create mode 100644 deploy/standalone/docker/deploy-platform.env.example create mode 100644 deploy/standalone/systemd/hyapp-deploy-platform.service create mode 100644 deploy/tencent-tat/README.md create mode 100644 deploy/tencent-tat/deploy_platform_tat_deploy.py create mode 100644 deploy/tencent-tat/hyapp-services.inventory.prod.example.json create mode 100644 deploy/tencent-tat/hyapp_tat_deploy.py create mode 100755 deploy/tencent-tat/hyapp_testbox_config.py create mode 100644 deploy/tencent-tat/inventory.prod.example.json create mode 100644 docs/liquid-glass-admin-ui-guideline.md create mode 100644 docs/modular-development-guideline.md create mode 100644 docs/v1-development-scope.md create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app/App.tsx create mode 100644 src/app/providers/QueryProvider.tsx create mode 100644 src/app/router.tsx create mode 100644 src/app/store/useAppContextStore.ts create mode 100644 src/app/styles/components.css create mode 100644 src/app/styles/layout.css create mode 100644 src/app/styles/tokens.css create mode 100644 src/entities/deployment-plan/api/getDeployApiInventory.ts create mode 100644 src/entities/deployment-plan/api/useDeployApiQueries.ts create mode 100644 src/entities/deployment-plan/index.ts create mode 100644 src/entities/deployment-plan/lib/getHyappDeploymentService.ts create mode 100644 src/entities/deployment-plan/mock/hyappDeploymentPlan.ts create mode 100644 src/entities/deployment-plan/model/types.ts create mode 100644 src/entities/service/api/getServiceDetail.ts create mode 100644 src/entities/service/api/getServices.ts create mode 100644 src/entities/service/api/useServiceDetailQuery.ts create mode 100644 src/entities/service/api/useServicesQuery.ts create mode 100644 src/entities/service/index.ts create mode 100644 src/entities/service/lib/getServiceSummary.ts create mode 100644 src/entities/service/mock/services.ts create mode 100644 src/entities/service/model/types.ts create mode 100644 src/features/release-service/api/createRelease.ts create mode 100644 src/features/release-service/api/useCreateReleaseMutation.ts create mode 100644 src/features/release-service/index.ts create mode 100644 src/features/release-service/lib/getReleasePrechecks.ts create mode 100644 src/features/release-service/lib/getReleaseVersionOptions.ts create mode 100644 src/features/release-service/model/types.ts create mode 100644 src/features/release-service/model/useReleaseDrawerStore.ts create mode 100644 src/features/release-service/ui/ReleaseDrawer.module.css create mode 100644 src/features/release-service/ui/ReleaseDrawer.tsx create mode 100644 src/features/release-service/ui/ReleaseLayer.tsx create mode 100644 src/main.tsx create mode 100644 src/pages/audit/AuditPage.tsx create mode 100644 src/pages/audit/index.ts create mode 100644 src/pages/logs-events/LogsEventsPage.tsx create mode 100644 src/pages/logs-events/index.ts create mode 100644 src/pages/overview/OverviewPage.module.css create mode 100644 src/pages/overview/OverviewPage.tsx create mode 100644 src/pages/overview/index.ts create mode 100644 src/pages/placeholder/PlaceholderPage.tsx create mode 100644 src/pages/placeholder/index.ts create mode 100644 src/pages/releases/ReleasesPage.module.css create mode 100644 src/pages/releases/ReleasesPage.tsx create mode 100644 src/pages/releases/index.ts create mode 100644 src/pages/resources/ResourcesPage.tsx create mode 100644 src/pages/resources/index.ts create mode 100644 src/pages/service-detail/ServiceDetailPage.module.css create mode 100644 src/pages/service-detail/ServiceDetailPage.tsx create mode 100644 src/pages/service-detail/index.ts create mode 100644 src/pages/services/ServicesPage.module.css create mode 100644 src/pages/services/ServicesPage.tsx create mode 100644 src/pages/services/index.ts create mode 100644 src/shared/api/queryKeys.ts create mode 100644 src/shared/ui/Button/Button.module.css create mode 100644 src/shared/ui/Button/Button.tsx create mode 100644 src/shared/ui/Button/index.ts create mode 100644 src/shared/ui/Card/Card.module.css create mode 100644 src/shared/ui/Card/Card.tsx create mode 100644 src/shared/ui/Card/index.ts create mode 100644 src/shared/ui/Checkbox/Checkbox.module.css create mode 100644 src/shared/ui/Checkbox/Checkbox.test.tsx create mode 100644 src/shared/ui/Checkbox/Checkbox.tsx create mode 100644 src/shared/ui/Checkbox/index.ts create mode 100644 src/shared/ui/EmptyState/EmptyState.module.css create mode 100644 src/shared/ui/EmptyState/EmptyState.tsx create mode 100644 src/shared/ui/EmptyState/index.ts create mode 100644 src/shared/ui/IconButton/IconButton.module.css create mode 100644 src/shared/ui/IconButton/IconButton.tsx create mode 100644 src/shared/ui/IconButton/index.ts create mode 100644 src/shared/ui/MetricCard/MetricCard.module.css create mode 100644 src/shared/ui/MetricCard/MetricCard.tsx create mode 100644 src/shared/ui/MetricCard/index.ts create mode 100644 src/shared/ui/ProgressMeter/ProgressMeter.module.css create mode 100644 src/shared/ui/ProgressMeter/ProgressMeter.tsx create mode 100644 src/shared/ui/ProgressMeter/index.ts create mode 100644 src/shared/ui/Radio/Radio.module.css create mode 100644 src/shared/ui/Radio/Radio.test.tsx create mode 100644 src/shared/ui/Radio/Radio.tsx create mode 100644 src/shared/ui/Radio/index.ts create mode 100644 src/shared/ui/Select/Select.module.css create mode 100644 src/shared/ui/Select/Select.test.tsx create mode 100644 src/shared/ui/Select/Select.tsx create mode 100644 src/shared/ui/Select/index.ts create mode 100644 src/shared/ui/StatusBadge/StatusBadge.module.css create mode 100644 src/shared/ui/StatusBadge/StatusBadge.test.tsx create mode 100644 src/shared/ui/StatusBadge/StatusBadge.tsx create mode 100644 src/shared/ui/StatusBadge/index.ts create mode 100644 src/shared/ui/Table/Table.module.css create mode 100644 src/shared/ui/Table/Table.tsx create mode 100644 src/shared/ui/Table/index.ts create mode 100644 src/shared/ui/Tag/Tag.module.css create mode 100644 src/shared/ui/Tag/Tag.test.tsx create mode 100644 src/shared/ui/Tag/Tag.tsx create mode 100644 src/shared/ui/Tag/index.ts create mode 100644 src/test/setup.ts create mode 100644 src/vite-env.d.ts create mode 100644 src/widgets/app-shell/AppShell.module.css create mode 100644 src/widgets/app-shell/AppShell.tsx create mode 100644 src/widgets/app-shell/index.ts create mode 100644 src/widgets/service-table/ServiceTable.module.css create mode 100644 src/widgets/service-table/ServiceTable.tsx create mode 100644 src/widgets/service-table/index.ts create mode 100644 src/widgets/top-context-bar/TopContextBar.module.css create mode 100644 src/widgets/top-context-bar/TopContextBar.tsx create mode 100644 src/widgets/top-context-bar/index.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts 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 ( +
+