diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..4e35786 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,60 @@ +name: End-to-End Tests + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + BUN_VERSION: 1.3.14 + CI: true + NEXT_TELEMETRY_DISABLED: '1' + +jobs: + playwright: + name: Playwright / Chromium + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Cache Bun package downloads + uses: actions/cache@v6 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-${{ env.BUN_VERSION }}- + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + - name: Run E2E tests + run: bun run test:e2e + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + if-no-files-found: ignore diff --git a/app/account-status/account-status.tsx b/app/account-status/account-status.tsx new file mode 100644 index 0000000..5fa302d --- /dev/null +++ b/app/account-status/account-status.tsx @@ -0,0 +1,407 @@ +'use client'; + +import { + Alert, + Block, + Collapse, + Empty, + Flexbox, + SkeletonButton, + SkeletonParagraph, + SkeletonTags, + SkeletonTitle, + Tag, + Text, + Tooltip, +} from '@lobehub/ui'; +import { Button } from '@lobehub/ui/base-ui'; +import { RefreshCw } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { CredentialSummary } from '@/app/credentials/credentials'; + +interface AccountStatusProps { + credentials: CredentialSummary[]; +} + +interface AccountStatusSnapshot { + checkin: { claimed: boolean | null; message: string | null }; + credits: { + total: number | null; + used: number | null; + remaining: number | null; + plan: string | null; + resetAt: string | null; + }; + error: string | null; + filename: string; + models: string[]; + queriedAt: string; +} + +const initialSnapshot = (filename: string): AccountStatusSnapshot => ({ + checkin: { claimed: null, message: null }, + credits: { + total: null, + used: null, + remaining: null, + plan: null, + resetAt: null, + }, + error: null, + filename, + models: [], + queriedAt: '', +}); + +const unavailableSnapshot = (filename: string): AccountStatusSnapshot => ({ + ...initialSnapshot(filename), + error: 'Credential is unavailable', +}); + +const failedSnapshot = ( + filename: string, + error: unknown, +): AccountStatusSnapshot => ({ + ...initialSnapshot(filename), + error: error instanceof Error ? error.message : 'Account status query failed', + queriedAt: new Date().toISOString(), +}); + +const quotaPercent = (snapshot: AccountStatusSnapshot): number | null => { + const { total, used } = snapshot.credits; + if (total === null || total <= 0 || used === null) return null; + return Math.min(100, Math.max(0, (used / total) * 100)); +}; + +const QuotaProgress = ({ + snapshot, + unknownLabel, + usedLabel, +}: { + snapshot: AccountStatusSnapshot; + unknownLabel: string; + usedLabel: (percent: number) => string; +}) => { + const percent = quotaPercent(snapshot); + const tone = + percent === null + ? 'unknown' + : percent >= 100 + ? 'exhausted' + : percent >= 80 + ? 'warning' + : 'normal'; + return ( + + + {percent === null ? '—' : `${percent.toFixed(0)}%`} + + {snapshot.credits.used ?? '—'} / {snapshot.credits.total ?? '—'} + + + + + ); +}; + +const AccountStatusSkeleton = () => ( + + + + + + + +); + +const AccountStatusCard = ({ + credential, + snapshot, + busy, + onRefresh, + onCheckin, +}: { + credential: CredentialSummary; + snapshot: AccountStatusSnapshot; + busy: string | null; + onRefresh: () => void; + onCheckin: () => void; +}) => { + const text = useTranslations('Admin'); + const percent = quotaPercent(snapshot); + const quotaUnknown = text('accountStatus.quotaUnknown'); + const models = snapshot.models.slice(0, 8); + const hasMoreModels = snapshot.models.length > models.length; + return ( + + + + {credential.email || credential.user_id} + + + + {credential.filename} + + + + {snapshot.error ? : null} + + {text('accountStatus.quota')} + + text('accountStatus.quotaUsed', { percent: value.toFixed(0) }) + } + /> + + + {text('accountStatus.total')}: {snapshot.credits.total ?? '—'} + + + {text('accountStatus.used')}: {snapshot.credits.used ?? '—'} + + + {text('accountStatus.remaining')}:{' '} + {snapshot.credits.remaining ?? '—'} + + + + {text('accountStatus.plan')}: {snapshot.credits.plan ?? '—'} + + + {text('accountStatus.resetAt')}: {snapshot.credits.resetAt ?? '—'} + + {percent !== null && percent >= 100 ? ( + {text('accountStatus.exhausted')} + ) : null} + + + + {text('accountStatus.checkin')}:{' '} + {snapshot.checkin.claimed === true + ? text('accountStatus.checkedIn') + : snapshot.checkin.claimed === false + ? text('accountStatus.notCheckedIn') + : '—'} + + + + + {text('accountStatus.models')} + {models.length ? ( + + {snapshot.models.map((model) => ( + {model} + ))} + + ), + }, + ]} + variant="borderless" + /> + ) : ( + {text('accountStatus.noModels')} + )} + + + + {snapshot.queriedAt + ? new Date(snapshot.queriedAt).toLocaleString() + : '—'} + + + + + ); +}; + +const AccountStatus = ({ credentials }: AccountStatusProps) => { + const text = useTranslations('Admin'); + const [snapshots, setSnapshots] = useState< + Record + >({}); + const [busy, setBusy] = useState>({}); + const [page, setPage] = useState(1); + const [batchBusy, setBatchBusy] = useState(null); + const loadOne = useCallback( + async (filename: string, action: 'refresh' | 'checkin' = 'refresh') => { + setBusy((current) => ({ ...current, [filename]: action })); + try { + const response = await fetch('/admin-api/account-status', { + body: JSON.stringify({ action, filename }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + if (!response.ok) { + throw new Error(`Account status request failed (${response.status})`); + } + const payload = (await response.json()) as { + status?: AccountStatusSnapshot; + statuses?: AccountStatusSnapshot[]; + }; + const snapshot = payload.status ?? payload.statuses?.[0]; + if (snapshot) + setSnapshots((current) => ({ ...current, [filename]: snapshot })); + } catch (error) { + setSnapshots((current) => ({ + ...current, + [filename]: current[filename] ?? failedSnapshot(filename, error), + })); + } finally { + setBusy((current) => { + const next = { ...current }; + delete next[filename]; + return next; + }); + } + }, + [], + ); + const loadAll = useCallback( + async (action: 'refresh' | 'checkin') => { + setBatchBusy(action); + try { + await Promise.all( + credentials + .filter((credential) => !credential.is_expired) + .map((credential) => loadOne(credential.filename, action)), + ); + } finally { + setBatchBusy(null); + } + }, + [credentials, loadOne], + ); + useEffect(() => { + const timer = window.setTimeout(() => { + void loadAll('refresh'); + }, 0); + return () => window.clearTimeout(timer); + }, [loadAll]); + const pageCredentials = useMemo( + () => + credentials.length > 50 + ? credentials.slice((page - 1) * 12, page * 12) + : credentials, + [credentials, page], + ); + const pageCount = + credentials.length > 50 ? Math.ceil(credentials.length / 12) : 1; + return ( + + + {text('accountStatus.title')} + + + + + + {credentials.length ? ( + pageCredentials.map((credential) => { + const snapshot = snapshots[credential.filename]; + if (!snapshot && !credential.is_expired) { + return ; + } + return ( + void loadOne(credential.filename, 'checkin')} + onRefresh={() => void loadOne(credential.filename)} + /> + ); + }) + ) : ( + + )} + {pageCount > 1 ? ( + + + + {page} / {pageCount} + + + + ) : null} + + ); +}; + +export default AccountStatus; diff --git a/app/account-status/page.tsx b/app/account-status/page.tsx new file mode 100644 index 0000000..06916f8 --- /dev/null +++ b/app/account-status/page.tsx @@ -0,0 +1,15 @@ +import { AdminPage } from '@/app/page'; +import { getAccountStatusCredentials } from '@/lib/server/domain/account-status'; +import AccountStatus from './account-status'; + +const AccountStatusPage = async () => { + const credentials = await getAccountStatusCredentials(); + + return ( + + + + ); +}; + +export default AccountStatusPage; diff --git a/app/admin-api/account-status/route.ts b/app/admin-api/account-status/route.ts new file mode 100644 index 0000000..8ff3afa --- /dev/null +++ b/app/admin-api/account-status/route.ts @@ -0,0 +1,37 @@ +import { getAdminSessionErrorResponse } from '@/lib/server/admin/session'; +import { + checkinAccounts, + checkinAccount, + getAccountStatus, + getAccountStatusCredentials, +} from '@/lib/server/domain/account-status'; +import { getJsonBody } from '@/lib/server/shared/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const GET = async (request: Request): Promise => { + const authError = await getAdminSessionErrorResponse(request); + if (authError) return authError; + const credentials = await getAccountStatusCredentials(); + return Response.json({ credentials, statuses: await getAccountStatus() }); +}; + +export const POST = async (request: Request): Promise => { + const authError = await getAdminSessionErrorResponse(request); + if (authError) return authError; + const body = await getJsonBody<{ action?: unknown; filename?: unknown }>( + request, + ); + const filename = + typeof body.filename === 'string' ? body.filename.trim() : ''; + if (body.action === 'checkin') { + if (filename) { + return Response.json({ status: await checkinAccount(filename) }); + } + return Response.json({ statuses: await checkinAccounts() }); + } + return Response.json({ + statuses: await getAccountStatus(filename ? [filename] : undefined), + }); +}; diff --git a/app/globals.scss b/app/globals.scss index 5e9dc1a..ed2ab7f 100644 --- a/app/globals.scss +++ b/app/globals.scss @@ -266,6 +266,77 @@ textarea { padding: 24px 32px 48px; } +.account-status-progress { + display: block; + height: 8px; + width: 100%; + overflow: hidden; + border-radius: var(--lobe-border-radius-sm); + background: var(--lobe-color-fill-secondary); +} + +.account-status-progress::-webkit-progress-bar { + border-radius: inherit; + background: var(--lobe-color-fill-secondary); +} + +.account-status-progress::-webkit-progress-value { + border-radius: inherit; + background: var(--lobe-color-primary); + transition: width 180ms ease; +} + +.account-status-progress::-moz-progress-bar { + border-radius: inherit; + background: var(--lobe-color-primary); +} + +.account-status-progress-warning::-webkit-progress-value { + background: var(--lobe-color-warning, #d97706); +} + +.account-status-progress-warning::-moz-progress-bar { + background: var(--lobe-color-warning, #d97706); +} + +.account-status-progress-exhausted::-webkit-progress-value { + background: var(--lobe-color-error); +} + +.account-status-progress-exhausted::-moz-progress-bar { + background: var(--lobe-color-error); +} + +.account-status-progress-unknown::-webkit-progress-value, +.account-status-progress-unknown::-moz-progress-bar { + background: var(--lobe-color-fill); +} + +.account-status-card-busy { + opacity: 0.72; +} + +@media (max-width: 767px) { + .console-main { + padding-inline: 16px; + } + + .account-status-quota-values { + flex-direction: column; + gap: 8px; + } + + .account-status-card button { + flex: 1 1 0; + } +} + +@media (min-width: 768px) and (max-width: 1199px) { + .console-main { + padding-inline: 24px; + } +} + .usage-auto-refresh-control { align-items: center; display: inline-flex; diff --git a/app/page-data.ts b/app/page-data.ts index f5df143..43ab58f 100644 --- a/app/page-data.ts +++ b/app/page-data.ts @@ -8,7 +8,13 @@ import type { import type { AdminDebugSnapshot } from '@/app/debug/debug'; export type TabKey = - 'dashboard' | 'usage' | 'credentials' | 'api-test' | 'debug' | 'settings'; + | 'dashboard' + | 'usage' + | 'credentials' + | 'account-status' + | 'api-test' + | 'debug' + | 'settings'; export interface AdminSettingsSnapshot { labels: Record; @@ -35,6 +41,11 @@ export interface CredentialsTabInitialData { tab: 'credentials'; } +export interface AccountStatusTabInitialData { + credentials: CredentialSummary[]; + tab: 'account-status'; +} + export interface ApiTestInitialData { credentialModels: Record; credentials: CredentialSummary[]; @@ -55,6 +66,7 @@ export interface SettingsTabInitialData { export type AdminConsoleInitialData = | ApiTestInitialData + | AccountStatusTabInitialData | CredentialsTabInitialData | DashboardInitialData | DebugTabInitialData diff --git a/app/page-loader.ts b/app/page-loader.ts index 49c439e..4b5d012 100644 --- a/app/page-loader.ts +++ b/app/page-loader.ts @@ -141,6 +141,14 @@ export const getInitialData = async ({ tab, }; } + case 'account-status': { + const credentials = await listCredentials(); + + return { + credentials: credentials.credentials as unknown as CredentialSummary[], + tab, + }; + } case 'api-test': { const eligibleCredentials = await listEligibleCredentialRecords(); const [credentials, currentCredential, models] = await Promise.all([ diff --git a/app/page-shell.tsx b/app/page-shell.tsx index 8608562..2835e1a 100644 --- a/app/page-shell.tsx +++ b/app/page-shell.tsx @@ -72,11 +72,18 @@ const tabs: Array<{ icon: typeof LayoutDashboard; key: TabKey; labelKey: - 'apiTest' | 'credentials' | 'dashboard' | 'debug' | 'settings' | 'usage'; + | 'apiTest' + | 'credentials' + | 'dashboard' + | 'accountStatus' + | 'debug' + | 'settings' + | 'usage'; }> = [ { icon: LayoutDashboard, key: 'dashboard', labelKey: 'dashboard' }, { icon: ChartLine, key: 'usage', labelKey: 'usage' }, { icon: KeyRound, key: 'credentials', labelKey: 'credentials' }, + { icon: KeyRound, key: 'account-status', labelKey: 'accountStatus' }, { icon: Send, key: 'api-test', labelKey: 'apiTest' }, { icon: Bug, key: 'debug', labelKey: 'debug' }, { icon: Settings2, key: 'settings', labelKey: 'settings' }, @@ -1913,6 +1920,7 @@ const AdminPageLayoutContent = ({ {children} ) : null} + {activeTab === 'account-status' ? children : null} {activeTab === 'usage' ? ( =4.8.4 <6.1.0" } }, "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ=="], @@ -2503,6 +2514,8 @@ "vite/esbuild": ["esbuild@0.28.1", "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "vite/fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "vite/picomatch": ["picomatch@4.0.5", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "vite/postcss": ["postcss@8.5.16", "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], diff --git a/e2e/account-status.spec.ts b/e2e/account-status.spec.ts new file mode 100644 index 0000000..fcbad00 --- /dev/null +++ b/e2e/account-status.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Account Status tab', () => { + test.beforeEach(async ({ context }) => { + await context.addCookies([ + { + name: 'codebuddy2api-locale', + value: 'en-US', + domain: '127.0.0.1', + path: '/', + }, + ]); + }); + + test('navigates to the tab and shows the empty state', async ({ page }) => { + await page.goto('/account-status'); + + await expect( + page.getByRole('button', { name: 'Account Status' }), + ).toBeVisible(); + await expect(page.getByText('No credentials')).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Refresh all' }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Check in all' }), + ).toBeVisible(); + }); + + test('keeps the account status layout usable on mobile', async ({ page }) => { + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto('/account-status'); + + await expect + .poll(() => + page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ) + .toBe(true); + await expect(page.getByText('No credentials')).toBeVisible(); + }); +}); diff --git a/lib/server/domain/account-status.ts b/lib/server/domain/account-status.ts new file mode 100644 index 0000000..e10624d --- /dev/null +++ b/lib/server/domain/account-status.ts @@ -0,0 +1,202 @@ +import { getCodeBuddyApiEndpoint } from './config'; +import { + listCredentials, + listEligibleCredentialRecords, + type CredentialRecord, +} from './credentials'; +import { getModelsForCredential } from '../proxy/codebuddy'; + +export interface AccountStatusSnapshot { + checkin: { claimed: boolean | null; message: string | null }; + credits: { + total: number | null; + used: number | null; + remaining: number | null; + plan: string | null; + resetAt: string | null; + }; + error: string | null; + filename: string; + models: string[]; + queriedAt: string; +} + +const getBearerToken = (credential: CredentialRecord): string => + String( + credential.data.bearer_token ?? credential.data.access_token ?? '', + ).trim(); + +const asRecord = (value: unknown): Record | null => + value && typeof value === 'object' + ? (value as Record) + : null; + +const findValue = (value: unknown, keys: string[]): unknown => { + const record = asRecord(value); + if (record) { + for (const key of keys) { + if (record[key] !== undefined && record[key] !== null) return record[key]; + } + for (const nested of Object.values(record)) { + const found = findValue(nested, keys); + if (found !== undefined) return found; + } + } + if (Array.isArray(value)) { + for (const item of value) { + const found = findValue(item, keys); + if (found !== undefined) return found; + } + } + return undefined; +}; + +const toNumber = (value: unknown): number | null => { + const number = Number(value); + return Number.isFinite(number) ? number : null; +}; + +const fetchJson = async ( + credential: CredentialRecord, + path: string, + method = 'GET', +): Promise => { + const response = await fetch(new URL(path, await getCodeBuddyApiEndpoint()), { + method, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${getBearerToken(credential)}`, + }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw new Error(`${path} returned ${response.status}`); + return response.json(); +}; + +const loadAccountStatus = async ( + credential: CredentialRecord, +): Promise => { + const errors: string[] = []; + let creditsPayload: unknown; + let checkinPayload: unknown; + let models: string[] = []; + + try { + creditsPayload = await fetchJson(credential, '/api/v2/quota/usage'); + } catch (error) { + errors.push( + error instanceof Error ? error.message : 'Credits query failed', + ); + } + try { + checkinPayload = await fetchJson( + credential, + '/sash/api/v1/me/daily-check-in/status', + ); + } catch (error) { + errors.push( + error instanceof Error ? error.message : 'Check-in query failed', + ); + } + try { + models = ( + await getModelsForCredential({ + bearerToken: getBearerToken(credential), + credentialData: credential.data, + }) + ).map((model) => model.id); + } catch (error) { + errors.push(error instanceof Error ? error.message : 'Model query failed'); + } + + const claimedValue = findValue(checkinPayload, [ + 'claimed', + 'isClaimed', + 'checkedIn', + 'status', + ]); + const claimed = + typeof claimedValue === 'boolean' + ? claimedValue + : typeof claimedValue === 'string' + ? ['CLAIMED', 'ALREADY_CLAIMED', 'CHECKED_IN'].includes( + claimedValue.toUpperCase(), + ) + : null; + return { + checkin: { + claimed, + message: typeof claimedValue === 'string' ? claimedValue : null, + }, + credits: { + total: toNumber( + findValue(creditsPayload, ['total', 'total_size', 'quota']), + ), + used: toNumber(findValue(creditsPayload, ['used', 'total_used'])), + remaining: toNumber( + findValue(creditsPayload, ['remaining', 'total_remain']), + ), + plan: + String( + findValue(creditsPayload, ['plan', 'planName', 'userType']) ?? '', + ) || null, + resetAt: + String( + findValue(creditsPayload, ['resetAt', 'reset_at', 'resetTime']) ?? '', + ) || null, + }, + error: errors.length ? errors.join('; ') : null, + filename: credential.filename, + models, + queriedAt: new Date().toISOString(), + }; +}; + +export const getAccountStatus = async ( + filenames?: string[], +): Promise => { + const credentials = await listEligibleCredentialRecords(filenames); + const results: AccountStatusSnapshot[] = []; + for (let index = 0; index < credentials.length; index += 4) { + const chunk = credentials.slice(index, index + 4); + results.push(...(await Promise.all(chunk.map(loadAccountStatus)))); + } + return results; +}; + +export const getAccountStatusCredentials = async () => { + const response = await listCredentials(); + return response.credentials; +}; + +export const checkinAccount = async ( + filename: string, +): Promise => { + const credential = (await listEligibleCredentialRecords([filename]))[0]; + if (!credential) throw new Error('Credential is unavailable'); + try { + await fetchJson(credential, '/sash/api/v1/me/daily-check-in/claim', 'POST'); + } catch (error) { + return { + ...(await loadAccountStatus(credential)), + error: error instanceof Error ? error.message : 'Check-in failed', + }; + } + return loadAccountStatus(credential); +}; + +export const checkinAccounts = async ( + filenames?: string[], +): Promise => { + const credentials = await listEligibleCredentialRecords(filenames); + const results: AccountStatusSnapshot[] = []; + for (let index = 0; index < credentials.length; index += 4) { + const chunk = credentials.slice(index, index + 4); + results.push( + ...(await Promise.all( + chunk.map((credential) => checkinAccount(credential.filename)), + )), + ); + } + return results; +}; diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index 50d49a6..7f1274a 100644 --- a/lib/server/proxy/codebuddy.ts +++ b/lib/server/proxy/codebuddy.ts @@ -2203,10 +2203,7 @@ export const getModelsForCredential = async ({ bearerToken: string; credentialData: CredentialData; }): Promise => { - const endpoint = new URL( - '/console/enterprises/personal/models', - await getCodeBuddyApiEndpoint(), - ); + const apiEndpoint = await getCodeBuddyApiEndpoint(); const headers = new Headers({ Accept: 'application/json', Authorization: `Bearer ${bearerToken}`, @@ -2232,10 +2229,16 @@ export const getModelsForCredential = async ({ headers.set('X-Tenant-Id', String(tenantId)); } - const response = await fetch(endpoint, { - headers, - signal: AbortSignal.timeout(15_000), - }); + const fetchModels = async (path: string): Promise => + fetch(new URL(path, apiEndpoint), { + headers, + signal: AbortSignal.timeout(15_000), + }); + let response = await fetchModels('/v3/config'); + + if (response.status === 404 || response.status === 405) { + response = await fetchModels('/console/enterprises/personal/models'); + } if (!response.ok) { throw new Error(`Model discovery failed with status ${response.status}`); diff --git a/messages/en-US.json b/messages/en-US.json index 926ac67..72f88c2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -25,10 +25,35 @@ "dashboard": "Dashboard", "usage": "Usage", "credentials": "Credentials", + "accountStatus": "Account Status", "apiTest": "API Test", "debug": "Debug", "settings": "Settings" }, + "accountStatus": { + "title": "Account Status", + "quota": "Quota", + "total": "Total", + "used": "Used", + "remaining": "Remaining", + "plan": "Plan", + "resetAt": "Reset time", + "exhausted": "Quota exhausted", + "checkin": "Check-in", + "checkedIn": "Checked in today", + "notCheckedIn": "Not checked in today", + "checkinAction": "Check in", + "models": "Available models", + "showModels": "Show all ({count})", + "modelCount": "Models ({count})", + "noModels": "No models", + "refresh": "Refresh", + "refreshAll": "Refresh all", + "checkinAll": "Check in all", + "empty": "No credentials", + "quotaUnknown": "Unknown quota", + "quotaUsed": "{percent}% quota used" + }, "dashboard": { "welcomeTitle": "Ready to crank through some tokens?", "active": "{count} active", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index 4a41dfc..c94ee0f 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -25,10 +25,35 @@ "dashboard": "ダッシュボード", "usage": "使用量", "credentials": "認証情報", + "accountStatus": "アカウント状態", "apiTest": "API テスト", "debug": "Debug", "settings": "設定" }, + "accountStatus": { + "title": "アカウント状態", + "quota": "使用量", + "total": "合計", + "used": "使用済み", + "remaining": "残り", + "plan": "プラン", + "resetAt": "リセット時刻", + "exhausted": "使用量上限に達しました", + "checkin": "チェックイン", + "checkedIn": "本日はチェックイン済み", + "notCheckedIn": "本日は未チェックイン", + "checkinAction": "チェックイン", + "models": "利用可能なモデル", + "showModels": "すべて表示({count})", + "modelCount": "モデル({count})", + "noModels": "モデルなし", + "refresh": "更新", + "refreshAll": "すべて更新", + "checkinAll": "すべてチェックイン", + "empty": "認証情報がありません", + "quotaUnknown": "使用量不明", + "quotaUsed": "使用量 {percent}%" + }, "dashboard": { "welcomeTitle": "トークンをぶん回す準備はできた?", "active": "{count} 件が有効", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 71db5c9..e423b8d 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -25,10 +25,35 @@ "dashboard": "仪表板", "usage": "用量统计", "credentials": "凭证管理", + "accountStatus": "账号状态", "apiTest": "API 测试", "debug": "Debug", "settings": "设置" }, + "accountStatus": { + "title": "账号状态", + "quota": "额度", + "total": "总额", + "used": "已使用", + "remaining": "剩余", + "plan": "套餐", + "resetAt": "重置时间", + "exhausted": "额度已用尽", + "checkin": "签到", + "checkedIn": "今日已签到", + "notCheckedIn": "今日未签到", + "checkinAction": "签到", + "models": "可用模型", + "showModels": "展开全部({count} 个)", + "modelCount": "模型({count} 个)", + "noModels": "暂无模型", + "refresh": "刷新", + "refreshAll": "刷新全部", + "checkinAll": "全部签到", + "empty": "暂无凭据", + "quotaUnknown": "额度未知", + "quotaUsed": "已使用 {percent}% 额度" + }, "dashboard": { "welcomeTitle": "做好蹬的准备了吗?", "active": "{count} 个有效", diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..ce4e94a 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 65e0be1..4c86621 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "next dev", "prepare": "husky", "build": "next build", + "test:e2e": "playwright test", "start": "next start --hostname 0.0.0.0 --port 8001", "lint": "eslint . --ext .ts,.tsx --max-warnings=0", "format": "prettier --write .", @@ -44,6 +45,7 @@ "@commitlint/types": "^21.2.0", "@eslint/compat": "^2.1.0", "@next/eslint-plugin-next": "^16.3.3", + "@playwright/test": "^1.55.0", "@tailwindcss/postcss": "^4.3.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..cc93bb2 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL: 'http://127.0.0.1:8001', + trace: 'retain-on-failure', + }, + webServer: { + command: 'bun run dev -- --hostname 127.0.0.1 --port 8001', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + url: 'http://127.0.0.1:8001/health', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/tests/admin/page-loader.test.ts b/tests/admin/page-loader.test.ts index f3ce968..3720299 100644 --- a/tests/admin/page-loader.test.ts +++ b/tests/admin/page-loader.test.ts @@ -106,6 +106,7 @@ describe('tab-scoped initial data', () => { 'credentials', ['listAccessKeys', 'listCredentials', 'getCurrentCredentialInfo'], ], + ['account-status', ['listCredentials']], [ 'api-test', [ @@ -123,18 +124,19 @@ describe('tab-scoped initial data', () => { const initialData = await getInitialData({ locale: 'en-US', tab }); expect(initialData.tab).toBe(tab); + const forbiddenKeys = [ + 'accessKeys', + 'apiEndpoint', + 'credentials', + 'currentCredential', + 'debug', + 'health', + 'settings', + 'stats', + 'usage', + ].filter((key) => !(tab === 'account-status' && key === 'credentials')); expect(Object.keys(initialData).sort()).not.toEqual( - expect.arrayContaining([ - 'accessKeys', - 'apiEndpoint', - 'credentials', - 'currentCredential', - 'debug', - 'health', - 'settings', - 'stats', - 'usage', - ]), + expect.arrayContaining(forbiddenKeys), ); for (const [name, loader] of Object.entries(domainLoaders)) { diff --git a/tests/server/account-status-route.test.ts b/tests/server/account-status-route.test.ts new file mode 100644 index 0000000..6c52072 --- /dev/null +++ b/tests/server/account-status-route.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/server/admin/session', () => ({ + getAdminSessionErrorResponse: vi.fn(), +})); +vi.mock('@/lib/server/domain/account-status', () => ({ + checkinAccount: vi.fn(), + checkinAccounts: vi.fn(), + getAccountStatus: vi.fn(), + getAccountStatusCredentials: vi.fn(), +})); + +const { getAdminSessionErrorResponse } = + await import('@/lib/server/admin/session'); +const { + checkinAccount, + checkinAccounts, + getAccountStatus, + getAccountStatusCredentials, +} = await import('@/lib/server/domain/account-status'); +const { GET, POST } = await import('@/app/admin-api/account-status/route'); + +const request = (body?: unknown): Request => + new Request('http://localhost/admin-api/account-status', { + ...(body === undefined + ? {} + : { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }), + }); + +describe('account status admin route', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAdminSessionErrorResponse).mockResolvedValue(null); + vi.mocked(getAccountStatusCredentials).mockResolvedValue([] as never); + vi.mocked(getAccountStatus).mockResolvedValue([]); + vi.mocked(checkinAccounts).mockResolvedValue([]); + vi.mocked(checkinAccount).mockResolvedValue({} as never); + }); + + it('requires an administrator session', async () => { + const denied = Response.json({ error: 'unauthorized' }, { status: 401 }); + vi.mocked(getAdminSessionErrorResponse) + .mockResolvedValueOnce(denied) + .mockResolvedValueOnce(denied); + + expect((await GET(request())).status).toBe(401); + expect((await POST(request({ action: 'refresh' }))).status).toBe(401); + }); + + it('returns credentials and statuses on GET', async () => { + vi.mocked(getAccountStatusCredentials).mockResolvedValueOnce([ + { filename: 'one.json' }, + ] as never); + vi.mocked(getAccountStatus).mockResolvedValueOnce([ + { filename: 'one.json' } as never, + ]); + + const payload = await (await GET(request())).json(); + expect(payload).toEqual({ + credentials: [{ filename: 'one.json' }], + statuses: [{ filename: 'one.json' }], + }); + }); + + it('supports refresh and single or batch check-in actions', async () => { + await POST(request({ action: 'refresh', filename: ' one.json ' })); + expect(getAccountStatus).toHaveBeenCalledWith(['one.json']); + + await POST(request({ action: 'checkin', filename: 'one.json' })); + expect(checkinAccount).toHaveBeenCalledWith('one.json'); + + await POST(request({ action: 'checkin' })); + expect(checkinAccounts).toHaveBeenCalledWith(); + + await POST(request({ action: 'unknown', filename: 42 })); + expect(getAccountStatus).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/tests/server/account-status.test.ts b/tests/server/account-status.test.ts new file mode 100644 index 0000000..52c454c --- /dev/null +++ b/tests/server/account-status.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/server/domain/config', () => ({ + getCodeBuddyApiEndpoint: vi.fn(), +})); +vi.mock('@/lib/server/domain/credentials', () => ({ + listCredentials: vi.fn(), + listEligibleCredentialRecords: vi.fn(), +})); +vi.mock('@/lib/server/proxy/codebuddy', () => ({ + getModelsForCredential: vi.fn(), +})); + +const { getCodeBuddyApiEndpoint } = await import('@/lib/server/domain/config'); +const { listCredentials, listEligibleCredentialRecords } = + await import('@/lib/server/domain/credentials'); +const { getModelsForCredential } = await import('@/lib/server/proxy/codebuddy'); +const { + checkinAccount, + checkinAccounts, + getAccountStatus, + getAccountStatusCredentials, +} = await import('@/lib/server/domain/account-status'); + +const credential = (filename: string) => ({ + data: { bearer_token: `token-${filename}` }, + filePath: `/tmp/${filename}`, + filename, +}); +const jsonResponse = (payload: unknown, status = 200) => + new Response(JSON.stringify(payload), { + headers: { 'Content-Type': 'application/json' }, + status, + }); + +describe('account status domain', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getCodeBuddyApiEndpoint).mockResolvedValue( + 'https://codebuddy.example.test', + ); + vi.mocked(listEligibleCredentialRecords).mockResolvedValue([ + credential('one.json'), + ] as never); + vi.mocked(listCredentials).mockResolvedValue({ credentials: [] } as never); + vi.mocked(getModelsForCredential).mockResolvedValue([ + { displayName: 'Model One', id: 'model-one' }, + ]); + }); + + it('normalizes quota, check-in, and models', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + jsonResponse({ + userQuota: { total: 1000, used: 250, remaining: 750, plan: 'Pro' }, + }), + ) + .mockResolvedValueOnce(jsonResponse({ status: 'CLAIMED' })); + const [result] = await getAccountStatus(); + expect(result).toMatchObject({ + credits: { total: 1000, used: 250, remaining: 750, plan: 'Pro' }, + checkin: { claimed: true }, + models: ['model-one'], + error: null, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('records partial upstream errors', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ userQuota: { total: 0 } })) + .mockResolvedValueOnce(jsonResponse({}, 404)); + vi.mocked(getModelsForCredential).mockRejectedValueOnce( + new Error('models unavailable'), + ); + const [result] = await getAccountStatus(); + expect(result.credits.total).toBe(0); + expect(result.error).toContain('returned 404'); + expect(result.error).toContain('models unavailable'); + }); + + it('keeps unsupported quota and check-in values unknown', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + jsonResponse({ + data: [ + { + limits: { + planName: 'Team', + quota: 'not-a-number', + reset_at: 'tomorrow', + total_remain: '3', + total_used: '2', + }, + }, + ], + }), + ) + .mockResolvedValueOnce(jsonResponse({ status: 'PENDING' })); + + const [result] = await getAccountStatus(); + + expect(result).toMatchObject({ + checkin: { claimed: false, message: 'PENDING' }, + credits: { + plan: 'Team', + remaining: 3, + resetAt: 'tomorrow', + total: null, + used: 2, + }, + }); + }); + + it('searches nested arrays and supports access-token credentials', async () => { + vi.mocked(listEligibleCredentialRecords).mockResolvedValueOnce([ + { + data: { access_token: 'access-token-only' }, + filePath: '/tmp/array.json', + filename: 'array.json', + }, + ] as never); + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + jsonResponse({ items: [{ total: 12, used: 4, remaining: 8 }] }), + ) + .mockResolvedValueOnce(jsonResponse({ items: [{ claimed: true }] })); + + const [result] = await getAccountStatus(); + + expect(result.credits).toMatchObject({ total: 12, used: 4, remaining: 8 }); + expect(result.checkin.claimed).toBe(true); + }); + + it('returns the configured credential summaries', async () => { + vi.mocked(listCredentials).mockResolvedValueOnce({ + credentials: [{ filename: 'summary.json' }], + } as never); + + await expect(getAccountStatusCredentials()).resolves.toEqual([ + { filename: 'summary.json' }, + ]); + }); + + it('handles non-Error upstream failures without discarding other results', async () => { + vi.spyOn(globalThis, 'fetch') + .mockRejectedValueOnce('quota unavailable') + .mockResolvedValueOnce(jsonResponse({ checkedIn: false })); + vi.mocked(getModelsForCredential).mockRejectedValueOnce( + 'models unavailable', + ); + + const [result] = await getAccountStatus(); + + expect(result.checkin.claimed).toBe(false); + expect(result.error).toContain('Credits query failed'); + expect(result.error).toContain('Model query failed'); + }); + + it('checks in and refreshes one account', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ success: true })) + .mockResolvedValueOnce( + jsonResponse({ userQuota: { total: 10, used: 2, remaining: 8 } }), + ) + .mockResolvedValueOnce(jsonResponse({ claimed: true })); + const result = await checkinAccount('one.json'); + expect(result.credits.remaining).toBe(8); + }); + + it('returns a refreshed error snapshot when check-in fails', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({}, 503)) + .mockResolvedValueOnce( + jsonResponse({ + userQuota: { quota: 3, total_remain: 2, total_used: 1 }, + }), + ) + .mockResolvedValueOnce(jsonResponse({ isClaimed: false })); + + const result = await checkinAccount('one.json'); + + expect(result.error).toContain('claim returned 503'); + expect(result.credits).toMatchObject({ remaining: 2, total: 3, used: 1 }); + }); + + it('rejects a check-in request for a missing credential', async () => { + vi.mocked(listEligibleCredentialRecords).mockResolvedValueOnce([] as never); + + await expect(checkinAccount('missing.json')).rejects.toThrow( + 'Credential is unavailable', + ); + }); + + it('processes all batch accounts', async () => { + const records = Array.from({ length: 5 }, (_, index) => + credential(`credential-${index}.json`), + ); + vi.mocked(listEligibleCredentialRecords).mockResolvedValue( + records as never, + ); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => + init?.method === 'POST' + ? jsonResponse({ success: true }) + : jsonResponse({ userQuota: { total: 1, used: 0, remaining: 1 } }), + ); + const results = await checkinAccounts(); + expect(results).toHaveLength(5); + }); +}); diff --git a/tests/server/units.test.ts b/tests/server/units.test.ts index 46d90cf..216003e 100644 --- a/tests/server/units.test.ts +++ b/tests/server/units.test.ts @@ -4233,6 +4233,15 @@ describe('server units', () => { .mockResolvedValueOnce( new Response(JSON.stringify({ code: 0, data: { models: [] } })), ) + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + code: 0, + data: { models: [{ id: 'fallback-model', name: 'Fallback' }] }, + }), + ), + ) .mockRejectedValue(new Error('Upstream unavailable')); await expect( @@ -4261,6 +4270,9 @@ describe('server units', () => { await expect( getModelsForCredential({ bearerToken: 'token-d', credentialData: {} }), ).resolves.toEqual([]); + await expect( + getModelsForCredential({ bearerToken: 'token-e', credentialData: {} }), + ).resolves.toEqual([]); const records = [ { diff --git a/vitest.config.ts b/vitest.config.ts index 68dace4..2ae9e34 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,7 +25,13 @@ const vitestConfig = defineConfig({ clearMocks: true, setupFiles: ['./vitest.setup.ts'], testTimeout: 15_000, - exclude: ['.next/**', 'coverage/**', 'dist/**', 'node_modules/**'], + exclude: [ + '.next/**', + 'coverage/**', + 'dist/**', + 'e2e/**', + 'node_modules/**', + ], coverage: { provider: 'v8', include: ['lib/server/**/*.ts'],