From 5c84390b9012e93b5dbb052b4fb24e38afe35a4e Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 1 Apr 2026 16:16:18 +0800 Subject: [PATCH] fix(console): add account api fallback proxy --- config/feature_flags.yaml | 332 ++++++++++++++++++ deploy/single-node/.env.runtime.example | 18 +- docs/architecture/web-console/overview.md | 89 +++++ docs/en/deployment.md | 11 +- .../2026-03-18-frontend-single-node-deploy.md | 18 +- docs/usage/deployment.md | 32 +- docs/zh/deployment.md | 10 +- src/app/api/account/[...segments]/route.ts | 62 ++++ .../admin/collector/[...segments]/route.ts | 28 ++ .../admin/scheduler/[...segments]/route.ts | 28 ++ .../api/admin/traffic/[...segments]/route.ts | 28 ++ .../user-center/account/SubscriptionPanel.tsx | 46 +++ .../user-center/lib/fetchAccountUsage.test.ts | 37 ++ .../user-center/lib/fetchAccountUsage.ts | 61 ++++ 14 files changed, 755 insertions(+), 45 deletions(-) create mode 100644 config/feature_flags.yaml create mode 100644 docs/architecture/web-console/overview.md create mode 100644 src/app/api/account/[...segments]/route.ts create mode 100644 src/app/api/admin/collector/[...segments]/route.ts create mode 100644 src/app/api/admin/scheduler/[...segments]/route.ts create mode 100644 src/app/api/admin/traffic/[...segments]/route.ts create mode 100644 src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts create mode 100644 src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts diff --git a/config/feature_flags.yaml b/config/feature_flags.yaml new file mode 100644 index 0000000..f93ebcf --- /dev/null +++ b/config/feature_flags.yaml @@ -0,0 +1,332 @@ +# Feature flag inventory for the dashboard and public site. +# This file is a human-readable catalog of page-level and module-level flags. + +meta: + app: console.svc.plus + scope: + - public-site + - dashboard + - xworkmate + - cloud-iac + - docs + source: + - src/config/feature-toggles.json + - src/modules/extensions/builtin + - src/app + +runtime: + current_implementation: + toggles_json: src/config/feature-toggles.json + loader: src/lib/featureToggles.ts + extension_flags: src/lib/featureFlags.ts + notes: + - "feature-toggles.json is the active runtime source for path-based gating." + - "This YAML is an inventory and planning file; it does not currently drive runtime behavior." + +sections: + globalNavigation: + enabled: true + description: Top-level navigation and auth entry points. + default_channel: stable + routes: + - path: / + status: enabled + channel: stable + - path: /docs + status: enabled + channel: beta + - path: /blogs + status: enabled + note: Public content listing, not currently toggle-gated in code. + - path: /services + status: enabled + note: Public service directory. + - path: /prices + status: enabled + - path: /support + status: enabled + - path: /about + status: enabled + - path: /login + status: enabled + channel: stable + - path: /register + status: enabled + channel: stable + - path: /panel + status: enabled + channel: stable + - path: /panel/management + status: enabled + note: Shown only to admin/operator users. + - path: /cloud_iac + status: enabled + channel: develop + - path: /download + status: enabled + channel: stable + - path: /insight + status: enabled + channel: develop + - path: /xworkmate + status: enabled + + appModules: + enabled: true + description: Path-based module gating used by route handlers and content loaders. + routes: + - path: /docs + status: enabled + uses: src/app/docs/page.tsx + - path: /docs/[collection] + status: enabled + uses: src/app/docs/[collection]/page.tsx + - path: /docs/[collection]/[...slug] + status: enabled + uses: src/app/docs/[collection]/[...slug]/page.tsx + - path: /download + status: enabled + uses: src/app/download/page.tsx + - path: /download/[...segments] + status: enabled + uses: src/app/download/[...segments]/page.tsx + - path: /cloud_iac + status: enabled + uses: src/app/cloud_iac/page.tsx + - path: /cloud_iac/[provider] + status: enabled + uses: src/app/cloud_iac/[provider]/page.tsx + - path: /cloud_iac/[provider]/[service] + status: enabled + uses: src/app/cloud_iac/[provider]/[service]/page.tsx + - path: /insight + status: enabled + uses: src/app/services/insight/page.tsx + - path: /editor + status: enabled + uses: src/app/editor/page.tsx + - path: /editor/wechat + status: enabled + - path: /editor/xiaohongshu + status: enabled + - path: /xworkmate + status: enabled + uses: src/app/xworkmate/page.tsx + - path: /xworkmate/admin + status: enabled + uses: src/app/xworkmate/admin/page.tsx + - path: /xworkmate/integrations + status: enabled + uses: src/app/xworkmate/integrations/page.tsx + + cmsExperience: + enabled: true + description: CMS/homepage experience gating. + routes: + - path: /homepage + status: enabled + children: + - path: /homepage/dynamic + status: enabled + +extensions: + builtin.user-center: + enabled: true + description: Core dashboard user center and account management extension. + routes: + - path: /panel + id: dashboard + enabled: true + sidebar_section: workspace + - path: /panel/agent + id: agents + enabled: true + env_var: NEXT_PUBLIC_FEATURE_AGENT_MODULE + default_enabled: true + sidebar_section: productivity + - path: /panel/api + id: apis + enabled: true + env_var: NEXT_PUBLIC_FEATURE_API_MODULE + default_enabled: true + sidebar_section: productivity + - path: /panel/account + id: accounts + enabled: true + sidebar_section: management + - path: /panel/subscription + id: subscription + enabled: true + env_var: NEXT_PUBLIC_FEATURE_SUBSCRIPTION_MODULE + default_enabled: true + sidebar_section: management + - path: /panel/ldp + id: ldp + enabled: true + env_var: NEXT_PUBLIC_FEATURE_LDP_MODULE + default_enabled: false + sidebar_section: management + - path: /panel/appearance + id: appearance + enabled: true + sidebar_section: preferences + - path: /panel/management + id: management + enabled: true + roles: + - admin + - operator + permissions: + - admin.settings.read + - admin.users.metrics.read + - admin.users.list.read + - admin.agents.status.read + - admin.blacklist.read + sidebar_hidden: true + + builtin.infra: + enabled: true + description: Infrastructure and ops extension. + routes: + - path: /panel/deployments + id: deployments + enabled: true + sidebar_section: infra + - path: /panel/resources + id: resources + enabled: true + sidebar_section: infra + - path: /panel/api-keys + id: apiKeys + enabled: true + sidebar_section: infra + - path: /panel/observability + id: logs + enabled: true + sidebar_section: infra + - path: /panel/settings + id: settings + enabled: true + sidebar_section: preferences + +pages: + public: + - path: / + component: src/app/page.tsx + status: enabled + - path: /services + component: src/app/services/page.tsx + status: enabled + - path: /about + component: src/app/about/page.tsx + status: enabled + - path: /prices + component: src/app/prices/page.tsx + status: enabled + - path: /support + component: src/app/support/page.tsx + status: enabled + - path: /support/discussions + component: src/app/support/discussions/page.tsx + status: enabled + - path: /blogs + component: src/app/blogs/page.tsx + status: enabled + - path: /blogs/[...slug] + component: src/app/blogs/[...slug]/page.tsx + status: enabled + - path: /terms + component: src/app/terms/page.tsx + status: enabled + - path: /privacy + component: src/app/privacy/page.tsx + status: enabled + - path: /download + component: src/app/download/page.tsx + status: gated_by_appModules + - path: /download/[...segments] + component: src/app/download/[...segments]/page.tsx + status: gated_by_appModules + - path: /docs + component: src/app/docs/page.tsx + status: gated_by_appModules + - path: /docs/[collection] + component: src/app/docs/[collection]/page.tsx + status: gated_by_appModules + - path: /docs/[collection]/[...slug] + component: src/app/docs/[collection]/[...slug]/page.tsx + status: gated_by_appModules + - path: /cloud_iac + component: src/app/cloud_iac/page.tsx + status: gated_by_appModules + - path: /cloud_iac/[provider] + component: src/app/cloud_iac/[provider]/page.tsx + status: gated_by_appModules + - path: /cloud_iac/[provider]/[service] + component: src/app/cloud_iac/[provider]/[service]/page.tsx + status: gated_by_appModules + - path: /editor + component: src/app/editor/page.tsx + status: redirect_external + - path: /editor/wechat + component: src/app/editor/wechat/page.tsx + status: enabled + - path: /editor/xiaohongshu + component: src/app/editor/xiaohongshu/page.tsx + status: enabled + - path: /xworkmate + component: src/app/xworkmate/page.tsx + status: enabled + - path: /xworkmate/admin + component: src/app/xworkmate/admin/page.tsx + status: enabled + - path: /xworkmate/integrations + component: src/app/xworkmate/integrations/page.tsx + status: enabled + + auth: + - path: /login + component: src/app/(auth)/login/page.tsx + status: gated_by_globalNavigation + - path: /register + component: src/app/(auth)/register/page.tsx + status: gated_by_globalNavigation + - path: /email-verification + component: src/app/(auth)/email-verification/page.tsx + status: gated_by_globalNavigation + - path: /logout + component: src/app/logout/page.tsx + status: enabled + + panel: + - path: /panel + component: src/app/panel/page.tsx + status: extension_route + - path: /panel/account + component: src/app/panel/account/page.tsx + status: extension_route + - path: /panel/agent + component: src/app/panel/agent/page.tsx + status: extension_route + - path: /panel/api + component: src/app/panel/api/page.tsx + status: extension_route + - path: /panel/appearance + component: src/app/panel/appearance/page.tsx + status: extension_route + - path: /panel/ldp + component: src/app/panel/ldp/page.tsx + status: extension_route + - path: /panel/management + component: src/app/panel/management/page.tsx + status: extension_route + - path: /panel/subscription + component: src/app/panel/subscription/page.tsx + status: extension_route + - path: /panel/[...segments] + component: src/app/panel/[...segments]/page.tsx + status: catch_all_extension + +recommendations: + - "If this file is intended to become runtime-configurable, wire it into src/lib/featureToggles.ts and keep feature-toggles.json as the generated artifact." + - "If this file is intended only for documentation, keep it synchronized with feature-toggles.json and extension definitions." diff --git a/deploy/single-node/.env.runtime.example b/deploy/single-node/.env.runtime.example index 0526d49..fbb563c 100644 --- a/deploy/single-node/.env.runtime.example +++ b/deploy/single-node/.env.runtime.example @@ -1,22 +1,22 @@ # Compose settings FRONTEND_IMAGE=ghcr.io/cloud-neutral-toolkit/dashboard:replace-me -PRIMARY_DOMAIN=cn.svc.plus -SECONDARY_DOMAIN=cn.onwalk.net +PRIMARY_DOMAIN=cn-console.svc.plus +SECONDARY_DOMAIN=cn-console.onwalk.net # Frontend runtime NODE_ENV=production PORT=3000 RUNTIME_ENV=prod REGION=cn -APP_BASE_URL=https://cn.svc.plus -NEXT_PUBLIC_APP_BASE_URL=https://cn.svc.plus -NEXT_PUBLIC_SITE_URL=https://cn.svc.plus -NEXT_PUBLIC_LOGIN_URL=https://cn.svc.plus/login -NEXT_PUBLIC_DOCS_BASE_URL=https://cn.svc.plus/docs +APP_BASE_URL=https://cn-console.svc.plus +NEXT_PUBLIC_APP_BASE_URL=https://cn-console.svc.plus +NEXT_PUBLIC_SITE_URL=https://cn-console.svc.plus +NEXT_PUBLIC_LOGIN_URL=https://cn-console.svc.plus/login +NEXT_PUBLIC_DOCS_BASE_URL=https://cn-console.svc.plus/docs SESSION_COOKIE_SECURE=true NEXT_PUBLIC_SESSION_COOKIE_SECURE=true -RUNTIME_HOSTNAME=cn.svc.plus -DEPLOYMENT_HOSTNAME=cn.svc.plus +RUNTIME_HOSTNAME=cn-console.svc.plus +DEPLOYMENT_HOSTNAME=cn-console.svc.plus NEXT_PUBLIC_RUNTIME_ENVIRONMENT=prod NEXT_PUBLIC_RUNTIME_REGION=cn diff --git a/docs/architecture/web-console/overview.md b/docs/architecture/web-console/overview.md new file mode 100644 index 0000000..d726fd4 --- /dev/null +++ b/docs/architecture/web-console/overview.md @@ -0,0 +1,89 @@ +# console.svc.plus Web Console Architecture + +## Scope + +`console.svc.plus` is the browser-facing control plane. It is a Next.js App Router application that combines public pages, docs browsing, account/admin panels, and a BFF layer that forwards requests to downstream services. + +## Architecture + +```mermaid +flowchart TB + subgraph Pages["src/app pages"] + Root["/ -> landing page"] + Docs["/docs, /docs/[collection], /docs/[collection]/[...slug]\nDocs reader"] + Auth["/login, /register, /email-verification, /logout\nAuth flows"] + Panel["/panel/*\nUser / admin console"] + Tools["/editor/*, /download/*, /cloud_iac/*, /xworkmate/*\nProduct tools"] + Content["/blogs/*, /services/*, /support/*, /prices, /about, /privacy, /terms, /[slug]"] + Admin["/dashboard/cms\nCMS/admin entry"] + end + + subgraph BFF["src/app/api route handlers"] + AuthAPI["/api/auth/*"] + AdminAPI["/api/admin/*"] + AgentAPI["/api/agent-server/[...segments]\n/api/agent/[...segments]"] + RagAPI["/api/rag/query\n/api/askai"] + UtilAPI["/api/users\n/api/ping\n/api/content-meta\n/api/render-markdown\n/api/dl-index/*\n/api/marketing/home-stats\n/api/integrations/*\n/api/moltbot/chat\n/api/openclaw/assistant\n/api/task/[...segments]\n/api/xworkmate/profile"] + SandboxAPI["/api/sandbox/*"] + end + + Accounts["accounts.svc.plus"] + Rag["rag-server.svc.plus"] + DocsSvc["docs.svc.plus"] + External["Other upstream services"] + + AuthAPI --> Accounts + AdminAPI --> Accounts + AgentAPI --> Accounts + RagAPI --> Rag + UtilAPI --> DocsSvc + UtilAPI --> External + SandboxAPI --> Accounts +``` + +## Frontend Routes + +| Route family | Path | Purpose | +| --- | --- | --- | +| Home | `/` | Public landing page and site entry | +| Docs | `/docs`, `/docs/[collection]`, `/docs/[collection]/[...slug]` | Documentation reader, sidebar, and TOC | +| Auth | `/login`, `/register`, `/email-verification`, `/logout` | Sign in / sign up / email verification / session cleanup | +| Panel | `/panel`, `/panel/account`, `/panel/agent`, `/panel/api`, `/panel/appearance`, `/panel/ldp`, `/panel/management`, `/panel/subscription`, `/panel/[...segments]` | Signed-in account and admin console | +| Tools | `/editor`, `/editor/wechat`, `/editor/xiaohongshu`, `/download`, `/download/[...segments]`, `/cloud_iac`, `/cloud_iac/[provider]`, `/cloud_iac/[provider]/[service]`, `/xworkmate`, `/xworkmate/admin`, `/xworkmate/integrations` | Product tools and service explorers | +| Content | `/blogs`, `/blogs/[...slug]`, `/services`, `/services/openclaw`, `/services/insight`, `/support`, `/support/discussions`, `/about`, `/prices`, `/privacy`, `/terms`, `/[slug]` | Marketing / informational pages | +| Admin / CMS | `/dashboard/cms` | CMS or content-management entry | +| Error pages | `/404`, `/500` | Static error surfaces | + +## BFF / API Routes + +| API family | Path | Purpose | Upstream target | +| --- | --- | --- | --- | +| Auth | `/api/auth/login`, `/api/auth/register`, `/api/auth/register/send`, `/api/auth/register/verify`, `/api/auth/verify-email`, `/api/auth/verify-email/send`, `/api/auth/session`, `/api/auth/token/exchange` | Login, registration, token exchange, session lookup | `accounts.svc.plus/api/auth/*` | +| MFA | `/api/auth/mfa/status`, `/api/auth/mfa/setup`, `/api/auth/mfa/verify`, `/api/auth/mfa/disable` | TOTP setup and verification | `accounts.svc.plus/api/auth/*` | +| OAuth / billing | `/api/auth/oauth/login/[provider]`, `/api/auth/stripe/checkout`, `/api/auth/stripe/portal`, `/api/auth/subscriptions`, `/api/auth/subscriptions/cancel` | OAuth redirects and billing actions | `accounts.svc.plus/api/auth/*` | +| Admin | `/api/admin/settings`, `/api/admin/homepage-video`, `/api/admin/users/*`, `/api/admin/blacklist/*`, `/api/admin/sandbox/*` | Account admin operations | `accounts.svc.plus/api/*` | +| Agent bridge | `/api/agent-server/[...segments]`, `/api/agent/[...segments]` | Agent registry/status and legacy alias | `accounts.svc.plus` | +| RAG | `/api/rag/query`, `/api/askai` | Retrieval and answer generation | `rag-server.svc.plus` | +| Sandbox / session shaping | `/api/sandbox/assume`, `/api/sandbox/assume/revert`, `/api/sandbox/assume/status`, `/api/sandbox/binding` | Guest / demo identity switching | `accounts.svc.plus/api/auth/*` and internal sandbox reads | +| Content / docs | `/api/content-meta`, `/api/render-markdown`, `/api/blogs/latest`, `/api/dl-index/*` | Docs/content rendering and download manifests | docs / CDN / download service | +| Integrations | `/api/integrations/defaults`, `/api/integrations/probe`, `/api/marketing/home-stats` | Integration defaults, health probes, marketing metrics | config-dependent external services | +| Misc | `/api/ping`, `/api/users`, `/api/xworkmate/profile`, `/api/task/[...segments]`, `/api/openclaw/assistant`, `/api/moltbot/chat`, `/api/render-markdown` | Health, user lookup, profile, task and assistant proxies | `accounts.svc.plus`, internal API, task services | + +## Auth and Session Notes + +- Browser calls use the session cookie and BFF logic in `src/server/account/session.ts`. +- Service-to-service calls use `INTERNAL_SERVICE_TOKEN` when configured. +- `api/agent-server/[...segments]` keeps caller `Authorization` untouched when an agent token is already present. +- `api/agent-server/[...segments]` forwards the dashboard session token for browser-driven calls. + +## Dependencies + +- `accounts.svc.plus` for identity, profile, sandbox, billing, and admin actions. +- `rag-server.svc.plus` for RAG query and AskAI. +- `docs.svc.plus` for docs content and navigation data. +- CDN / external providers for content, analytics, and integration checks. + +## Notes + +- Route groups in parentheses, such as `(auth)`, are Next.js organizational folders and do not appear in the public URL. +- The BFF layer is the main place where console-specific auth shaping, cookie management, and upstream proxying happen. diff --git a/docs/en/deployment.md b/docs/en/deployment.md index bf79c8e..261ef16 100644 --- a/docs/en/deployment.md +++ b/docs/en/deployment.md @@ -3,10 +3,10 @@ ## Production Baseline - Runtime: `Caddy + Docker Compose` -- Deploy host: `47.120.61.35` +- Deploy host: `root@cn-console.svc.plus` - Domains: - - `cn.svc.plus` - - `cn.onwalk.net` + - `cn-console.svc.plus` + - `cn-console.onwalk.net` - Frontend release workflow: `.github/workflows/service_release_frontend-deploy.yml` ## Operating Model @@ -21,10 +21,9 @@ The stack is static-first: - The Next.js standalone container serves dynamic HTML, auth endpoints, and API proxy routes. Static assets and hashed CSS/JS files are extracted by the `frontend-assets` helper task, so the runtime no longer needs to compile anything on the single-node host. - `docs.svc.plus` is the source of truth for rendered docs/blog pages; the browser does not call it directly. -Releases are orchestrated through `.github/workflows/service_release_frontend-deploy.yml`. That workflow clones the knowledge repository, runs the Docker build/push sequence, renders `.env.runtime`, and ships `docker-compose.yml`, `Caddyfile`, and the runtime env file to the host. The control-plane workflow `.github/workflows/service_release_apiserver-deploy.yml` then updates Cloudflare DNS for the release domain (via `scripts/github-actions/update-release-dns.sh`) so `cn.svc.plus` and the redirected alias `cn.onwalk.net` point at the new environment. -Releases are orchestrated through `.github/workflows/service_release_frontend-deploy.yml`. That workflow builds/pushes the image, renders `.env.runtime` including `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL`, and ships `docker-compose.yml`, `Caddyfile`, and the runtime env file to the host. The control-plane workflow `.github/workflows/service_release_apiserver-deploy.yml` then updates Cloudflare DNS for the release domain (via `scripts/github-actions/update-release-dns.sh`) so `cn.svc.plus` and the redirected alias `cn.onwalk.net` point at the new environment. +Releases are orchestrated through `.github/workflows/service_release_frontend-deploy.yml`. That workflow builds/pushes the image, renders `.env.runtime` including `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL`, and ships `docker-compose.yml`, `Caddyfile`, and the runtime env file to the host. The control-plane workflow `.github/workflows/service_release_apiserver-deploy.yml` then updates Cloudflare DNS for the release domain (via `scripts/github-actions/update-release-dns.sh`) so `cn-console.svc.plus` and the redirected alias `cn-console.onwalk.net` point at the new environment. -This baseline is intentional for the weak-IO single-node host (47.120.61.35). No images are built on the target machine, keeping the deployment lightweight: the host only logs into GHCR, pulls the `dashboard` image, extracts assets into `frontend_static`, and starts `dashboard` plus `caddy` containers via `docker compose`. +This baseline is intentional for the weak-IO single-node host `root@cn-console.svc.plus`. No images are built on the target machine, keeping the deployment lightweight: the host only logs into GHCR, pulls the `dashboard` image, extracts assets into `frontend_static`, and starts `dashboard` plus `caddy` containers via `docker compose`. `docs.svc.plus` is now the dedicated docs/blog service for the frontend delivery path. diff --git a/docs/plans/2026-03-18-frontend-single-node-deploy.md b/docs/plans/2026-03-18-frontend-single-node-deploy.md index 35b1e05..f1c907b 100644 --- a/docs/plans/2026-03-18-frontend-single-node-deploy.md +++ b/docs/plans/2026-03-18-frontend-single-node-deploy.md @@ -3,10 +3,10 @@ ## Scope - Repository: `console.svc.plus` -- Target host: `root@47.120.61.35` +- Target host: `root@cn-console.svc.plus` - Public domains: - - `cn.svc.plus` - - `cn.onwalk.net` + - `cn-console.svc.plus` + - `cn-console.onwalk.net` - Delivery mode: `GitHub Actions + GHCR + Caddy + Docker Compose` This document defines the deployment baseline for the China-facing frontend node. The source of truth is this upstream repository. The control-plane repository may consume the repo through git submodule, but should not become the primary place where this deployment design lives. @@ -26,7 +26,7 @@ The result should support repeatable releases, quick rollback by image tag, and ### Host constraints -- `47.120.61.35` is a single-node host +- `cn-console.svc.plus` is a single-node host - deployment user is `root` - local image build on the host is explicitly disallowed - IO pressure should be minimized during release @@ -140,16 +140,16 @@ Temporary nature: Primary domain: -- `cn.svc.plus` +- `cn-console.svc.plus` Secondary domain: -- `cn.onwalk.net` +- `cn-console.onwalk.net` Current routing decision: - Caddy accepts both domains -- requests for `cn.onwalk.net` are redirected permanently to `cn.svc.plus` +- requests for `cn-console.onwalk.net` are redirected permanently to `cn-console.svc.plus` Reason: @@ -200,7 +200,7 @@ Rollback steps: 1. set `FRONTEND_IMAGE` to a previous known-good tag 2. rerun `frontend-assets` 3. restart `dashboard` and `caddy` -4. verify `cn.svc.plus` +4. verify `cn-console.svc.plus` This avoids rebuilding and keeps rollback cheap on the weak-IO host. @@ -261,7 +261,7 @@ Mitigation: ### Near term - populate required GitHub `vars` and `secrets` -- run the workflow against `47.120.61.35` + - run the workflow against `root@cn-console.svc.plus` - validate DNS, TLS, static assets, login flow, and upstream API proxy behavior ### Later diff --git a/docs/usage/deployment.md b/docs/usage/deployment.md index 9a8a607..8c6054d 100644 --- a/docs/usage/deployment.md +++ b/docs/usage/deployment.md @@ -4,11 +4,11 @@ - Runtime: `console.svc.plus` - Topology: `Caddy + Docker Compose + GitHub Actions` -- Deploy host: `root@47.120.61.35` +- Deploy host: `root@cn-console.svc.plus` - Public domains: - - `https://cn.svc.plus` - - `https://cn.onwalk.net` -- Primary origin: `https://cn.svc.plus` + - `https://cn-console.svc.plus` + - `https://cn-console.onwalk.net` +- Primary origin: `https://cn-console.svc.plus` ## Current Delivery Model @@ -24,7 +24,7 @@ This is intentionally static-first for the current weak-IO single-node host. Dyn ## Control Plane & DNS Stage -The control repo (`github-org-x-evor`) tracks `console.svc.plus` through `console.svc.plus.code-workspace` and keeps the `subrepos/accounts.svc.plus` pointer in sync via `skills/cross-repo-upstream-submodule-sync`. Releases resolve metadata with that workspace and the `config/single-node-release` manifests. After `.github/workflows/service_release_frontend-deploy.yml` finishes pushing the new image, the control-plane workflow `.github/workflows/service_release_apiserver-deploy.yml` calls `scripts/github-actions/update-release-dns.sh` to update Cloudflare DNS so the new endpoint is reachable under `cn.svc.plus` and `cn.onwalk.net`. +The control repo (`github-org-x-evor`) tracks `console.svc.plus` through `console.svc.plus.code-workspace` and keeps the `subrepos/accounts.svc.plus` pointer in sync via `skills/cross-repo-upstream-submodule-sync`. Releases resolve metadata with that workspace and the `config/single-node-release` manifests. After `.github/workflows/service_release_frontend-deploy.yml` finishes pushing the new image, the control-plane workflow `.github/workflows/service_release_apiserver-deploy.yml` calls `scripts/github-actions/update-release-dns.sh` to update Cloudflare DNS so the new endpoint is reachable under `cn-console.svc.plus` and `cn-console.onwalk.net`. ## Runtime Layout @@ -95,10 +95,10 @@ Repository/environment variables recommended: 2. Docker builds the frontend image with the public `NEXT_PUBLIC_*` values needed at build time. 3. The image is pushed to GHCR. 4. The workflow runs a matrix DNS stage, updating one public domain per job. -5. The workflow renders `.env.runtime`, including docs service runtime endpoints. +5. The workflow renders `.env.runtime`, including docs service runtime endpoints and the `cn-console` origin settings. 6. The workflow uploads `docker-compose.yml`, `Caddyfile`, and `.env.runtime` to the host. 7. The host pulls the new image, refreshes the static asset volume, and starts `dashboard + caddy`. -8. The workflow verifies `cn.svc.plus` and `cn.onwalk.net`. +8. The workflow verifies `cn-console.svc.plus` and `cn-console.onwalk.net`. ## Verification Commands @@ -122,10 +122,10 @@ PY Remote checks: ```bash -ssh root@47.120.61.35 "cd /opt/console-svc-plus && docker compose --env-file .env.runtime ps" -ssh root@47.120.61.35 "curl -fsSI -H 'Host: cn.svc.plus' http://127.0.0.1/" -curl -fsSIL https://cn.svc.plus -curl -fsSIL https://cn.onwalk.net +ssh root@cn-console.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime ps" +ssh root@cn-console.svc.plus "curl -fsSI -H 'Host: cn-console.svc.plus' http://127.0.0.1/" +curl -fsSIL https://cn-console.svc.plus +curl -fsSIL https://cn-console.onwalk.net ``` ## Failure Signatures @@ -134,9 +134,9 @@ curl -fsSIL https://cn.onwalk.net The workflow token or package visibility is wrong. - `frontend-assets` fails The image layout changed and no longer contains `/app/dashboard/static` or `/app/dashboard/public`. -- `cn.svc.plus` returns `502` +- `cn-console.svc.plus` returns `502` Caddy is up, but the `dashboard` container failed or is not reachable on port `3000`. -- `cn.onwalk.net` does not redirect +- `cn-console.onwalk.net` does not redirect Check the deployed `Caddyfile` and domain DNS. ## Rollback @@ -146,8 +146,8 @@ curl -fsSIL https://cn.onwalk.net 3. Restart the services: ```bash -ssh root@47.120.61.35 "cd /opt/console-svc-plus && docker compose --env-file .env.runtime run --rm frontend-assets" -ssh root@47.120.61.35 "cd /opt/console-svc-plus && docker compose --env-file .env.runtime up -d dashboard caddy" +ssh root@cn-console.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime run --rm frontend-assets" +ssh root@cn-console.svc.plus "cd /opt/console-svc-plus && docker compose --env-file .env.runtime up -d dashboard caddy" ``` -4. Verify `https://cn.svc.plus` again before closing the incident. +4. Verify `https://cn-console.svc.plus` again before closing the incident. diff --git a/docs/zh/deployment.md b/docs/zh/deployment.md index fa09381..29e0440 100644 --- a/docs/zh/deployment.md +++ b/docs/zh/deployment.md @@ -3,10 +3,10 @@ ## 生产基线 - 运行拓扑: `Caddy + Docker Compose` -- 目标主机: `47.120.61.35` +- 目标主机: `root@cn-console.svc.plus` - 域名: - - `cn.svc.plus` - - `cn.onwalk.net` + - `cn-console.svc.plus` + - `cn-console.onwalk.net` - 前端独立发布流水线: `.github/workflows/service_release_frontend-deploy.yml` ## 运行方式 @@ -21,9 +21,9 @@ - Next.js standalone 容器只承接动态页面、认证接口和代理接口,`frontend-assets` 任务会把所有静态文件(包括哈希后的 CSS/JS)拷贝到 `frontend_static`。 - `docs.svc.plus` 是 docs/blog 的运行时内容源,浏览器不会直接访问它。 -发布由 `.github/workflows/service_release_frontend-deploy.yml` 驱动,CI 构建/推送镜像、渲染包含 `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL` 的 `.env.runtime`,然后将 `docker-compose.yml`、`Caddyfile` 与运行时环境文件发送到主机。随后控制平面工作流 `.github/workflows/service_release_apiserver-deploy.yml` 通过 `scripts/github-actions/update-release-dns.sh` 更新 Cloudflare DNS,使 `cn.svc.plus` 与别名 `cn.onwalk.net` 指向更新后的环境。 +发布由 `.github/workflows/service_release_frontend-deploy.yml` 驱动,CI 构建/推送镜像、渲染包含 `DOCS_SERVICE_URL` / `DOCS_SERVICE_INTERNAL_URL` 的 `.env.runtime`,然后将 `docker-compose.yml`、`Caddyfile` 与运行时环境文件发送到主机。随后控制平面工作流 `.github/workflows/service_release_apiserver-deploy.yml` 通过 `scripts/github-actions/update-release-dns.sh` 更新 Cloudflare DNS,使 `cn-console.svc.plus` 与别名 `cn-console.onwalk.net` 指向更新后的环境。 -这是针对弱 IO 单机主机 `47.120.61.35` 的部署权衡:主机不会在本地构建镜像,只需登录 GHCR、拉取 `dashboard` 镜像、解包静态资源到 `frontend_static`,再通过 `docker compose` 启动 `dashboard` 与 `caddy`。 +这是针对弱 IO 单机主机 `root@cn-console.svc.plus` 的部署权衡:主机不会在本地构建镜像,只需登录 GHCR、拉取 `dashboard` 镜像、解包静态资源到 `frontend_static`,再通过 `docker compose` 启动 `dashboard` 与 `caddy`。 `docs.svc.plus` 已经是前端 docs/blog 内容的独立服务。 diff --git a/src/app/api/account/[...segments]/route.ts b/src/app/api/account/[...segments]/route.ts new file mode 100644 index 0000000..6814220 --- /dev/null +++ b/src/app/api/account/[...segments]/route.ts @@ -0,0 +1,62 @@ +export const dynamic = 'force-dynamic' + +import type { NextRequest } from 'next/server' + +import { createUpstreamProxyHandler } from '@lib/apiProxy' +import { getAccountSession } from '@server/account/session' +import { getAccountServiceBaseUrl } from '@server/serviceConfig' + +const ACCOUNT_PREFIX = '/api/account' + +function createHandler() { + const upstreamBaseUrl = getAccountServiceBaseUrl() + return createUpstreamProxyHandler({ + upstreamBaseUrl, + upstreamPathPrefix: ACCOUNT_PREFIX, + getAdditionalHeaders: async (request) => { + if (request.headers.get('authorization')) { + return undefined + } + + const session = await getAccountSession(request) + if (!session.token) { + return undefined + } + + return { + authorization: `Bearer ${session.token}`, + 'x-account-session': session.token, + } + }, + }) +} + +const handler = createHandler() + +export function GET(request: NextRequest) { + return handler(request) +} + +export function POST(request: NextRequest) { + return handler(request) +} + +export function PUT(request: NextRequest) { + return handler(request) +} + +export function PATCH(request: NextRequest) { + return handler(request) +} + +export function DELETE(request: NextRequest) { + return handler(request) +} + +export function HEAD(request: NextRequest) { + return handler(request) +} + +export function OPTIONS(request: NextRequest) { + return handler(request) +} diff --git a/src/app/api/admin/collector/[...segments]/route.ts b/src/app/api/admin/collector/[...segments]/route.ts new file mode 100644 index 0000000..e0b7ec5 --- /dev/null +++ b/src/app/api/admin/collector/[...segments]/route.ts @@ -0,0 +1,28 @@ +export const dynamic = 'force-dynamic' + +import type { NextRequest } from 'next/server' + +import { createUpstreamProxyHandler } from '@lib/apiProxy' +import { getAccountSession } from '@server/account/session' +import { getAccountServiceBaseUrl } from '@server/serviceConfig' + +const ADMIN_COLLECTOR_PREFIX = '/api/admin/collector' + +const handler = createUpstreamProxyHandler({ + upstreamBaseUrl: getAccountServiceBaseUrl(), + upstreamPathPrefix: ADMIN_COLLECTOR_PREFIX, + getAdditionalHeaders: async (request) => { + const session = await getAccountSession(request) + if (!session.token) { + return undefined + } + return { + authorization: `Bearer ${session.token}`, + 'x-account-session': session.token, + } + }, +}) + +export function GET(request: NextRequest) { + return handler(request) +} diff --git a/src/app/api/admin/scheduler/[...segments]/route.ts b/src/app/api/admin/scheduler/[...segments]/route.ts new file mode 100644 index 0000000..002d15c --- /dev/null +++ b/src/app/api/admin/scheduler/[...segments]/route.ts @@ -0,0 +1,28 @@ +export const dynamic = 'force-dynamic' + +import type { NextRequest } from 'next/server' + +import { createUpstreamProxyHandler } from '@lib/apiProxy' +import { getAccountSession } from '@server/account/session' +import { getAccountServiceBaseUrl } from '@server/serviceConfig' + +const ADMIN_SCHEDULER_PREFIX = '/api/admin/scheduler' + +const handler = createUpstreamProxyHandler({ + upstreamBaseUrl: getAccountServiceBaseUrl(), + upstreamPathPrefix: ADMIN_SCHEDULER_PREFIX, + getAdditionalHeaders: async (request) => { + const session = await getAccountSession(request) + if (!session.token) { + return undefined + } + return { + authorization: `Bearer ${session.token}`, + 'x-account-session': session.token, + } + }, +}) + +export function GET(request: NextRequest) { + return handler(request) +} diff --git a/src/app/api/admin/traffic/[...segments]/route.ts b/src/app/api/admin/traffic/[...segments]/route.ts new file mode 100644 index 0000000..de287a7 --- /dev/null +++ b/src/app/api/admin/traffic/[...segments]/route.ts @@ -0,0 +1,28 @@ +export const dynamic = 'force-dynamic' + +import type { NextRequest } from 'next/server' + +import { createUpstreamProxyHandler } from '@lib/apiProxy' +import { getAccountSession } from '@server/account/session' +import { getAccountServiceBaseUrl } from '@server/serviceConfig' + +const ADMIN_TRAFFIC_PREFIX = '/api/admin/traffic' + +const handler = createUpstreamProxyHandler({ + upstreamBaseUrl: getAccountServiceBaseUrl(), + upstreamPathPrefix: ADMIN_TRAFFIC_PREFIX, + getAdditionalHeaders: async (request) => { + const session = await getAccountSession(request) + if (!session.token) { + return undefined + } + return { + authorization: `Bearer ${session.token}`, + 'x-account-session': session.token, + } + }, +}) + +export function GET(request: NextRequest) { + return handler(request) +} diff --git a/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx b/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx index 09789ee..08ff8f4 100644 --- a/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx +++ b/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx @@ -5,6 +5,7 @@ import useSWR from "swr"; import { openStripePortal } from "@components/billing/stripe-client"; import Card from "../components/Card"; +import { fetchAccountPolicy, fetchAccountUsageSummary } from "../lib/fetchAccountUsage"; const fetcher = (url: string) => fetch(url, { @@ -48,6 +49,8 @@ export default function SubscriptionPanel() { "/api/auth/subscriptions", fetcher, ); + const { data: usageSummary } = useSWR("account-usage-summary", fetchAccountUsageSummary); + const { data: accountPolicy } = useSWR("account-policy", fetchAccountPolicy); const [submitting, setSubmitting] = useState(null); const [portalLoading, setPortalLoading] = useState(false); const [error, setError] = useState(null); @@ -124,6 +127,49 @@ export default function SubscriptionPanel() { {error ?

{error}

: null} + {usageSummary ? ( +
+
+

+ Authoritative Usage +

+

+ {usageSummary.totalBytes.toLocaleString()} B +

+

+ 统计由 accounts.svc.plus 汇总,非本地客户端计数。 +

+
+
+

+ Balance / Quota +

+

+ {typeof usageSummary.currentBalance === "number" + ? usageSummary.currentBalance.toFixed(2) + : "—"} +

+

+ 剩余配额 {typeof usageSummary.remainingIncludedQuota === "number" + ? `${usageSummary.remainingIncludedQuota.toLocaleString()} B` + : "—"} +

+
+
+

+ Policy / Sync +

+

+ {accountPolicy?.preferredStrategy || "—"} +

+

+ 统计延迟约 {usageSummary.syncDelaySeconds ?? 0} 秒,策略组{" "} + {accountPolicy?.eligibleNodeGroups?.join(", ") || "—"} +

+
+
+ ) : null} + {isLoading ? (

加载订阅中… diff --git a/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts new file mode 100644 index 0000000..80b7480 --- /dev/null +++ b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { fetchAccountPolicy, fetchAccountUsageSummary } from './fetchAccountUsage' + +describe('fetchAccountUsage', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('loads the authoritative usage summary from the account api', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ accountUuid: 'acct-1', totalBytes: 384 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + await expect(fetchAccountUsageSummary()).resolves.toEqual({ + accountUuid: 'acct-1', + totalBytes: 384, + }) + }) + + it('loads the authoritative policy snapshot from the account api', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ accountUuid: 'acct-1', preferredStrategy: 'ewma' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + await expect(fetchAccountPolicy()).resolves.toEqual({ + accountUuid: 'acct-1', + preferredStrategy: 'ewma', + }) + }) +}) diff --git a/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts new file mode 100644 index 0000000..320a483 --- /dev/null +++ b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts @@ -0,0 +1,61 @@ +'use client' + +type AccountUsageError = Error & { + status?: number +} + +export type AccountUsageSummary = { + accountUuid: string + totalBytes: number + uplinkBytes?: number + downlinkBytes?: number + currentBalance?: number + remainingIncludedQuota?: number + syncDelaySeconds?: number + suspendState?: string + throttleState?: string +} + +export type AccountPolicy = { + accountUuid: string + preferredStrategy: string + eligibleNodeGroups?: string[] + authState?: string + degradeMode?: string +} + +function toError(payload: unknown, status: number): AccountUsageError { + const message = + payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string' + ? payload.message + : payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string' + ? payload.error + : `Request failed (${status})` + const error = new Error(message) as AccountUsageError + error.status = status + return error +} + +async function requestJSON(url: string): Promise { + const response = await fetch(url, { + credentials: 'include', + cache: 'no-store', + headers: { + Accept: 'application/json', + }, + }) + + const payload = await response.json().catch(() => null) + if (!response.ok) { + throw toError(payload, response.status) + } + return payload as T +} + +export function fetchAccountUsageSummary(): Promise { + return requestJSON('/api/account/usage/summary') +} + +export function fetchAccountPolicy(): Promise { + return requestJSON('/api/account/policy') +}