From a054a44808c5caf471c93ed33bc1867f27ccad6a Mon Sep 17 00:00:00 2001 From: xuhongbin Date: Mon, 9 Mar 2026 12:31:08 +0800 Subject: [PATCH] fix: require live admin key on web auth gating --- app/(tabs)/_layout.tsx | 4 +-- app/(tabs)/index.tsx | 4 +-- app/(tabs)/monitor.tsx | 66 +++++++++++++++++++++++++++++-------- app/(tabs)/users.tsx | 68 ++++++++++++++++++++++++++++++++------- app/login.tsx | 4 +-- src/store/admin-config.ts | 65 +++++++++++++++++++++++++++++-------- 6 files changed, 167 insertions(+), 44 deletions(-) diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index f3261b9..82554ec 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,13 +1,13 @@ import { Redirect, Tabs } from 'expo-router'; import { ChartNoAxesCombined, Settings2, Users } from 'lucide-react-native'; -import { adminConfigState } from '@/src/store/admin-config'; +import { adminConfigState, hasAuthenticatedAdminSession } from '@/src/store/admin-config'; const { useSnapshot } = require('valtio/react'); export default function TabsLayout() { const config = useSnapshot(adminConfigState); - const hasAccount = Boolean(config.baseUrl.trim()); + const hasAccount = hasAuthenticatedAdminSession(config); if (!hasAccount) { return ; diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 3016e68..cf92f56 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,12 +1,12 @@ import { Redirect } from 'expo-router'; -import { adminConfigState } from '@/src/store/admin-config'; +import { adminConfigState, hasAuthenticatedAdminSession } from '@/src/store/admin-config'; const { useSnapshot } = require('valtio/react'); export default function IndexScreen() { const config = useSnapshot(adminConfigState); - const hasAccount = Boolean(config.baseUrl.trim()); + const hasAccount = hasAuthenticatedAdminSession(config); return ; } diff --git a/app/(tabs)/monitor.tsx b/app/(tabs)/monitor.tsx index 4c6fd08..e681caf 100644 --- a/app/(tabs)/monitor.tsx +++ b/app/(tabs)/monitor.tsx @@ -8,8 +8,8 @@ import { BarChartCard } from '@/src/components/bar-chart-card'; import { formatTokenValue } from '@/src/lib/formatters'; import { DonutChartCard } from '@/src/components/donut-chart-card'; import { LineTrendChart } from '@/src/components/line-trend-chart'; -import { getAdminSettings, getDashboardModels, getDashboardStats, getDashboardTrend, listAccounts } from '@/src/services/admin'; -import { adminConfigState } from '@/src/store/admin-config'; +import { getAdminSettings, getDashboardModels, getDashboardStats, getDashboardTrend, listAllAccounts } from '@/src/services/admin'; +import { adminConfigState, hasAuthenticatedAdminSession } from '@/src/store/admin-config'; const { useSnapshot } = require('valtio/react'); @@ -41,6 +41,38 @@ const RANGE_TITLE_MAP: Record = { '30d': '30D', }; +function hasAccountError(account: { status?: string; error_message?: string | null }) { + return Boolean(account.status === 'error' || account.error_message); +} + +function hasAccountRateLimited(account: { + rate_limit_reset_at?: string | null; + extra?: Record; +}) { + if (account.rate_limit_reset_at) { + const resetTime = new Date(account.rate_limit_reset_at).getTime(); + if (!Number.isNaN(resetTime) && resetTime > Date.now()) { + return true; + } + } + + const modelLimits = account.extra?.model_rate_limits; + if (!modelLimits || typeof modelLimits !== 'object' || Array.isArray(modelLimits)) { + return false; + } + + const now = Date.now(); + return Object.values(modelLimits as Record).some((info) => { + if (!info || typeof info !== 'object' || Array.isArray(info)) return false; + + const resetAt = (info as { rate_limit_reset_at?: unknown }).rate_limit_reset_at; + if (typeof resetAt !== 'string' || !resetAt.trim()) return false; + + const resetTime = new Date(resetAt).getTime(); + return !Number.isNaN(resetTime) && resetTime > now; + }); +} + function getDateRange(rangeKey: RangeKey) { const end = new Date(); const start = new Date(); @@ -136,13 +168,18 @@ function StatCard({ title, value, detail }: { title: string; value: string; deta export default function MonitorScreen() { const config = useSnapshot(adminConfigState); - const hasAccount = Boolean(config.baseUrl.trim()); + const hasAccount = hasAuthenticatedAdminSession(config); const [rangeKey, setRangeKey] = useState('7d'); const range = useMemo(() => getDateRange(rangeKey), [rangeKey]); const statsQuery = useQuery({ queryKey: ['monitor-stats'], queryFn: getDashboardStats, enabled: hasAccount }); const settingsQuery = useQuery({ queryKey: ['admin-settings'], queryFn: getAdminSettings, enabled: hasAccount }); - const accountsQuery = useQuery({ queryKey: ['monitor-accounts'], queryFn: () => listAccounts(''), enabled: hasAccount }); + const accountPageSize = Math.max(statsQuery.data?.total_accounts ?? 20, 20); + const accountsQuery = useQuery({ + queryKey: ['monitor-accounts', accountPageSize], + queryFn: () => listAllAccounts(''), + enabled: hasAccount, + }); const trendQuery = useQuery({ queryKey: ['monitor-trend', rangeKey, range.start_date, range.end_date, range.granularity], queryFn: () => getDashboardTrend(range), @@ -168,9 +205,12 @@ export default function MonitorScreen() { const trend = trendQuery.data?.trend ?? []; const topModels = (modelsQuery.data?.models ?? []).slice(0, 5); const errorMessage = getErrorMessage(statsQuery.error ?? settingsQuery.error ?? accountsQuery.error ?? trendQuery.error ?? modelsQuery.error); - const currentPageErrorAccounts = accounts.filter((item) => item.status === 'error' || item.error_message).length; - const currentPagePausedAccounts = accounts.filter((item) => item.schedulable === false && item.status !== 'error' && !item.error_message).length; - const currentPageBusyAccounts = accounts.filter((item) => (item.current_concurrency ?? 0) > 0 && item.status !== 'error' && !item.error_message).length; + const currentPageErrorAccounts = accounts.filter(hasAccountError).length; + const currentPageLimitedAccounts = accounts.filter((item) => hasAccountRateLimited(item)).length; + const currentPageBusyAccounts = accounts.filter((item) => { + if (hasAccountError(item) || hasAccountRateLimited(item)) return false; + return (item.current_concurrency ?? 0) > 0; + }).length; const totalAccounts = stats?.total_accounts ?? accountsQuery.data?.total ?? accounts.length; const aggregatedErrorAccounts = stats?.error_accounts ?? 0; const errorAccounts = Math.max(aggregatedErrorAccounts, currentPageErrorAccounts); @@ -271,7 +311,7 @@ export default function MonitorScreen() { detail={`TPM ${formatNumber(stats?.tpm)}`} /> -
+
总数 @@ -286,11 +326,11 @@ export default function MonitorScreen() { {formatNumber(errorAccounts)} - 暂停 - {formatNumber(currentPagePausedAccounts)} + 限流 + {formatNumber(currentPageLimitedAccounts)} - 总数 / 健康 / 异常优先使用后端聚合字段;暂停与繁忙基于当前页账号列表。 + 总数 / 健康 / 异常优先使用后端聚合字段;限流与繁忙基于当前页账号列表。
{throughputPoints.length > 1 ? ( @@ -318,13 +358,13 @@ export default function MonitorScreen() { diff --git a/app/(tabs)/users.tsx b/app/(tabs)/users.tsx index edeae80..372025a 100644 --- a/app/(tabs)/users.tsx +++ b/app/(tabs)/users.tsx @@ -1,14 +1,15 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQueries, useQuery } from '@tanstack/react-query'; import { router } from 'expo-router'; import { useMemo, useState } from 'react'; import { FlatList, Pressable, RefreshControl, Text, TextInput, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useDebouncedValue } from '@/src/hooks/use-debounced-value'; +import { formatCompactNumber, formatTokenValue } from '@/src/lib/formatters'; import { queryClient } from '@/src/lib/query-client'; -import { getUser, listUserApiKeys, listUsers } from '@/src/services/admin'; -import { adminConfigState } from '@/src/store/admin-config'; -import type { AdminUser } from '@/src/types/admin'; +import { getUser, getUsageStats, listUserApiKeys, listUsers } from '@/src/services/admin'; +import { adminConfigState, hasAuthenticatedAdminSession } from '@/src/store/admin-config'; +import type { AdminUser, UsageStats } from '@/src/types/admin'; const { useSnapshot } = require('valtio/react'); @@ -26,8 +27,30 @@ const colors = { }; type SortOrder = 'desc' | 'asc'; +type RangeKey = '24h' | '7d' | '30d'; -function formatBalance(value?: number) { +function getDateRange(rangeKey: RangeKey) { + const end = new Date(); + const start = new Date(); + + if (rangeKey === '24h') { + start.setHours(end.getHours() - 23, 0, 0, 0); + } else if (rangeKey === '30d') { + start.setDate(end.getDate() - 29); + } else { + start.setDate(end.getDate() - 6); + } + + const toDate = (value: Date) => value.toISOString().slice(0, 10); + + return { + start_date: toDate(start), + end_date: toDate(end), + granularity: rangeKey === '24h' ? ('hour' ) : ('day' ), + }; +} + +function formatCost(value?: number) { if (typeof value !== 'number' || Number.isNaN(value)) return '$0.00'; return `$${value.toFixed(2)}`; } @@ -85,16 +108,20 @@ function MetricTile({ title, value, tone = 'default' }: { title: string; value: return ( {title} - + {value} ); } -function UserCard({ user }: { user: AdminUser }) { +function UserCard({ user, usage }: { user: AdminUser; usage?: UsageStats }) { const isAdmin = user.role?.trim().toLowerCase() === 'admin'; - const statusLabel = `${isAdmin ? 'admin · ' : ''}${user.status || 'active'}`; + const userNameLabel = getUserNameLabel(user); + const statusLabel = `${isAdmin ? 'admin · ' : ''}${user.status || 'active'} · ${userNameLabel}`; + const totalCost = Number(usage?.total_account_cost ?? usage?.total_actual_cost ?? usage?.total_cost ?? 0); + const totalTokens = Number(usage?.total_tokens ?? 0); + const totalRequests = Number(usage?.total_requests ?? 0); return ( @@ -109,8 +136,9 @@ function UserCard({ user }: { user: AdminUser }) { - - + + + ); @@ -118,7 +146,7 @@ function UserCard({ user }: { user: AdminUser }) { export default function UsersScreen() { const config = useSnapshot(adminConfigState); - const hasAccount = Boolean(config.baseUrl.trim()); + const hasAccount = hasAuthenticatedAdminSession(config); const [searchText, setSearchText] = useState(''); const [sortOrder, setSortOrder] = useState('desc'); const debouncedSearchText = useDebouncedValue(searchText, 250); @@ -129,6 +157,8 @@ export default function UsersScreen() { enabled: hasAccount, }); + const usageRange = useMemo(() => getDateRange('7d'), []); + const users = useMemo(() => { const items = [...(usersQuery.data?.items ?? [])]; items.sort((left, right) => { @@ -138,6 +168,20 @@ export default function UsersScreen() { return items; }, [sortOrder, usersQuery.data?.items]); + const usageQueries = useQueries({ + queries: users.map((user) => ({ + queryKey: ['usage-stats', 'user', user.id, '7d', usageRange.start_date, usageRange.end_date], + queryFn: () => getUsageStats({ ...usageRange, user_id: user.id }), + enabled: hasAccount, + staleTime: 60_000, + })), + }); + + const usageByUserId = useMemo( + () => new Map(users.map((user, index) => [user.id, usageQueries[index]?.data] as const)), + [users, usageQueries] + ); + const errorMessage = getErrorMessage(usersQuery.error); return ( @@ -212,7 +256,7 @@ export default function UsersScreen() { router.push(`/users/${item.id}`); }} > - + )} /> diff --git a/app/login.tsx b/app/login.tsx index 193c6cd..81beb2a 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -8,7 +8,7 @@ import { z } from 'zod'; import { getAdminSettings, getDashboardStats } from '@/src/services/admin'; import { queryClient } from '@/src/lib/query-client'; -import { adminConfigState, saveAdminConfig } from '@/src/store/admin-config'; +import { adminConfigState, hasAuthenticatedAdminSession, saveAdminConfig } from '@/src/store/admin-config'; const { useSnapshot } = require('valtio/react'); @@ -56,7 +56,7 @@ function getConnectionErrorMessage(error: unknown) { export default function LoginScreen() { const config = useSnapshot(adminConfigState); - const hasAccount = Boolean(config.baseUrl.trim()); + const hasAccount = hasAuthenticatedAdminSession(config); const { control, handleSubmit, formState } = useForm({ resolver: zodResolver(schema), defaultValues: { diff --git a/src/store/admin-config.ts b/src/store/admin-config.ts index 153f17d..b8b8109 100644 --- a/src/store/admin-config.ts +++ b/src/store/admin-config.ts @@ -6,6 +6,7 @@ const BASE_URL_KEY = 'sub2api_base_url'; const ADMIN_KEY_KEY = 'sub2api_admin_api_key'; const ACCOUNTS_KEY = 'sub2api_accounts'; const ACTIVE_ACCOUNT_ID_KEY = 'sub2api_active_account_id'; +const IS_WEB = Platform.OS === 'web'; export type AdminAccountProfile = { id: string; @@ -43,10 +44,48 @@ function sortAccounts(accounts: AdminAccountProfile[]) { function normalizeAccount(account: AdminAccountProfile): AdminAccountProfile { return { ...account, + adminApiKey: account.adminApiKey ?? '', enabled: account.enabled ?? true, }; } +function sanitizeAccountsForWeb(accounts: AdminAccountProfile[]) { + if (!IS_WEB) { + return accounts; + } + + return accounts.map((account) => ({ + ...account, + adminApiKey: '', + })); +} + +function persistAdminApiKey(value: string) { + if (IS_WEB) { + return deleteItem(ADMIN_KEY_KEY); + } + + return setItem(ADMIN_KEY_KEY, value); +} + +function persistAccounts(accounts: AdminAccountProfile[]) { + return setItem(ACCOUNTS_KEY, JSON.stringify(sanitizeAccountsForWeb(accounts))); +} + +export function hasAuthenticatedAdminSession(config: { baseUrl: string; adminApiKey: string }) { + const hasBaseUrl = Boolean(config.baseUrl.trim()); + + if (!hasBaseUrl) { + return false; + } + + if (!IS_WEB) { + return true; + } + + return Boolean(config.adminApiKey.trim()); +} + function getNextActiveAccount(accounts: AdminAccountProfile[], activeAccountId?: string) { const enabledAccounts = accounts.filter((account) => account.enabled !== false); @@ -139,7 +178,7 @@ export async function hydrateAdminConfig() { if (rawAccounts) { try { const parsed = JSON.parse(rawAccounts) as AdminAccountProfile[]; - accounts = Array.isArray(parsed) ? parsed.map((account) => normalizeAccount(account)) : []; + accounts = Array.isArray(parsed) ? sanitizeAccountsForWeb(parsed.map((account) => normalizeAccount(account))) : []; } catch { accounts = []; } @@ -148,7 +187,7 @@ export async function hydrateAdminConfig() { if (accounts.length === 0 && baseUrl) { const legacyConfig = normalizeConfig({ baseUrl, - adminApiKey: adminApiKey ?? defaults.adminApiKey, + adminApiKey: IS_WEB ? defaults.adminApiKey : adminApiKey ?? defaults.adminApiKey, }); accounts = [ @@ -172,10 +211,10 @@ export async function hydrateAdminConfig() { adminConfigState.adminApiKey = activeAccount?.adminApiKey ?? defaults.adminApiKey; await Promise.all([ - setItem(ACCOUNTS_KEY, JSON.stringify(sortedAccounts)), + persistAccounts(sortedAccounts), nextActiveAccountId ? setItem(ACTIVE_ACCOUNT_ID_KEY, nextActiveAccountId) : deleteItem(ACTIVE_ACCOUNT_ID_KEY), setItem(BASE_URL_KEY, activeAccount?.baseUrl ?? defaults.baseUrl), - setItem(ADMIN_KEY_KEY, activeAccount?.adminApiKey ?? defaults.adminApiKey), + persistAdminApiKey(activeAccount?.adminApiKey ?? defaults.adminApiKey), ]); } finally { adminConfigState.hydrated = true; @@ -211,8 +250,8 @@ export async function saveAdminConfig(input: { baseUrl: string; adminApiKey: str await Promise.all([ setItem(BASE_URL_KEY, normalized.baseUrl), - setItem(ADMIN_KEY_KEY, normalized.adminApiKey), - setItem(ACCOUNTS_KEY, JSON.stringify(nextAccounts)), + persistAdminApiKey(normalized.adminApiKey), + persistAccounts(nextAccounts), setItem(ACTIVE_ACCOUNT_ID_KEY, nextAccount.id), ]); @@ -247,8 +286,8 @@ export async function switchAdminAccount(accountId: string) { await Promise.all([ setItem(BASE_URL_KEY, nextAccount.baseUrl), - setItem(ADMIN_KEY_KEY, nextAccount.adminApiKey), - setItem(ACCOUNTS_KEY, JSON.stringify(nextAccounts)), + persistAdminApiKey(nextAccount.adminApiKey), + persistAccounts(nextAccounts), setItem(ACTIVE_ACCOUNT_ID_KEY, nextAccount.id), ]); @@ -263,10 +302,10 @@ export async function removeAdminAccount(accountId: string) { const nextActiveAccount = getNextActiveAccount(nextAccounts, adminConfigState.activeAccountId === accountId ? '' : adminConfigState.activeAccountId); await Promise.all([ - setItem(ACCOUNTS_KEY, JSON.stringify(nextAccounts)), + persistAccounts(nextAccounts), nextActiveAccount ? setItem(ACTIVE_ACCOUNT_ID_KEY, nextActiveAccount.id) : deleteItem(ACTIVE_ACCOUNT_ID_KEY), setItem(BASE_URL_KEY, nextActiveAccount?.baseUrl ?? ''), - setItem(ADMIN_KEY_KEY, nextActiveAccount?.adminApiKey ?? ''), + persistAdminApiKey(nextActiveAccount?.adminApiKey ?? ''), ]); adminConfigState.accounts = nextAccounts; @@ -276,7 +315,7 @@ export async function removeAdminAccount(accountId: string) { } export async function logoutAdminAccount() { - await Promise.all([setItem(BASE_URL_KEY, ''), setItem(ADMIN_KEY_KEY, ''), deleteItem(ACTIVE_ACCOUNT_ID_KEY)]); + await Promise.all([setItem(BASE_URL_KEY, ''), persistAdminApiKey(''), deleteItem(ACTIVE_ACCOUNT_ID_KEY)]); adminConfigState.activeAccountId = ''; adminConfigState.baseUrl = ''; @@ -292,10 +331,10 @@ export async function setAdminAccountEnabled(accountId: string, enabled: boolean const nextActiveAccount = getNextActiveAccount(nextAccounts, enabled ? accountId : adminConfigState.activeAccountId); await Promise.all([ - setItem(ACCOUNTS_KEY, JSON.stringify(nextAccounts)), + persistAccounts(nextAccounts), nextActiveAccount ? setItem(ACTIVE_ACCOUNT_ID_KEY, nextActiveAccount.id) : deleteItem(ACTIVE_ACCOUNT_ID_KEY), setItem(BASE_URL_KEY, nextActiveAccount?.baseUrl ?? ''), - setItem(ADMIN_KEY_KEY, nextActiveAccount?.adminApiKey ?? ''), + persistAdminApiKey(nextActiveAccount?.adminApiKey ?? ''), ]); adminConfigState.accounts = nextAccounts;