fix(console): add account api fallback proxy

This commit is contained in:
Haitao Pan 2026-04-01 16:16:18 +08:00
parent 343a93864f
commit 5c84390b90
14 changed files with 755 additions and 45 deletions

332
config/feature_flags.yaml Normal file
View File

@ -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."

View File

@ -1,22 +1,22 @@
# Compose settings # Compose settings
FRONTEND_IMAGE=ghcr.io/cloud-neutral-toolkit/dashboard:replace-me FRONTEND_IMAGE=ghcr.io/cloud-neutral-toolkit/dashboard:replace-me
PRIMARY_DOMAIN=cn.svc.plus PRIMARY_DOMAIN=cn-console.svc.plus
SECONDARY_DOMAIN=cn.onwalk.net SECONDARY_DOMAIN=cn-console.onwalk.net
# Frontend runtime # Frontend runtime
NODE_ENV=production NODE_ENV=production
PORT=3000 PORT=3000
RUNTIME_ENV=prod RUNTIME_ENV=prod
REGION=cn REGION=cn
APP_BASE_URL=https://cn.svc.plus APP_BASE_URL=https://cn-console.svc.plus
NEXT_PUBLIC_APP_BASE_URL=https://cn.svc.plus NEXT_PUBLIC_APP_BASE_URL=https://cn-console.svc.plus
NEXT_PUBLIC_SITE_URL=https://cn.svc.plus NEXT_PUBLIC_SITE_URL=https://cn-console.svc.plus
NEXT_PUBLIC_LOGIN_URL=https://cn.svc.plus/login NEXT_PUBLIC_LOGIN_URL=https://cn-console.svc.plus/login
NEXT_PUBLIC_DOCS_BASE_URL=https://cn.svc.plus/docs NEXT_PUBLIC_DOCS_BASE_URL=https://cn-console.svc.plus/docs
SESSION_COOKIE_SECURE=true SESSION_COOKIE_SECURE=true
NEXT_PUBLIC_SESSION_COOKIE_SECURE=true NEXT_PUBLIC_SESSION_COOKIE_SECURE=true
RUNTIME_HOSTNAME=cn.svc.plus RUNTIME_HOSTNAME=cn-console.svc.plus
DEPLOYMENT_HOSTNAME=cn.svc.plus DEPLOYMENT_HOSTNAME=cn-console.svc.plus
NEXT_PUBLIC_RUNTIME_ENVIRONMENT=prod NEXT_PUBLIC_RUNTIME_ENVIRONMENT=prod
NEXT_PUBLIC_RUNTIME_REGION=cn NEXT_PUBLIC_RUNTIME_REGION=cn

View File

@ -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.

View File

@ -3,10 +3,10 @@
## Production Baseline ## Production Baseline
- Runtime: `Caddy + Docker Compose` - Runtime: `Caddy + Docker Compose`
- Deploy host: `47.120.61.35` - Deploy host: `root@cn-console.svc.plus`
- Domains: - Domains:
- `cn.svc.plus` - `cn-console.svc.plus`
- `cn.onwalk.net` - `cn-console.onwalk.net`
- Frontend release workflow: `.github/workflows/service_release_frontend-deploy.yml` - Frontend release workflow: `.github/workflows/service_release_frontend-deploy.yml`
## Operating Model ## 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. - 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. - `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-console.svc.plus` and the redirected alias `cn-console.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.
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. `docs.svc.plus` is now the dedicated docs/blog service for the frontend delivery path.

View File

@ -3,10 +3,10 @@
## Scope ## Scope
- Repository: `console.svc.plus` - Repository: `console.svc.plus`
- Target host: `root@47.120.61.35` - Target host: `root@cn-console.svc.plus`
- Public domains: - Public domains:
- `cn.svc.plus` - `cn-console.svc.plus`
- `cn.onwalk.net` - `cn-console.onwalk.net`
- Delivery mode: `GitHub Actions + GHCR + Caddy + Docker Compose` - 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. 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 ### Host constraints
- `47.120.61.35` is a single-node host - `cn-console.svc.plus` is a single-node host
- deployment user is `root` - deployment user is `root`
- local image build on the host is explicitly disallowed - local image build on the host is explicitly disallowed
- IO pressure should be minimized during release - IO pressure should be minimized during release
@ -140,16 +140,16 @@ Temporary nature:
Primary domain: Primary domain:
- `cn.svc.plus` - `cn-console.svc.plus`
Secondary domain: Secondary domain:
- `cn.onwalk.net` - `cn-console.onwalk.net`
Current routing decision: Current routing decision:
- Caddy accepts both domains - 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: Reason:
@ -200,7 +200,7 @@ Rollback steps:
1. set `FRONTEND_IMAGE` to a previous known-good tag 1. set `FRONTEND_IMAGE` to a previous known-good tag
2. rerun `frontend-assets` 2. rerun `frontend-assets`
3. restart `dashboard` and `caddy` 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. This avoids rebuilding and keeps rollback cheap on the weak-IO host.
@ -261,7 +261,7 @@ Mitigation:
### Near term ### Near term
- populate required GitHub `vars` and `secrets` - 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 - validate DNS, TLS, static assets, login flow, and upstream API proxy behavior
### Later ### Later

View File

@ -4,11 +4,11 @@
- Runtime: `console.svc.plus` - Runtime: `console.svc.plus`
- Topology: `Caddy + Docker Compose + GitHub Actions` - Topology: `Caddy + Docker Compose + GitHub Actions`
- Deploy host: `root@47.120.61.35` - Deploy host: `root@cn-console.svc.plus`
- Public domains: - Public domains:
- `https://cn.svc.plus` - `https://cn-console.svc.plus`
- `https://cn.onwalk.net` - `https://cn-console.onwalk.net`
- Primary origin: `https://cn.svc.plus` - Primary origin: `https://cn-console.svc.plus`
## Current Delivery Model ## 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 ## 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 ## 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. 2. Docker builds the frontend image with the public `NEXT_PUBLIC_*` values needed at build time.
3. The image is pushed to GHCR. 3. The image is pushed to GHCR.
4. The workflow runs a matrix DNS stage, updating one public domain per job. 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. 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`. 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 ## Verification Commands
@ -122,10 +122,10 @@ PY
Remote checks: Remote checks:
```bash ```bash
ssh root@47.120.61.35 "cd /opt/console-svc-plus && docker compose --env-file .env.runtime ps" ssh root@cn-console.svc.plus "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/" ssh root@cn-console.svc.plus "curl -fsSI -H 'Host: cn-console.svc.plus' http://127.0.0.1/"
curl -fsSIL https://cn.svc.plus curl -fsSIL https://cn-console.svc.plus
curl -fsSIL https://cn.onwalk.net curl -fsSIL https://cn-console.onwalk.net
``` ```
## Failure Signatures ## Failure Signatures
@ -134,9 +134,9 @@ curl -fsSIL https://cn.onwalk.net
The workflow token or package visibility is wrong. The workflow token or package visibility is wrong.
- `frontend-assets` fails - `frontend-assets` fails
The image layout changed and no longer contains `/app/dashboard/static` or `/app/dashboard/public`. 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`. 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. Check the deployed `Caddyfile` and domain DNS.
## Rollback ## Rollback
@ -146,8 +146,8 @@ curl -fsSIL https://cn.onwalk.net
3. Restart the services: 3. Restart the services:
```bash ```bash
ssh root@47.120.61.35 "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 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 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.

View File

@ -3,10 +3,10 @@
## 生产基线 ## 生产基线
- 运行拓扑: `Caddy + Docker Compose` - 运行拓扑: `Caddy + Docker Compose`
- 目标主机: `47.120.61.35` - 目标主机: `root@cn-console.svc.plus`
- 域名: - 域名:
- `cn.svc.plus` - `cn-console.svc.plus`
- `cn.onwalk.net` - `cn-console.onwalk.net`
- 前端独立发布流水线: `.github/workflows/service_release_frontend-deploy.yml` - 前端独立发布流水线: `.github/workflows/service_release_frontend-deploy.yml`
## 运行方式 ## 运行方式
@ -21,9 +21,9 @@
- Next.js standalone 容器只承接动态页面、认证接口和代理接口,`frontend-assets` 任务会把所有静态文件(包括哈希后的 CSS/JS拷贝到 `frontend_static` - Next.js standalone 容器只承接动态页面、认证接口和代理接口,`frontend-assets` 任务会把所有静态文件(包括哈希后的 CSS/JS拷贝到 `frontend_static`
- `docs.svc.plus` 是 docs/blog 的运行时内容源,浏览器不会直接访问它。 - `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 内容的独立服务。 `docs.svc.plus` 已经是前端 docs/blog 内容的独立服务。

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -5,6 +5,7 @@ import useSWR from "swr";
import { openStripePortal } from "@components/billing/stripe-client"; import { openStripePortal } from "@components/billing/stripe-client";
import Card from "../components/Card"; import Card from "../components/Card";
import { fetchAccountPolicy, fetchAccountUsageSummary } from "../lib/fetchAccountUsage";
const fetcher = (url: string) => const fetcher = (url: string) =>
fetch(url, { fetch(url, {
@ -48,6 +49,8 @@ export default function SubscriptionPanel() {
"/api/auth/subscriptions", "/api/auth/subscriptions",
fetcher, fetcher,
); );
const { data: usageSummary } = useSWR("account-usage-summary", fetchAccountUsageSummary);
const { data: accountPolicy } = useSWR("account-policy", fetchAccountPolicy);
const [submitting, setSubmitting] = useState<string | null>(null); const [submitting, setSubmitting] = useState<string | null>(null);
const [portalLoading, setPortalLoading] = useState(false); const [portalLoading, setPortalLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -124,6 +127,49 @@ export default function SubscriptionPanel() {
{error ? <p className="mt-3 text-sm text-red-600">{error}</p> : null} {error ? <p className="mt-3 text-sm text-red-600">{error}</p> : null}
{usageSummary ? (
<div className="mt-4 grid gap-3 md:grid-cols-3">
<div className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">
Authoritative Usage
</p>
<p className="mt-2 text-2xl font-semibold text-[var(--color-heading)]">
{usageSummary.totalBytes.toLocaleString()} B
</p>
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
accounts.svc.plus
</p>
</div>
<div className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">
Balance / Quota
</p>
<p className="mt-2 text-2xl font-semibold text-[var(--color-heading)]">
{typeof usageSummary.currentBalance === "number"
? usageSummary.currentBalance.toFixed(2)
: "—"}
</p>
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
{typeof usageSummary.remainingIncludedQuota === "number"
? `${usageSummary.remainingIncludedQuota.toLocaleString()} B`
: "—"}
</p>
</div>
<div className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">
Policy / Sync
</p>
<p className="mt-2 text-base font-semibold text-[var(--color-heading)]">
{accountPolicy?.preferredStrategy || "—"}
</p>
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
{usageSummary.syncDelaySeconds ?? 0} {" "}
{accountPolicy?.eligibleNodeGroups?.join(", ") || "—"}
</p>
</div>
</div>
) : null}
{isLoading ? ( {isLoading ? (
<p className="mt-4 text-sm text-[var(--color-text-subtle)]"> <p className="mt-4 text-sm text-[var(--color-text-subtle)]">

View File

@ -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',
})
})
})

View File

@ -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<T>(url: string): Promise<T> {
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<AccountUsageSummary> {
return requestJSON<AccountUsageSummary>('/api/account/usage/summary')
}
export function fetchAccountPolicy(): Promise<AccountPolicy> {
return requestJSON<AccountPolicy>('/api/account/policy')
}