import { useQuery } from '@tanstack/react-query'; import { router } from 'expo-router'; import { useMemo, useState } from 'react'; import { Pressable, RefreshControl, ScrollView, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; 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, hasAuthenticatedAdminSession } from '@/src/store/admin-config'; const { useSnapshot } = require('valtio/react'); type RangeKey = '24h' | '7d' | '30d'; const colors = { page: '#f4efe4', card: '#fbf8f2', mutedCard: '#f1ece2', primary: '#1d5f55', text: '#16181a', subtext: '#6f665c', border: '#e7dfcf', dangerBg: '#fbf1eb', danger: '#c25d35', successBg: '#e6f4ee', success: '#1d5f55', }; const RANGE_OPTIONS: Array<{ key: RangeKey; label: string }> = [ { key: '24h', label: '24H' }, { key: '7d', label: '7D' }, { key: '30d', label: '30D' }, ]; const RANGE_TITLE_MAP: Record = { '24h': '24H', '7d': '7D', '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(); 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' as const) : ('day' as const), }; } function formatNumber(value?: number) { if (typeof value !== 'number' || Number.isNaN(value)) return '--'; return new Intl.NumberFormat('en-US').format(value); } function formatMoney(value?: number) { if (typeof value !== 'number' || Number.isNaN(value)) return '--'; return `$${value.toFixed(2)}`; } function formatCompactNumber(value?: number) { if (typeof value !== 'number' || Number.isNaN(value)) return '--'; if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; return String(value); } function formatTokenDisplay(value?: number) { if (typeof value !== 'number' || Number.isNaN(value)) return '--'; return formatTokenValue(value); } function getPointLabel(value: string, rangeKey: RangeKey) { if (rangeKey === '24h') { return value.slice(11, 13); } return value.slice(5, 10); } function getErrorMessage(error: unknown) { if (error instanceof Error && error.message) { switch (error.message) { case 'BASE_URL_REQUIRED': return '请先去服务器页填写服务地址。'; case 'ADMIN_API_KEY_REQUIRED': return '请先去服务器页填写 Admin Token。'; case 'INVALID_SERVER_RESPONSE': return '当前服务返回的数据格式不正确,请确认它是可用的 Sub2API 管理接口。'; default: return error.message; } } return '当前无法加载概览数据,请检查服务地址、Token 和网络。'; } function Section({ title, subtitle, children, right }: { title: string; subtitle?: string; children: React.ReactNode; right?: React.ReactNode }) { return ( {title} {subtitle ? {subtitle} : null} {right} {children} ); } function StatCard({ title, value, detail }: { title: string; value: string; detail?: string }) { return ( {title} {value} {detail ? {detail} : null} ); } export default function MonitorScreen() { const config = useSnapshot(adminConfigState); 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, staleTime: 60_000, }); const settingsQuery = useQuery({ queryKey: ['admin-settings'], queryFn: getAdminSettings, enabled: hasAccount, staleTime: 120_000, }); const accountsQuery = useQuery({ queryKey: ['monitor-accounts'], queryFn: () => listAccounts(''), enabled: hasAccount, staleTime: 60_000, }); const trendQuery = useQuery({ queryKey: ['monitor-trend', rangeKey, range.start_date, range.end_date, range.granularity], queryFn: () => getDashboardTrend(range), enabled: hasAccount, staleTime: 60_000, placeholderData: (previousData) => previousData, }); const modelsQuery = useQuery({ queryKey: ['monitor-models', rangeKey, range.start_date, range.end_date], queryFn: () => getDashboardModels(range), enabled: hasAccount, staleTime: 60_000, placeholderData: (previousData) => previousData, }); function refetchAll() { statsQuery.refetch(); settingsQuery.refetch(); accountsQuery.refetch(); trendQuery.refetch(); modelsQuery.refetch(); } const stats = statsQuery.data; const siteName = settingsQuery.data?.site_name?.trim() || '管理控制台'; const accounts = accountsQuery.data?.items ?? []; 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(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); const healthyAccounts = stats?.normal_accounts ?? Math.max(totalAccounts - errorAccounts, 0); const latestTrendPoints = trend.slice(-6).reverse(); const selectedTokenTotal = trend.reduce((sum, item) => sum + item.total_tokens, 0); const selectedCostTotal = trend.reduce((sum, item) => sum + item.cost, 0); const selectedOutputTotal = trend.reduce((sum, item) => sum + item.output_tokens, 0); const rangeTitle = RANGE_TITLE_MAP[rangeKey]; const isLoading = statsQuery.isLoading || settingsQuery.isLoading || accountsQuery.isLoading; const hasError = Boolean(statsQuery.error || settingsQuery.error || accountsQuery.error || trendQuery.error || modelsQuery.error); const throughputPoints = useMemo( () => trend.map((item) => ({ label: getPointLabel(item.date, rangeKey), value: item.total_tokens })), [rangeKey, trend] ); const requestPoints = useMemo( () => trend.map((item) => ({ label: getPointLabel(item.date, rangeKey), value: item.requests })), [rangeKey, trend] ); const costPoints = useMemo( () => trend.map((item) => ({ label: getPointLabel(item.date, rangeKey), value: item.cost })), [rangeKey, trend] ); const totalInputTokens = useMemo(() => trend.reduce((sum, item) => sum + item.input_tokens, 0), [trend]); const totalOutputTokens = useMemo(() => trend.reduce((sum, item) => sum + item.output_tokens, 0), [trend]); const totalCacheReadTokens = useMemo(() => trend.reduce((sum, item) => sum + item.cache_read_tokens, 0), [trend]); const isRefreshing = statsQuery.isRefetching || settingsQuery.isRefetching || accountsQuery.isRefetching || trendQuery.isRefetching || modelsQuery.isRefetching; return ( void refetchAll()} tintColor="#1d5f55" />} > 概览 {siteName} 的当前运行状态。 {RANGE_OPTIONS.map((option) => { const active = option.key === rangeKey; return ( setRangeKey(option.key)} > {option.label} ); })} {range.start_date} 到 {range.end_date} {!hasAccount ? (
请先前往“服务器”页填写服务地址和 Admin Token,再返回查看概览数据。 router.push('/settings')}> 去配置服务器
) : isLoading ? (
已连接服务器,正在拉取概览、模型和账号状态数据。
) : hasError ? (
{errorMessage} 重试 router.push('/settings')}> 检查服务器
) : (
router.push('/accounts/overview')} > 账号清单 )} > router.push('/accounts/overview')}> 总数 {formatNumber(totalAccounts)} 健康 {formatNumber(healthyAccounts)} 异常 {formatNumber(errorAccounts)} 限流 {formatNumber(currentPageLimitedAccounts)} 总数 / 健康 / 异常优先使用后端聚合字段;限流与繁忙基于当前页账号列表。点击进入账号清单。
{throughputPoints.length > 1 ? ( ) : null} {requestPoints.length > 1 ? ( ) : null} {costPoints.length > 1 ? ( ) : null} ({ label: model.model, value: model.total_tokens, color: '#a34d2d', meta: `请求 ${formatNumber(model.requests)} · 成本 ${formatMoney(model.cost)}`, }))} formatValue={formatCompactNumber} />
{latestTrendPoints.length === 0 ? ( 当前时间范围没有趋势数据。 ) : ( {latestTrendPoints.map((point) => ( {point.date} 请求 {formatCompactNumber(point.requests)} Token {formatTokenDisplay(point.total_tokens)} 成本 {formatMoney(point.cost)} ))} )}
)}
); }