From 47d132dfd7e99bcc7bb6ffc968943753f91327fd Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 9 Apr 2026 13:50:55 +0800 Subject: [PATCH 01/10] feat(user-center): show authoritative billing usage details --- .../user-center/account/SubscriptionPanel.tsx | 51 ++++++++++- .../__tests__/SubscriptionPanel.test.tsx | 90 +++++++++++++++++++ .../user-center/lib/fetchAccountUsage.test.ts | 29 +++++- .../user-center/lib/fetchAccountUsage.ts | 42 +++++++++ 4 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 src/modules/extensions/builtin/user-center/account/__tests__/SubscriptionPanel.test.tsx diff --git a/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx b/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx index 08ff8f4..94245cf 100644 --- a/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx +++ b/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx @@ -5,7 +5,7 @@ import useSWR from "swr"; import { openStripePortal } from "@components/billing/stripe-client"; import Card from "../components/Card"; -import { fetchAccountPolicy, fetchAccountUsageSummary } from "../lib/fetchAccountUsage"; +import { fetchAccountBillingSummary, fetchAccountPolicy, fetchAccountUsageSummary } from "../lib/fetchAccountUsage"; const fetcher = (url: string) => fetch(url, { @@ -50,6 +50,7 @@ export default function SubscriptionPanel() { fetcher, ); const { data: usageSummary } = useSWR("account-usage-summary", fetchAccountUsageSummary); + const { data: billingSummary } = useSWR("account-billing-summary", fetchAccountBillingSummary); const { data: accountPolicy } = useSWR("account-policy", fetchAccountPolicy); const [submitting, setSubmitting] = useState(null); const [portalLoading, setPortalLoading] = useState(false); @@ -139,6 +140,9 @@ export default function SubscriptionPanel() {

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

+

+ 数据源:{usageSummary.sourceOfTruth || "—"} +

@@ -154,6 +158,10 @@ export default function SubscriptionPanel() { ? `${usageSummary.remainingIncludedQuota.toLocaleString()} B` : "—"}

+

+ 套餐 {usageSummary.billingProfile?.packageName || billingSummary?.billingProfile?.packageName || "default"}, + 规则 {usageSummary.billingProfile?.pricingRuleVersion || billingSummary?.billingProfile?.pricingRuleVersion || "—"} +

@@ -166,6 +174,47 @@ export default function SubscriptionPanel() { 统计延迟约 {usageSummary.syncDelaySeconds ?? 0} 秒,策略组{" "} {accountPolicy?.eligibleNodeGroups?.join(", ") || "—"}

+

+ 状态 {usageSummary.arrears ? "欠费" : "正常"} / {usageSummary.throttleState || "—"} / {usageSummary.suspendState || "—"} +

+
+ + ) : null} + + {billingSummary?.ledger?.length ? ( +
+
+
+

Recent Billing Ledger

+

+ 展示 accounts.svc.plus 返回的最新按量计费分录。 +

+
+

+ 数据源:{billingSummary.sourceOfTruth || "—"} +

+
+
+ {billingSummary.ledger.slice(0, 5).map((entry) => ( +
+
+

{entry.entryType}

+

+ {entry.pricingRuleVersion || "—"} · {entry.bucketStart ? formatDate(entry.bucketStart) : "—"} +

+
+
+

{entry.ratedBytes.toLocaleString()} B

+

+ {typeof entry.amountDelta === "number" ? entry.amountDelta.toFixed(2) : "—"} / 余额{" "} + {typeof entry.balanceAfter === "number" ? entry.balanceAfter.toFixed(2) : "—"} +

+
+
+ ))}
) : null} diff --git a/src/modules/extensions/builtin/user-center/account/__tests__/SubscriptionPanel.test.tsx b/src/modules/extensions/builtin/user-center/account/__tests__/SubscriptionPanel.test.tsx new file mode 100644 index 0000000..87b66d3 --- /dev/null +++ b/src/modules/extensions/builtin/user-center/account/__tests__/SubscriptionPanel.test.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import SubscriptionPanel from '../SubscriptionPanel' + +vi.mock('swr', () => ({ + default: vi.fn((key: string) => { + if (key === '/api/auth/subscriptions') { + return { + data: { subscriptions: [] }, + isLoading: false, + mutate: vi.fn(), + } + } + if (key === 'account-usage-summary') { + return { + data: { + totalBytes: 384, + currentBalance: 87.5, + remainingIncludedQuota: 2048, + syncDelaySeconds: 12, + arrears: false, + sourceOfTruth: 'postgresql', + billingProfile: { + packageName: 'starter', + pricingRuleVersion: 'pricing-v1', + }, + }, + } + } + if (key === 'account-billing-summary') { + return { + data: { + sourceOfTruth: 'postgresql', + billingProfile: { + packageName: 'starter', + pricingRuleVersion: 'pricing-v1', + }, + ledger: [ + { + id: 'ledger-1', + entryType: 'traffic_charge', + ratedBytes: 50, + amountDelta: -12.5, + balanceAfter: 75, + pricingRuleVersion: 'pricing-v1', + bucketStart: '2026-04-08T10:30:00Z', + }, + ], + }, + } + } + if (key === 'account-policy') { + return { + data: { + preferredStrategy: 'ewma', + eligibleNodeGroups: ['hk-premium'], + }, + } + } + return { data: undefined, isLoading: false, mutate: vi.fn() } + }), +})) + +vi.mock('@components/billing/stripe-client', () => ({ + openStripePortal: vi.fn(), +})) + +vi.mock('../../lib/fetchAccountUsage', () => ({ + fetchAccountUsageSummary: vi.fn(), + fetchAccountBillingSummary: vi.fn(), + fetchAccountPolicy: vi.fn(), +})) + +describe('SubscriptionPanel', () => { + it('renders accounts-backed source-of-truth usage metadata', () => { + render() + + expect(screen.getByText('Authoritative Usage')).toBeInTheDocument() + expect(screen.getByText('统计由 accounts.svc.plus 汇总,非本地客户端计数。')).toBeInTheDocument() + expect(screen.getAllByText('数据源:postgresql')).toHaveLength(2) + expect(screen.getByText('384 B')).toBeInTheDocument() + expect(screen.getByText(/统计延迟约 12 秒/)).toBeInTheDocument() + expect(screen.getByText(/策略组 hk-premium/)).toBeInTheDocument() + expect(screen.getByText((content) => content.includes('套餐') && content.includes('starter') && content.includes('pricing-v1'))).toBeInTheDocument() + expect(screen.getByText('Recent Billing Ledger')).toBeInTheDocument() + expect(screen.getByText(/traffic_charge/)).toBeInTheDocument() + }) +}) diff --git a/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts index 80b7480..86956ac 100644 --- a/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts +++ b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { fetchAccountPolicy, fetchAccountUsageSummary } from './fetchAccountUsage' +import { fetchAccountBillingSummary, fetchAccountPolicy, fetchAccountUsageSummary } from './fetchAccountUsage' describe('fetchAccountUsage', () => { afterEach(() => { @@ -9,7 +9,7 @@ describe('fetchAccountUsage', () => { 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 }), { + new Response(JSON.stringify({ accountUuid: 'acct-1', totalBytes: 384, sourceOfTruth: 'postgresql' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }), @@ -18,6 +18,7 @@ describe('fetchAccountUsage', () => { await expect(fetchAccountUsageSummary()).resolves.toEqual({ accountUuid: 'acct-1', totalBytes: 384, + sourceOfTruth: 'postgresql', }) }) @@ -34,4 +35,28 @@ describe('fetchAccountUsage', () => { preferredStrategy: 'ewma', }) }) + + it('loads the authoritative billing summary from the account api', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + accountUuid: 'acct-1', + sourceOfTruth: 'postgresql', + billingProfile: { packageName: 'starter', pricingRuleVersion: 'pricing-v1' }, + ledger: [{ id: 'ledger-1', entryType: 'traffic_charge', ratedBytes: 50, amountDelta: -12.5, balanceAfter: 87.5 }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + await expect(fetchAccountBillingSummary()).resolves.toEqual({ + accountUuid: 'acct-1', + sourceOfTruth: 'postgresql', + billingProfile: { packageName: 'starter', pricingRuleVersion: 'pricing-v1' }, + ledger: [{ id: 'ledger-1', entryType: 'traffic_charge', ratedBytes: 50, amountDelta: -12.5, balanceAfter: 87.5 }], + }) + }) }) diff --git a/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts index 320a483..1f77392 100644 --- a/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts +++ b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts @@ -7,6 +7,7 @@ type AccountUsageError = Error & { export type AccountUsageSummary = { accountUuid: string totalBytes: number + sourceOfTruth?: string uplinkBytes?: number downlinkBytes?: number currentBalance?: number @@ -14,6 +15,8 @@ export type AccountUsageSummary = { syncDelaySeconds?: number suspendState?: string throttleState?: string + arrears?: boolean + billingProfile?: AccountBillingProfile } export type AccountPolicy = { @@ -24,6 +27,41 @@ export type AccountPolicy = { degradeMode?: string } +export type AccountBillingProfile = { + packageName?: string + includedQuotaBytes?: number + basePricePerByte?: number + regionMultiplier?: number + lineMultiplier?: number + pricingRuleVersion?: string +} + +export type BillingLedgerEntry = { + id: string + entryType: string + ratedBytes: number + amountDelta: number + balanceAfter: number + pricingRuleVersion?: string + bucketStart?: string + bucketEnd?: string + createdAt?: string +} + +export type AccountBillingSummary = { + accountUuid: string + sourceOfTruth?: string + quotaState?: { + currentBalance?: number + remainingIncludedQuota?: number + arrears?: boolean + throttleState?: string + suspendState?: string + } + billingProfile?: AccountBillingProfile + ledger?: BillingLedgerEntry[] +} + function toError(payload: unknown, status: number): AccountUsageError { const message = payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string' @@ -59,3 +97,7 @@ export function fetchAccountUsageSummary(): Promise { export function fetchAccountPolicy(): Promise { return requestJSON('/api/account/policy') } + +export function fetchAccountBillingSummary(): Promise { + return requestJSON('/api/account/billing/summary') +} From f10914bbe77d7a3471fc34ad160f8bc6aa34f324 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 9 Apr 2026 14:04:36 +0800 Subject: [PATCH 02/10] fix(user-center): narrow agent node error payload typing --- .../builtin/user-center/lib/fetchAgentNodes.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts b/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts index ea53927..d5b5d28 100644 --- a/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts +++ b/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts @@ -18,11 +18,17 @@ type AgentNodesError = Error & { status?: number } +function isAgentNodeErrorPayload( + payload: AgentNodePayload | null, +): payload is Exclude { + return !!payload && !Array.isArray(payload) +} + function extractMessage(payload: AgentNodePayload | null, status: number): string { - if (payload && typeof payload.message === 'string' && payload.message.trim().length > 0) { + if (isAgentNodeErrorPayload(payload) && typeof payload.message === 'string' && payload.message.trim().length > 0) { return payload.message } - if (payload && typeof payload.error === 'string' && payload.error.trim().length > 0) { + if (isAgentNodeErrorPayload(payload) && typeof payload.error === 'string' && payload.error.trim().length > 0) { return payload.error } return `Request failed (${status})` From a6fa674ca59074bcba744a15bce99b8463e8873c Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 08:58:58 +0800 Subject: [PATCH 03/10] Refactor frontend release workflow into pipeline stages --- .github/workflows/build-push-ghcr-image.yml | 90 -------- .github/workflows/pipeline.yaml | 225 ++++++++++++++++++++ 2 files changed, 225 insertions(+), 90 deletions(-) delete mode 100644 .github/workflows/build-push-ghcr-image.yml create mode 100644 .github/workflows/pipeline.yaml diff --git a/.github/workflows/build-push-ghcr-image.yml b/.github/workflows/build-push-ghcr-image.yml deleted file mode 100644 index 92df463..0000000 --- a/.github/workflows/build-push-ghcr-image.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Build And Push GHCR Image - -on: - push: - branches: - - main - paths: - - '.github/workflows/build-push-ghcr-image.yml' - - 'Dockerfile' - - 'package.json' - - 'yarn.lock' - - 'scripts/github-actions/build-and-push-frontend-image.sh' - - 'scripts/github-actions/compute-frontend-release-metadata.sh' - - 'scripts/github-actions/render-frontend-build-args.sh' - - 'scripts/github-actions/prepare-frontend-build-context.sh' - - 'scripts/prebuild.sh' - - 'contentlayer.config.ts' - - 'next.config.js' - - 'next.config.mjs' - - 'src/**' - - 'public/**' - workflow_call: - inputs: - image_tag: - description: Optional image tag. Defaults to the current commit SHA. - required: false - type: string - push_latest: - description: Also publish the `latest` tag. - required: false - default: false - type: boolean - workflow_dispatch: - inputs: - image_tag: - description: Optional image tag. Defaults to the current commit SHA. - required: false - type: string - push_latest: - description: Also publish the `latest` tag. - required: false - default: false - type: boolean - -permissions: - contents: read - packages: write - -concurrency: - group: build-push-ghcr-image-console-${{ github.ref_name }} - cancel-in-progress: false - -jobs: - build-and-push: - runs-on: ubuntu-latest - env: - PRIMARY_DOMAIN: console.svc.plus - NEXT_PUBLIC_RUNTIME_ENVIRONMENT: prod - NEXT_PUBLIC_RUNTIME_REGION: cn - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }} - steps: - - name: Check Out Repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - - name: Set Up Docker Buildx - run: | - docker buildx create --name console-builder --use >/dev/null 2>&1 || docker buildx use console-builder - docker buildx inspect --bootstrap - - - name: Log In To GHCR - env: - GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.repository_owner }} - GHCR_TOKEN: ${{ secrets.GHCR_TOKEN || github.token }} - run: | - echo "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USERNAME}" --password-stdin - - - name: Compute Image Metadata - id: metadata - env: - IMAGE_TAG_INPUT: ${{ inputs.image_tag }} - run: | - bash scripts/github-actions/compute-frontend-release-metadata.sh "${IMAGE_TAG_INPUT}" - - - name: Publish Frontend Image - env: - IMAGE_REF: ${{ steps.metadata.outputs.image_ref }} - IMAGE_LATEST_REF: ${{ steps.metadata.outputs.image_latest_ref }} - PUSH_LATEST: ${{ inputs.push_latest || github.ref == 'refs/heads/main' }} - run: | - bash scripts/github-actions/build-and-push-frontend-image.sh diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml new file mode 100644 index 0000000..4f0fafa --- /dev/null +++ b/.github/workflows/pipeline.yaml @@ -0,0 +1,225 @@ +name: Console Service Pipeline + +on: + push: + branches: + - main + paths: + - ".github/workflows/pipeline.yaml" + - "Dockerfile" + - "package.json" + - "yarn.lock" + - "scripts/github-actions/build-and-push-frontend-image.sh" + - "scripts/github-actions/compute-frontend-release-metadata.sh" + - "scripts/github-actions/render-frontend-build-args.sh" + - "scripts/github-actions/prepare-frontend-build-context.sh" + - "scripts/github-actions/verify-frontend-release.sh" + - "scripts/prebuild.sh" + - "contentlayer.config.ts" + - "next.config.js" + - "next.config.mjs" + - "src/**" + - "public/**" + workflow_dispatch: + inputs: + target_host: + description: Ansible host or alias + required: false + default: "jp-xhttp-contabo.svc.plus" + type: string + run_apply: + description: Apply deployment + required: true + default: true + type: boolean + internal_service_token: + description: Optional ACP auth token + required: false + default: "" + type: string + +permissions: + contents: read + packages: write + +concurrency: + group: console-pipeline-${{ github.ref_name }} + cancel-in-progress: false + +env: + PRIMARY_DOMAIN: console.svc.plus + SECONDARY_DOMAIN: console.onwalk.net + NEXT_PUBLIC_RUNTIME_ENVIRONMENT: prod + NEXT_PUBLIC_RUNTIME_REGION: cn + GHCR_REGISTRY: ghcr.io + +jobs: + prep: + name: Prep + runs-on: ubuntu-latest + outputs: + target_host: ${{ steps.inputs.outputs.target_host }} + run_apply: ${{ steps.inputs.outputs.run_apply }} + internal_service_token: ${{ steps.inputs.outputs.internal_service_token }} + image_tag: ${{ steps.metadata.outputs.image_tag }} + image_ref: ${{ steps.metadata.outputs.image_ref }} + image_latest_ref: ${{ steps.metadata.outputs.image_latest_ref }} + ghcr_namespace: ${{ steps.metadata.outputs.ghcr_namespace }} + push_latest: ${{ steps.push.outputs.push_latest }} + steps: + - name: Check Out Repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Resolve Inputs + id: inputs + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TARGET_HOST: ${{ inputs.target_host }} + INPUT_RUN_APPLY: ${{ inputs.run_apply }} + INPUT_INTERNAL_SERVICE_TOKEN: ${{ inputs.internal_service_token }} + run: | + if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then + target_host="${INPUT_TARGET_HOST}" + run_apply="${INPUT_RUN_APPLY}" + internal_service_token="${INPUT_INTERNAL_SERVICE_TOKEN}" + else + target_host="jp-xhttp-contabo.svc.plus" + run_apply="true" + internal_service_token="" + fi + + { + printf 'target_host=%s\n' "${target_host}" + printf 'run_apply=%s\n' "${run_apply}" + printf 'internal_service_token=%s\n' "${internal_service_token}" + } >> "${GITHUB_OUTPUT}" + + - name: Compute Image Metadata + id: metadata + run: | + bash scripts/github-actions/compute-frontend-release-metadata.sh + + - name: Resolve Push Latest + id: push + env: + REF: ${{ github.ref }} + run: | + if [[ "${REF}" == "refs/heads/main" ]]; then + echo "push_latest=true" >> "${GITHUB_OUTPUT}" + else + echo "push_latest=false" >> "${GITHUB_OUTPUT}" + fi + + build: + name: Build + runs-on: ubuntu-latest + needs: prep + env: + PRIMARY_DOMAIN: ${{ env.PRIMARY_DOMAIN }} + NEXT_PUBLIC_RUNTIME_ENVIRONMENT: ${{ env.NEXT_PUBLIC_RUNTIME_ENVIRONMENT }} + NEXT_PUBLIC_RUNTIME_REGION: ${{ env.NEXT_PUBLIC_RUNTIME_REGION }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }} + outputs: + image_ref: ${{ needs.prep.outputs.image_ref }} + image_tag: ${{ needs.prep.outputs.image_tag }} + steps: + - name: Check Out Repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Set Up Docker Buildx + run: | + docker buildx create --name console-builder --use >/dev/null 2>&1 || docker buildx use console-builder + docker buildx inspect --bootstrap + + - name: Log In To GHCR + env: + GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.repository_owner }} + GHCR_TOKEN: ${{ secrets.GHCR_TOKEN || github.token }} + run: | + echo "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USERNAME}" --password-stdin + + - name: Publish Frontend Image + env: + IMAGE_REF: ${{ needs.prep.outputs.image_ref }} + IMAGE_LATEST_REF: ${{ needs.prep.outputs.image_latest_ref }} + PUSH_LATEST: ${{ needs.prep.outputs.push_latest }} + run: | + bash scripts/github-actions/build-and-push-frontend-image.sh + + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: + - prep + - build + env: + TARGET_HOST: ${{ needs.prep.outputs.target_host }} + RUN_APPLY: ${{ needs.prep.outputs.run_apply }} + FRONTEND_IMAGE: ${{ needs.prep.outputs.image_ref }} + INTERNAL_SERVICE_TOKEN: ${{ needs.prep.outputs.internal_service_token }} + PLAYBOOKS_REPO: git@github.com:x-evor/playbooks.git + steps: + - name: Check Out Repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Set Up Python + uses: actions/setup-python@42375524a763eb7d2e0d429c75e7d708d93f2851 + with: + python-version: "3.11" + + - name: Install Ansible + run: | + python -m pip install --upgrade pip + python -m pip install ansible + + - name: Configure SSH For Playbooks Checkout + env: + PLAYBOOKS_REPO_SSH_KEY: ${{ secrets.PLAYBOOKS_REPO_SSH_KEY }} + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "${PLAYBOOKS_REPO_SSH_KEY}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan github.com >> ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + - name: Check Out Playbooks Repository + run: | + git clone "${PLAYBOOKS_REPO}" /tmp/playbooks + + - name: Run Deploy Playbook + env: + ANSIBLE_HOST_KEY_CHECKING: "False" + run: | + cd /tmp/playbooks + + ansible_args=( + deploy_console_svc_plus.yml + --limit "${TARGET_HOST}" + --extra-vars "target_host=${TARGET_HOST}" + --extra-vars "frontend_image=${FRONTEND_IMAGE}" + --extra-vars "image_ref=${FRONTEND_IMAGE}" + --extra-vars "internal_service_token=${INTERNAL_SERVICE_TOKEN}" + --extra-vars "run_apply=${RUN_APPLY}" + ) + + if [[ "${RUN_APPLY}" != "true" ]]; then + ansible_args+=(--check --diff) + fi + + ansible-playbook "${ansible_args[@]}" + + validate: + name: Validate + runs-on: ubuntu-latest + needs: + - prep + - deploy + if: ${{ always() && needs.deploy.result == 'success' }} + steps: + - name: Check Out Repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Verify Frontend Release + run: | + bash scripts/github-actions/verify-frontend-release.sh "${PRIMARY_DOMAIN}" "${SECONDARY_DOMAIN}" From b84d0079a675e7fee54b76c8c9f37c7797309868 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 09:00:35 +0800 Subject: [PATCH 04/10] Align console deploy job with playbooks contract --- .github/workflows/pipeline.yaml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 4f0fafa..326e46e 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -158,6 +158,8 @@ jobs: FRONTEND_IMAGE: ${{ needs.prep.outputs.image_ref }} INTERNAL_SERVICE_TOKEN: ${{ needs.prep.outputs.internal_service_token }} PLAYBOOKS_REPO: git@github.com:x-evor/playbooks.git + GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.repository_owner }} + GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN || github.token }} steps: - name: Check Out Repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 @@ -193,14 +195,17 @@ jobs: run: | cd /tmp/playbooks + if [[ "${RUN_APPLY}" == "true" ]]; then + dns_sync="true" + else + dns_sync="false" + fi + ansible_args=( + -i inventory.ini deploy_console_svc_plus.yml - --limit "${TARGET_HOST}" - --extra-vars "target_host=${TARGET_HOST}" - --extra-vars "frontend_image=${FRONTEND_IMAGE}" - --extra-vars "image_ref=${FRONTEND_IMAGE}" - --extra-vars "internal_service_token=${INTERNAL_SERVICE_TOKEN}" - --extra-vars "run_apply=${RUN_APPLY}" + -l "${TARGET_HOST}" + -e "console_service_sync_dns=${dns_sync}" ) if [[ "${RUN_APPLY}" != "true" ]]; then From bb693ce46387ef0ecee8185a0240408d253efb68 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 09:11:47 +0800 Subject: [PATCH 05/10] Fix GitHub Actions env references in pipeline --- .github/workflows/pipeline.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 326e46e..66470bf 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -115,9 +115,6 @@ jobs: runs-on: ubuntu-latest needs: prep env: - PRIMARY_DOMAIN: ${{ env.PRIMARY_DOMAIN }} - NEXT_PUBLIC_RUNTIME_ENVIRONMENT: ${{ env.NEXT_PUBLIC_RUNTIME_ENVIRONMENT }} - NEXT_PUBLIC_RUNTIME_REGION: ${{ env.NEXT_PUBLIC_RUNTIME_REGION }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }} outputs: image_ref: ${{ needs.prep.outputs.image_ref }} From d6d891c809a9c262f44d49fe1347c15255b5f2fd Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 09:21:48 +0800 Subject: [PATCH 06/10] Upgrade GitHub Actions runtime-compatible actions --- .github/workflows/pipeline.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 66470bf..35b4f3c 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -68,7 +68,7 @@ jobs: push_latest: ${{ steps.push.outputs.push_latest }} steps: - name: Check Out Repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@v5 - name: Resolve Inputs id: inputs @@ -121,7 +121,7 @@ jobs: image_tag: ${{ needs.prep.outputs.image_tag }} steps: - name: Check Out Repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@v5 - name: Set Up Docker Buildx run: | @@ -159,10 +159,10 @@ jobs: GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN || github.token }} steps: - name: Check Out Repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@v5 - name: Set Up Python - uses: actions/setup-python@42375524a763eb7d2e0d429c75e7d708d93f2851 + uses: actions/setup-python@v6 with: python-version: "3.11" @@ -220,7 +220,7 @@ jobs: if: ${{ always() && needs.deploy.result == 'success' }} steps: - name: Check Out Repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@v5 - name: Verify Frontend Release run: | From 4a08ff360f0e141a46265a5175777cc7c6076475 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 10:13:37 +0800 Subject: [PATCH 07/10] Simplify deploy playbook invocation --- .github/workflows/pipeline.yaml | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 35b4f3c..998b75e 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -32,11 +32,6 @@ on: required: true default: true type: boolean - internal_service_token: - description: Optional ACP auth token - required: false - default: "" - type: string permissions: contents: read @@ -52,6 +47,9 @@ env: NEXT_PUBLIC_RUNTIME_ENVIRONMENT: prod NEXT_PUBLIC_RUNTIME_REGION: cn GHCR_REGISTRY: ghcr.io + GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} + GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN }} + INTERNAL_SERVICE_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }} jobs: prep: @@ -60,7 +58,6 @@ jobs: outputs: target_host: ${{ steps.inputs.outputs.target_host }} run_apply: ${{ steps.inputs.outputs.run_apply }} - internal_service_token: ${{ steps.inputs.outputs.internal_service_token }} image_tag: ${{ steps.metadata.outputs.image_tag }} image_ref: ${{ steps.metadata.outputs.image_ref }} image_latest_ref: ${{ steps.metadata.outputs.image_latest_ref }} @@ -76,22 +73,18 @@ jobs: EVENT_NAME: ${{ github.event_name }} INPUT_TARGET_HOST: ${{ inputs.target_host }} INPUT_RUN_APPLY: ${{ inputs.run_apply }} - INPUT_INTERNAL_SERVICE_TOKEN: ${{ inputs.internal_service_token }} run: | if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then target_host="${INPUT_TARGET_HOST}" run_apply="${INPUT_RUN_APPLY}" - internal_service_token="${INPUT_INTERNAL_SERVICE_TOKEN}" else target_host="jp-xhttp-contabo.svc.plus" run_apply="true" - internal_service_token="" fi { printf 'target_host=%s\n' "${target_host}" printf 'run_apply=%s\n' "${run_apply}" - printf 'internal_service_token=%s\n' "${internal_service_token}" } >> "${GITHUB_OUTPUT}" - name: Compute Image Metadata @@ -130,8 +123,7 @@ jobs: - name: Log In To GHCR env: - GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.repository_owner }} - GHCR_TOKEN: ${{ secrets.GHCR_TOKEN || github.token }} + GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} run: | echo "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USERNAME}" --password-stdin @@ -153,10 +145,7 @@ jobs: TARGET_HOST: ${{ needs.prep.outputs.target_host }} RUN_APPLY: ${{ needs.prep.outputs.run_apply }} FRONTEND_IMAGE: ${{ needs.prep.outputs.image_ref }} - INTERNAL_SERVICE_TOKEN: ${{ needs.prep.outputs.internal_service_token }} PLAYBOOKS_REPO: git@github.com:x-evor/playbooks.git - GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.repository_owner }} - GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN || github.token }} steps: - name: Check Out Repository uses: actions/checkout@v5 @@ -192,21 +181,19 @@ jobs: run: | cd /tmp/playbooks - if [[ "${RUN_APPLY}" == "true" ]]; then - dns_sync="true" - else - dns_sync="false" - fi - ansible_args=( -i inventory.ini deploy_console_svc_plus.yml + -D -l "${TARGET_HOST}" - -e "console_service_sync_dns=${dns_sync}" + -e "FRONTEND_IMAGE=${FRONTEND_IMAGE}" + -e "GHCR_USERNAME=${GHCR_USERNAME}" + -e "GHCR_PASSWORD=${GHCR_PASSWORD}" + -e "INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN}" ) if [[ "${RUN_APPLY}" != "true" ]]; then - ansible_args+=(--check --diff) + ansible_args=(-C "${ansible_args[@]}") fi ansible-playbook "${ansible_args[@]}" From 1ec1cbf9ccaac575bfce1df8aa747f56ed729695 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 10:31:09 +0800 Subject: [PATCH 08/10] Finalize deploy workflow env and playbook args --- .github/workflows/pipeline.yaml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 998b75e..c8f37f4 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -46,10 +46,16 @@ env: SECONDARY_DOMAIN: console.onwalk.net NEXT_PUBLIC_RUNTIME_ENVIRONMENT: prod NEXT_PUBLIC_RUNTIME_REGION: cn + ACCOUNT_SERVICE_URL: https://accounts.svc.plus + CLOUDFLARE_ZONE_TAG: bf3427f83a2c52c8285ab3d741a6ee27 + CLOUDFLARE_WEB_ANALYTICS_SITE_TAG: 0973e84ec8872c67c570f8072e92e21b + CLOUDFLARE_ACCOUNT_ID: e71be5efb76a6c54f78f008da4404f00 GHCR_REGISTRY: ghcr.io GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN }} INTERNAL_SERVICE_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_DNS_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }} jobs: prep: @@ -107,8 +113,6 @@ jobs: name: Build runs-on: ubuntu-latest needs: prep - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }} outputs: image_ref: ${{ needs.prep.outputs.image_ref }} image_tag: ${{ needs.prep.outputs.image_tag }} @@ -190,6 +194,16 @@ jobs: -e "GHCR_USERNAME=${GHCR_USERNAME}" -e "GHCR_PASSWORD=${GHCR_PASSWORD}" -e "INTERNAL_SERVICE_TOKEN=${INTERNAL_SERVICE_TOKEN}" + -e "ACCOUNT_SERVICE_URL=${ACCOUNT_SERVICE_URL}" + -e "PRIMARY_DOMAIN=${PRIMARY_DOMAIN}" + -e "SECONDARY_DOMAIN=${SECONDARY_DOMAIN}" + -e "NEXT_PUBLIC_RUNTIME_ENVIRONMENT=${NEXT_PUBLIC_RUNTIME_ENVIRONMENT}" + -e "NEXT_PUBLIC_RUNTIME_REGION=${NEXT_PUBLIC_RUNTIME_REGION}" + -e "CLOUDFLARE_ZONE_TAG=${CLOUDFLARE_ZONE_TAG}" + -e "CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=${CLOUDFLARE_WEB_ANALYTICS_SITE_TAG}" + -e "CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID}" + -e "CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}" + -e "CLOUDFLARE_DNS_API_TOKEN=${CLOUDFLARE_DNS_API_TOKEN}" ) if [[ "${RUN_APPLY}" != "true" ]]; then From 3d36f8dd30c154e2c1e8d28d6872154d23737652 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 11:23:41 +0800 Subject: [PATCH 09/10] Fix GitHub Actions policy pins --- .github/workflows/pipeline.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index c8f37f4..0826f78 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -71,7 +71,7 @@ jobs: push_latest: ${{ steps.push.outputs.push_latest }} steps: - name: Check Out Repository - uses: actions/checkout@v5 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Resolve Inputs id: inputs @@ -118,7 +118,7 @@ jobs: image_tag: ${{ needs.prep.outputs.image_tag }} steps: - name: Check Out Repository - uses: actions/checkout@v5 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set Up Docker Buildx run: | @@ -152,10 +152,10 @@ jobs: PLAYBOOKS_REPO: git@github.com:x-evor/playbooks.git steps: - name: Check Out Repository - uses: actions/checkout@v5 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set Up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.11" @@ -221,7 +221,7 @@ jobs: if: ${{ always() && needs.deploy.result == 'success' }} steps: - name: Check Out Repository - uses: actions/checkout@v5 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Verify Frontend Release run: | From fe81be287407e82cabab77c5ff063613be7e8409 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 11:59:44 +0800 Subject: [PATCH 10/10] Fix agent nodes proxy response headers --- src/lib/apiProxy.test.ts | 52 +++++++++++++++++++ src/lib/apiProxy.ts | 11 +++- .../user-center/lib/fetchAgentNodes.test.ts | 2 +- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/lib/apiProxy.test.ts diff --git a/src/lib/apiProxy.test.ts b/src/lib/apiProxy.test.ts new file mode 100644 index 0000000..54e3d2f --- /dev/null +++ b/src/lib/apiProxy.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' + +import { proxyRequestToUpstream } from './apiProxy' + +describe('proxyRequestToUpstream', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('strips stale encoding headers when the upstream body is already decoded', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify([{ name: 'JP', address: 'jp-xhttp.svc.plus' }]), { + status: 200, + headers: { + 'Content-Encoding': 'gzip', + 'Content-Length': '405', + 'Content-Type': 'application/json; charset=utf-8', + }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const response = await proxyRequestToUpstream( + new NextRequest('https://console.svc.plus/api/agent/nodes', { + headers: { + host: 'console.svc.plus', + }, + }), + { + upstreamBaseUrl: 'https://accounts.svc.plus', + upstreamPathPrefix: '/api/agent', + }, + ) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://accounts.svc.plus/api/agent/nodes', + expect.objectContaining({ + cache: 'no-store', + method: 'GET', + redirect: 'manual', + }), + ) + expect(response.status).toBe(200) + expect(response.headers.get('content-encoding')).toBeNull() + expect(response.headers.get('content-length')).toBeNull() + expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8') + await expect(response.json()).resolves.toEqual([{ name: 'JP', address: 'jp-xhttp.svc.plus' }]) + }) +}) diff --git a/src/lib/apiProxy.ts b/src/lib/apiProxy.ts index 19fff40..86d2a3a 100644 --- a/src/lib/apiProxy.ts +++ b/src/lib/apiProxy.ts @@ -14,6 +14,14 @@ const DEFAULT_FORWARD_HEADERS = [ 'x-trace-id', ] as const +const RESPONSE_HEADERS_TO_STRIP = new Set([ + 'connection', + 'content-encoding', + 'content-length', + 'keep-alive', + 'transfer-encoding', +]) + const BODYLESS_METHODS = new Set(['GET', 'HEAD']) type ProxyOptions = { @@ -107,7 +115,8 @@ export async function proxyRequestToUpstream(request: NextRequest, options: Prox const responseHeaders = new Headers() upstreamResponse.headers.forEach((value, key) => { - if (key.toLowerCase() === 'set-cookie') { + const normalizedKey = key.toLowerCase() + if (normalizedKey === 'set-cookie' || RESPONSE_HEADERS_TO_STRIP.has(normalizedKey)) { return } responseHeaders.set(key, value) diff --git a/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.test.ts b/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.test.ts index caff97e..8ec685c 100644 --- a/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.test.ts +++ b/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.test.ts @@ -20,7 +20,7 @@ describe('fetchAgentNodes', () => { await expect(fetchAgentNodes()).resolves.toEqual([{ name: 'JP', address: 'jp-xhttp.svc.plus' }]) expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledWith( - '/api/agent-server/v1/nodes', + '/api/auth/sync/config?since_version=0', expect.objectContaining({ cache: 'no-store', credentials: 'include',