diff --git a/README.md b/README.md index b931a46c..340f6d3e 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ - **Live 与 Robotics**: 通过 WebSocket 支持文本、音频、JPEG、媒体结束、工具调用、恢复和中断 - **反指纹检测**: 为每个账户固定 Firefox 指纹与出口,官方 WAA 在 SpiderMonkey 的 Firefox 页面环境或 Camoufox 页面中运行 - **图形界面启动器**: 通过网页管理账户、服务启停、实时日志、模型、请求、用量统计和配置,界面提供深色与浅色两种配色,以及简体中文、繁體中文、English、日本語、한국어、Français 与 Deutsch 七种语言 +- **服务总览**: 首页汇总账户健康、模型调用、冷却、最近请求和错误,按太平洋时区展示当天各账户的请求与 Token 用量;账户状态筛选显示数量 - **桌面版**: Windows、macOS 与 Linux 桌面应用在窗口中显示管理页面,关闭窗口后在系统托盘继续运行 - **在线更新**: 管理端“版本”页显示最新发行版本与更新说明,桌面版与 Linux、macOS 命令行版一键下载、校验并重启到新版本,Linux deb 安装时由系统弹出管理员认证 - **模块化架构**: Go 负责协议、调度、API 与管理端,SpiderMonkey 或 Camoufox 负责 WAA 运行时,Camoufox 负责隔离登录 diff --git a/README_en.md b/README_en.md index 87e1b7a9..455e30c1 100644 --- a/README_en.md +++ b/README_en.md @@ -93,6 +93,7 @@ The three projects connect to AI Studio, the Gemini web app and Antigravity, and - **Live and Robotics**: WebSocket text, audio, JPEG images, media end, tool calls, resumption, and interruption - **Anti-Fingerprinting**: Each account keeps a stable Firefox fingerprint and network exit, and the official WAA runs in the Firefox page environment of SpiderMonkey or in a Camoufox page - **GUI Launcher**: Manage accounts, service controls, live logs, models, requests, usage statistics, and configuration in the web UI, with dark and light color themes and seven languages: 简体中文, 繁體中文, English, 日本語, 한국어, Français, and Deutsch +- **Service Overview**: The landing page summarizes account health, model traffic, cooldowns, recent requests, and errors, with daily per-account request and token usage in Pacific time; account-state filters show counts - **Desktop App**: Windows, macOS, and Linux desktop apps show the management UI in a window and keep running in the system tray after the window is closed - **Online Update**: The Version page shows the latest release and its notes; the desktop apps and the Linux and macOS command-line builds download, verify, and restart into the new version in one click, and a Linux deb install asks for administrator authentication - **Modular Architecture**: Go handles protocols, scheduling, APIs, and management; SpiderMonkey or Camoufox hosts WAA, and Camoufox hosts isolated login diff --git a/docs/images/overview-dark.webp b/docs/images/overview-dark.webp new file mode 100644 index 00000000..0fb7f554 Binary files /dev/null and b/docs/images/overview-dark.webp differ diff --git a/web/src/App.vue b/web/src/App.vue index 7d09a389..e84e1319 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -47,7 +47,9 @@ function lazyPanel(loader: AsyncComponentLoader) { const UsagePanel = lazyPanel(() => import('@/components/UsagePanel.vue')) const VersionPanel = lazyPanel(() => import('@/components/VersionPanel.vue')) const AccessPanel = lazyPanel(() => import('@/components/AccessPanel.vue')) +const OverviewPanel = lazyPanel(() => import('@/components/OverviewPanel.vue')) const validTabs: TabID[] = [ + 'overview', 'logs', 'accounts', 'access', @@ -57,7 +59,7 @@ const validTabs: TabID[] = [ 'settings', 'version', ] -const { tab: currentTab, select: selectTab } = useTabParam(validTabs, 'logs') +const { tab: currentTab, select: selectTab } = useTabParam(validTabs, 'overview') const router = useRouter() const route = useRoute() // currentTab 在从试用页返回时同步目标管理页面 @@ -66,7 +68,7 @@ watch( () => { if (route.name === 'console') { const tab = route.query.tab as TabID - currentTab.value = validTabs.includes(tab) ? tab : 'logs' + currentTab.value = validTabs.includes(tab) ? tab : 'overview' } }, ) @@ -185,7 +187,15 @@ watch( { flush: 'post' }, ) +// refreshOverview 同时刷新总览使用的账户、模型、请求与冷却数据 +const overviewRefreshKey = ref(0) +async function refreshOverview(): Promise { + await Promise.all([loadAccountData(), loadRequestData()]) + overviewRefreshKey.value++ +} + const navigation: { id: TabID; label: TranslationKey; icon: IconName }[] = [ + { id: 'overview', label: 'nav.overview', icon: 'overview' }, { id: 'logs', label: 'nav.logs', icon: 'logs' }, { id: 'accounts', label: 'nav.accounts', icon: 'accounts' }, { id: 'access', label: 'nav.access', icon: 'access' }, @@ -431,9 +441,23 @@ onMounted(revealCurrentTab)

- + + [...new Set(props.accounts.map((account) => account.state))]) +// stateCounts 是每种账户状态的数量,显示在状态筛选按钮上 +const stateCounts = computed(() => { + const counts = new Map() + for (const account of props.accounts) + counts.set(account.state, (counts.get(account.state) ?? 0) + 1) + return counts +}) const tiers = computed(() => [...new Set(props.accounts.map((account) => account.benefit_tier))]) const egresses = computed(() => [...new Set(props.accounts.map((account) => account.egress))]) @@ -1051,16 +1058,30 @@ async function refreshAllAccounts(): Promise { :placeholder="t('accounts.search')" class="min-w-0 flex-[1_1_12rem] rounded-md border border-line bg-canvas px-3 py-1.5 text-sm text-white focus:border-blue-500 focus:outline-none" /> - - - - + + +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { api } from '@/api' +import { useI18n, type TranslationKey } from '@/i18n' +import type { + Account, + Cooldown, + Model, + RequestSummary, + ServiceStatus, + TabID, + UsageRecord, + UsageReport, +} from '@/types' +import { groupCooldowns, pacificDayStart } from '@/overview-metrics' +import UiIcon from './UiIcon.vue' + +const props = defineProps<{ + status: ServiceStatus | null + serviceState: 'unavailable' | 'running' | 'launching' | 'stopped' + accounts: Account[] + models: Model[] + cooldowns: Cooldown[] + requests: RequestSummary[] + refreshKey: number +}>() + +const emit = defineEmits<{ + refresh: [] + select: [tab: TabID] + start: [] + stop: [] +}>() + +const { locale, t, errorText } = useI18n() + +const accountCounts = computed(() => { + if (props.status !== null) return props.status.accounts + return props.accounts.reduce( + (counts, account) => { + counts.total++ + if (account.state === 'ready') counts.ready++ + if (account.state === 'busy') counts.busy++ + if (account.state === 'cooldown') counts.cooldown++ + if (account.state === 'auth_required') counts.auth_required++ + return counts + }, + { total: 0, ready: 0, busy: 0, cooldown: 0, auth_required: 0 }, + ) +}) + +const todayReport = ref(null) +const recentRecords = ref([]) + +const requestStats = computed(() => { + const totals = todayReport.value?.totals + if (totals === undefined) return { total: 0, success: 0, failed: 0, cancelled: 0 } + return { + total: totals.requests, + success: totals.succeeded, + failed: totals.failed, + cancelled: totals.canceled, + } +}) + +const successRate = computed(() => { + if (requestStats.value.total === 0) return '—' + return `${Math.round((requestStats.value.success / requestStats.value.total) * 100)}%` +}) + +const accountRows = computed(() => + props.accounts + .slice() + .sort((left, right) => { + const order = { + auth_required: 0, + cooldown: 1, + unavailable: 2, + busy: 3, + ready: 4, + disabled: 5, + } + return ( + (order[left.state] ?? 9) - (order[right.state] ?? 9) || + left.label.localeCompare(right.label) + ) + }) + .slice(0, 8), +) + +const recentErrors = computed(() => + recentRecords.value + .filter((record) => record.state === 'failed' || record.status >= 400) + .slice(0, 5), +) + +const modelUsage = computed(() => + (todayReport.value?.groups.model ?? []) + .filter((group) => group.key !== '' && !group.key.startsWith('__') && group.requests > 0) + .slice(0, 6) + .map((group) => [group.key, group.requests] as [string, number]), +) + +const modelCooldowns = computed(() => groupCooldowns(props.cooldowns).slice(0, 6)) + +const todayUsage = computed(() => todayReport.value?.groups.account ?? []) +const todayUsageLoading = ref(false) +const todayUsageError = ref('') +let usageController: AbortController | undefined + +// loadTodayUsage 读取太平洋时区当天的账户用量与最近请求记录 +async function loadTodayUsage(): Promise { + usageController?.abort() + const current = new AbortController() + usageController = current + todayUsageLoading.value = true + try { + const now = new Date() + const from = pacificDayStart(now).toISOString() + const to = now.toISOString() + const usageQuery = new URLSearchParams({ + from, + to, + tz: 'America/Los_Angeles', + stack: 'account', + bucket: '86400', + }) + const recordQuery = new URLSearchParams({ from, to, limit: '50' }) + const [report, records] = await Promise.all([ + api.usage(usageQuery, current.signal), + api.usageRecords(recordQuery, current.signal), + ]) + todayReport.value = report + recentRecords.value = records.items + todayUsageError.value = '' + } catch (reason) { + if (current.signal.aborted) return + todayUsageError.value = errorText(reason) + } finally { + if (usageController === current) todayUsageLoading.value = false + } +} + +onMounted(loadTodayUsage) +watch( + () => props.refreshKey, + () => void loadTodayUsage(), +) +onBeforeUnmount(() => usageController?.abort()) + +// compactNumber 用当前语言把大数值压成万/百万级显示 +function compactNumber(value: number): string { + return new Intl.NumberFormat(locale.value, { + notation: 'compact', + maximumFractionDigits: 1, + }).format(value) +} + +const stateKeys: Record = { + ready: 'state.ready', + busy: 'state.busy', + cooldown: 'state.cooldown', + auth_required: 'state.auth_required', + unavailable: 'state.unavailable', + disabled: 'state.disabled', +} + +const serviceKeys: Record = { + running: 'overview.running', + launching: 'overview.launching', + stopped: 'overview.stopped', + unavailable: 'overview.unavailable', +} + +function formatTime(value?: string): string { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.valueOf())) return '—' + return new Intl.DateTimeFormat(locale.value, { hour: '2-digit', minute: '2-digit' }).format(date) +} + +function accountStateClass(state: Account['state']): string { + if (state === 'ready') return 'bg-green-400' + if (state === 'busy') return 'bg-blue-400' + if (state === 'cooldown') return 'bg-amber-400' + if (state === 'auth_required' || state === 'unavailable') return 'bg-red-400' + return 'bg-gray-500' +} + +function open(tab: TabID): void { + emit('select', tab) +} + +// recordDot 按请求记录的终态给出状态点颜色 +function recordDot(state: string): string { + if (state === 'completed' || state === 'tool_calls') return 'bg-green-400' + if (state === 'failed' || state === 'blocked') return 'bg-red-400' + if (state === 'limited') return 'bg-amber-400' + return 'bg-gray-400' +} + + + diff --git a/web/src/icons.ts b/web/src/icons.ts index 0ed237d7..8caa0cc3 100644 --- a/web/src/icons.ts +++ b/web/src/icons.ts @@ -21,6 +21,7 @@ import { Inbox, KeyRound, Languages, + LayoutDashboard, ListFilter, LoaderCircle, LogIn, @@ -72,6 +73,7 @@ import { // icons 将界面语义名映射到 Lucide 图标 export const icons = { + overview: LayoutDashboard, logs: ScrollText, accounts: KeyRound, access: Cable, diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 06188dde..810b14ce 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -2063,4 +2063,46 @@ export const messages: Record = { 'access.ep.media': 'In einer Antwort erzeugtes Bild oder Audio abrufen', 'access.ep.live': 'Live-WebSocket-Sitzung öffnen', 'access.ep.robotics': 'Gemini-Robotics-WebSocket-Stream öffnen', + + 'nav.overview': 'Übersicht', + 'section.overview.title': 'Dienstübersicht', + 'section.overview.description': 'Konten, Modelle, Anfragezustand und aktuelle Fehler', + 'overview.service': 'Dienststatus', + 'overview.accounts': 'Konten', + 'overview.models': 'Modelle', + 'overview.activeRequests': 'Aktive Anfragen', + 'overview.ready': 'bereit', + 'overview.cooldowns': 'Abkühlungen', + 'overview.authRequired': 'Anmeldung nötig', + 'overview.running': 'Läuft', + 'overview.launching': 'Startet', + 'overview.stopped': 'Gestoppt', + 'overview.unavailable': 'Nicht verfügbar', + 'overview.refresh': 'Übersicht aktualisieren', + 'overview.accountHealth': 'Kontostatus', + 'overview.accountHealthHint': 'Nach aktuellem Routing-Status sortiert', + 'overview.openAccounts': 'Konten anzeigen', + 'overview.modelCoverage': 'Modellverkehr', + 'overview.modelCoverageHint': 'Heutige Anfragen je Modell', + 'overview.openUsage': 'Nutzung anzeigen', + 'overview.recentErrors': 'Aktuelle Fehler', + 'overview.recentErrorsHint': 'Letzte fehlgeschlagene Anfragen heute', + 'overview.noErrors': 'Keine aktuellen Fehler', + 'overview.openLogs': 'Protokolle anzeigen', + 'overview.latestRequests': 'Letzte Anfragen', + 'overview.latestRequestsHint': 'Zuletzt gespeicherte Anfragen von heute', + 'overview.openRequests': 'Anfragen anzeigen', + 'overview.noRequests': 'Keine Anfragen', + 'overview.requests': 'Anfragen', + 'overview.requestSuccess': 'Erfolgsquote', + 'overview.requestFailed': 'Anfrage fehlgeschlagen', + 'overview.modelCooldowns': 'Modell-Abkühlungen', + 'overview.modelCooldownsHint': + 'Welche Modelle abkühlen, auf wie vielen Konten und wann sie wieder verfügbar sind', + 'overview.noCooldowns': 'Keine Abkühlungen', + 'overview.cooldownAccounts': 'Konten in Abkühlung', + 'overview.recoverAt': 'Verfügbar ab', + 'overview.todayUsage': 'Kontonutzung heute', + 'overview.todayUsageHint': 'Heutige Anfragen laut Protokoll (Pazifikzeit)', + 'overview.todayTotals': '{requests} Anfragen · {tokens} Tokens heute', } diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 2c6f458a..3978cdf9 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -2013,4 +2013,46 @@ export const messages: Record = { 'access.ep.media': 'Fetch an image or audio file generated in a response', 'access.ep.live': 'Open a Live WebSocket session', 'access.ep.robotics': 'Open a Gemini Robotics WebSocket stream', + + 'nav.overview': 'Overview', + 'section.overview.title': 'Service overview', + 'section.overview.description': 'Account pool, model coverage, request health and recent errors', + 'overview.service': 'Service', + 'overview.accounts': 'Accounts', + 'overview.models': 'Models', + 'overview.activeRequests': 'Active requests', + 'overview.ready': 'ready', + 'overview.cooldowns': 'cooldowns', + 'overview.authRequired': 'need sign-in', + 'overview.running': 'Running', + 'overview.launching': 'Starting', + 'overview.stopped': 'Stopped', + 'overview.unavailable': 'Unavailable', + 'overview.refresh': 'Refresh overview', + 'overview.accountHealth': 'Account health', + 'overview.accountHealthHint': 'Sorted by current dispatch status', + 'overview.openAccounts': 'View accounts', + 'overview.modelCoverage': 'Model traffic', + 'overview.modelCoverageHint': 'Requests per model today', + 'overview.openUsage': 'View usage', + 'overview.recentErrors': 'Recent errors', + 'overview.recentErrorsHint': 'Recent failed requests today', + 'overview.noErrors': 'No recent errors', + 'overview.openLogs': 'View logs', + 'overview.latestRequests': 'Latest requests', + 'overview.latestRequestsHint': "Today's latest persisted request records", + 'overview.openRequests': 'View requests', + 'overview.noRequests': 'No requests', + 'overview.requests': 'requests', + 'overview.requestSuccess': 'success rate', + 'overview.requestFailed': 'Request failed', + 'overview.modelCooldowns': 'Model cooldowns', + 'overview.modelCooldownsHint': + 'Which models are cooling down, on how many accounts, and when they recover', + 'overview.noCooldowns': 'No cooldowns', + 'overview.cooldownAccounts': 'accounts cooling down', + 'overview.recoverAt': 'Recovers', + 'overview.todayUsage': 'Account usage today', + 'overview.todayUsageHint': 'Today in Pacific time, from the request ledger', + 'overview.todayTotals': '{requests} requests · {tokens} tokens today', } diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 1f21683e..be82e648 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -2071,4 +2071,46 @@ export const messages: Record = { 'access.ep.media': 'Récupérer une image ou un audio généré dans une réponse', 'access.ep.live': 'Ouvrir une session WebSocket Live', 'access.ep.robotics': 'Ouvrir un flux WebSocket Gemini Robotics', + + 'nav.overview': 'Vue d’ensemble', + 'section.overview.title': 'Vue d’ensemble du service', + 'section.overview.description': 'Comptes, modèles, santé des requêtes et erreurs récentes', + 'overview.service': 'Service', + 'overview.accounts': 'Comptes', + 'overview.models': 'Modèles', + 'overview.activeRequests': 'Requêtes actives', + 'overview.ready': 'disponibles', + 'overview.cooldowns': 'en refroidissement', + 'overview.authRequired': 'connexion requise', + 'overview.running': 'En cours', + 'overview.launching': 'Démarrage', + 'overview.stopped': 'Arrêté', + 'overview.unavailable': 'Indisponible', + 'overview.refresh': 'Actualiser', + 'overview.accountHealth': 'Santé des comptes', + 'overview.accountHealthHint': 'Triés selon l’état de routage actuel', + 'overview.openAccounts': 'Voir les comptes', + 'overview.modelCoverage': 'Trafic des modèles', + 'overview.modelCoverageHint': 'Requêtes par modèle aujourd’hui', + 'overview.openUsage': 'Voir l’utilisation', + 'overview.recentErrors': 'Erreurs récentes', + 'overview.recentErrorsHint': 'Dernières requêtes échouées du jour', + 'overview.noErrors': 'Aucune erreur récente', + 'overview.openLogs': 'Voir les journaux', + 'overview.latestRequests': 'Dernières requêtes', + 'overview.latestRequestsHint': 'Dernières requêtes enregistrées du jour', + 'overview.openRequests': 'Voir les requêtes', + 'overview.noRequests': 'Aucune requête', + 'overview.requests': 'requêtes', + 'overview.requestSuccess': 'taux de réussite', + 'overview.requestFailed': 'Échec de la requête', + 'overview.modelCooldowns': 'Modèles en refroidissement', + 'overview.modelCooldownsHint': + 'Modèles en refroidissement, nombre de comptes et reprise la plus proche', + 'overview.noCooldowns': 'Aucun refroidissement', + 'overview.cooldownAccounts': 'comptes en refroidissement', + 'overview.recoverAt': 'Reprise', + 'overview.todayUsage': 'Utilisation du jour par compte', + 'overview.todayUsageHint': 'Requêtes du jour, selon le registre (heure du Pacifique)', + 'overview.todayTotals': '{requests} requêtes · {tokens} tokens aujourd’hui', } diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 5ce11170..3d6915f4 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -2006,4 +2006,45 @@ export const messages: Record = { 'access.ep.media': '回答で生成された画像や音声を取得', 'access.ep.live': 'Live のリアルタイム会話の WebSocket を開く', 'access.ep.robotics': 'Gemini Robotics の WebSocket ストリームを開く', + + 'nav.overview': '概要', + 'section.overview.title': 'サービス概要', + 'section.overview.description': 'アカウント、モデル、リクエスト状態、最近のエラー', + 'overview.service': 'サービス状態', + 'overview.accounts': 'アカウント', + 'overview.models': 'モデル', + 'overview.activeRequests': '処理中のリクエスト', + 'overview.ready': '利用可能', + 'overview.cooldowns': 'クールダウン', + 'overview.authRequired': '再ログインが必要', + 'overview.running': '実行中', + 'overview.launching': '起動中', + 'overview.stopped': '停止', + 'overview.unavailable': '利用不可', + 'overview.refresh': '概要を更新', + 'overview.accountHealth': 'アカウント状態', + 'overview.accountHealthHint': '現在の配信状態順', + 'overview.openAccounts': 'アカウントを表示', + 'overview.modelCoverage': 'モデル利用状況', + 'overview.modelCoverageHint': '本日のモデル別リクエスト数', + 'overview.openUsage': '使用量を表示', + 'overview.recentErrors': '最近のエラー', + 'overview.recentErrorsHint': '本日の最近の失敗リクエスト', + 'overview.noErrors': 'エラーはありません', + 'overview.openLogs': 'ログを表示', + 'overview.latestRequests': '最近のリクエスト', + 'overview.latestRequestsHint': '本日の最新の保存済みリクエスト', + 'overview.openRequests': 'リクエストを表示', + 'overview.noRequests': 'リクエストはありません', + 'overview.requests': '件', + 'overview.requestSuccess': '成功率', + 'overview.requestFailed': 'リクエスト失敗', + 'overview.modelCooldowns': 'モデルのクールダウン', + 'overview.modelCooldownsHint': 'クールダウン中のモデル、アカウント数、最短の復帰時刻', + 'overview.noCooldowns': 'クールダウンなし', + 'overview.cooldownAccounts': 'アカウントがクールダウン中', + 'overview.recoverAt': '復帰', + 'overview.todayUsage': 'アカウントの本日使用量', + 'overview.todayUsageHint': '太平洋時間の本日のリクエスト台帳から集計', + 'overview.todayTotals': '本日 {requests} 件 · {tokens} tokens', } diff --git a/web/src/locales/ko.ts b/web/src/locales/ko.ts index d9b72d8e..283f47f3 100644 --- a/web/src/locales/ko.ts +++ b/web/src/locales/ko.ts @@ -1977,4 +1977,45 @@ export const messages: Record = { 'access.ep.media': '응답에서 생성된 이미지나 오디오 가져오기', 'access.ep.live': 'Live 실시간 대화 WebSocket 열기', 'access.ep.robotics': 'Gemini Robotics WebSocket 스트림 열기', + + 'nav.overview': '개요', + 'section.overview.title': '서비스 개요', + 'section.overview.description': '계정 풀, 모델 범위, 요청 상태 및 최근 오류', + 'overview.service': '서비스 상태', + 'overview.accounts': '계정', + 'overview.models': '모델', + 'overview.activeRequests': '진행 중인 요청', + 'overview.ready': '사용 가능', + 'overview.cooldowns': '쿨다운', + 'overview.authRequired': '재로그인 필요', + 'overview.running': '실행 중', + 'overview.launching': '시작 중', + 'overview.stopped': '중지됨', + 'overview.unavailable': '사용 불가', + 'overview.refresh': '개요 새로고침', + 'overview.accountHealth': '계정 상태', + 'overview.accountHealthHint': '현재 디스패치 상태순', + 'overview.openAccounts': '계정 보기', + 'overview.modelCoverage': '모델 사용량', + 'overview.modelCoverageHint': '오늘 모델별 요청 수', + 'overview.openUsage': '사용량 보기', + 'overview.recentErrors': '최근 오류', + 'overview.recentErrorsHint': '오늘 최근 실패한 요청', + 'overview.noErrors': '최근 오류 없음', + 'overview.openLogs': '로그 보기', + 'overview.latestRequests': '최근 요청', + 'overview.latestRequestsHint': '오늘 최근 저장된 요청 기록', + 'overview.openRequests': '요청 보기', + 'overview.noRequests': '요청 없음', + 'overview.requests': '개', + 'overview.requestSuccess': '성공률', + 'overview.requestFailed': '요청 실패', + 'overview.modelCooldowns': '모델 쿨다운', + 'overview.modelCooldownsHint': '쿨다운 중인 모델, 계정 수, 가장 빠른 복구 시각', + 'overview.noCooldowns': '쿨다운 없음', + 'overview.cooldownAccounts': '개 계정 쿨다운 중', + 'overview.recoverAt': '복구', + 'overview.todayUsage': '계정 오늘 사용량', + 'overview.todayUsageHint': '태평양 시간 기준 오늘의 요청 기록에서 집계', + 'overview.todayTotals': '오늘 {requests}건 · {tokens} tokens', } diff --git a/web/src/locales/zh-CN.ts b/web/src/locales/zh-CN.ts index 6060ad31..e6207b05 100644 --- a/web/src/locales/zh-CN.ts +++ b/web/src/locales/zh-CN.ts @@ -1873,6 +1873,47 @@ export const messages = { 'access.ep.media': '读取回答中生成的图片或音频', 'access.ep.live': '打开 Live 实时对话的 WebSocket', 'access.ep.robotics': '打开 Gemini Robotics 的 WebSocket 流', + + 'nav.overview': '总览', + 'section.overview.title': '服务总览', + 'section.overview.description': '账号池、模型目录、请求健康与最近错误', + 'overview.service': '服务状态', + 'overview.accounts': '账号池', + 'overview.models': '模型', + 'overview.activeRequests': '进行中请求', + 'overview.ready': '可用', + 'overview.cooldowns': '冷却中', + 'overview.authRequired': '个待重新登录', + 'overview.running': '运行中', + 'overview.launching': '启动中', + 'overview.stopped': '已停止', + 'overview.unavailable': '不可用', + 'overview.refresh': '刷新总览', + 'overview.accountHealth': '账号健康', + 'overview.accountHealthHint': '按当前可调度状态排序', + 'overview.openAccounts': '查看账号', + 'overview.modelCoverage': '模型调用分布', + 'overview.modelCoverageHint': '当天各模型的请求数', + 'overview.openUsage': '查看用量', + 'overview.recentErrors': '最近错误', + 'overview.recentErrorsHint': '当天最近的失败请求', + 'overview.noErrors': '暂无错误', + 'overview.openLogs': '查看日志', + 'overview.latestRequests': '最近请求', + 'overview.latestRequestsHint': '当天最近的持久化请求记录', + 'overview.openRequests': '查看请求', + 'overview.noRequests': '暂无请求', + 'overview.requests': '次请求', + 'overview.requestSuccess': '成功率', + 'overview.requestFailed': '请求失败', + 'overview.modelCooldowns': '模型冷却池', + 'overview.modelCooldownsHint': '哪些模型在冷却、多少账号、最早何时恢复', + 'overview.noCooldowns': '暂无冷却', + 'overview.cooldownAccounts': '个账号冷却中', + 'overview.recoverAt': '最早恢复', + 'overview.todayUsage': '账号今日用量', + 'overview.todayUsageHint': '按太平洋时区当天的请求账本统计', + 'overview.todayTotals': '今日 {requests} 次 · {tokens} tokens', } as const export type TranslationKey = keyof typeof messages diff --git a/web/src/locales/zh-TW.ts b/web/src/locales/zh-TW.ts index 9a9e79a2..fa96d848 100644 --- a/web/src/locales/zh-TW.ts +++ b/web/src/locales/zh-TW.ts @@ -1880,4 +1880,45 @@ export const messages: Record = { 'access.ep.media': '讀取回答中產生的圖片或音訊', 'access.ep.live': '開啟 Live 即時對話的 WebSocket', 'access.ep.robotics': '開啟 Gemini Robotics 的 WebSocket 串流', + + 'nav.overview': '總覽', + 'section.overview.title': '服務總覽', + 'section.overview.description': '帳號池、模型目錄、請求健康與最近錯誤', + 'overview.service': '服務狀態', + 'overview.accounts': '帳號池', + 'overview.models': '模型', + 'overview.activeRequests': '進行中請求', + 'overview.ready': '可用', + 'overview.cooldowns': '冷卻中', + 'overview.authRequired': '個待重新登入', + 'overview.running': '執行中', + 'overview.launching': '啟動中', + 'overview.stopped': '已停止', + 'overview.unavailable': '不可用', + 'overview.refresh': '重新整理總覽', + 'overview.accountHealth': '帳號健康', + 'overview.accountHealthHint': '依目前可調度狀態排序', + 'overview.openAccounts': '查看帳號', + 'overview.modelCoverage': '模型呼叫分布', + 'overview.modelCoverageHint': '當天各模型的請求數', + 'overview.openUsage': '查看用量', + 'overview.recentErrors': '最近錯誤', + 'overview.recentErrorsHint': '當天最近的失敗請求', + 'overview.noErrors': '暫無錯誤', + 'overview.openLogs': '查看日誌', + 'overview.latestRequests': '最近請求', + 'overview.latestRequestsHint': '當天最近的持久化請求記錄', + 'overview.openRequests': '查看請求', + 'overview.noRequests': '暫無請求', + 'overview.requests': '次請求', + 'overview.requestSuccess': '成功率', + 'overview.requestFailed': '請求失敗', + 'overview.modelCooldowns': '模型冷卻池', + 'overview.modelCooldownsHint': '哪些模型在冷卻、多少帳號、最早何時恢復', + 'overview.noCooldowns': '暫無冷卻', + 'overview.cooldownAccounts': '個帳號冷卻中', + 'overview.recoverAt': '最早恢復', + 'overview.todayUsage': '帳號今日用量', + 'overview.todayUsageHint': '依太平洋時區當天的請求帳本統計', + 'overview.todayTotals': '今日 {requests} 次 · {tokens} tokens', } diff --git a/web/src/overview-metrics.test.ts b/web/src/overview-metrics.test.ts new file mode 100644 index 00000000..8d6ae3c0 --- /dev/null +++ b/web/src/overview-metrics.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vite-plus/test' +import { groupCooldowns, pacificDayStart } from './overview-metrics' + +describe('overview metrics', () => { + it('groups distinct cooling accounts and their earliest recovery by model', () => { + expect( + groupCooldowns([ + { account_id: 'a', model_id: 'argon', until: '2026-11-02T08:00:00Z' }, + { account_id: 'b', model_id: 'argon', until: '2026-11-02T07:00:00Z' }, + { account_id: 'b', model_id: 'argon', until: '2026-11-02T09:00:00Z' }, + { account_id: 'a', model_id: '', until: '2026-11-02T06:00:00Z' }, + ]), + ).toEqual([ + { model: 'argon', accounts: 2, until: '2026-11-02T07:00:00Z' }, + { model: '*', accounts: 1, until: '2026-11-02T06:00:00Z' }, + ]) + expect(groupCooldowns([])).toEqual([]) + }) + + it.each([ + ['2026-11-01T06:59:59Z', '2026-10-31T07:00:00.000Z'], + ['2026-11-01T07:00:00Z', '2026-11-01T07:00:00.000Z'], + ['2026-11-01T20:00:00Z', '2026-11-01T07:00:00.000Z'], + ['2026-11-02T20:00:00Z', '2026-11-02T08:00:00.000Z'], + ['2026-03-08T07:59:59Z', '2026-03-07T08:00:00.000Z'], + ['2026-03-08T20:00:00Z', '2026-03-08T08:00:00.000Z'], + ['2026-03-09T20:00:00Z', '2026-03-09T07:00:00.000Z'], + ])('uses the Pacific midnight offset for %s', (now, expected) => { + expect(pacificDayStart(new Date(now)).toISOString()).toBe(expected) + }) +}) diff --git a/web/src/overview-metrics.ts b/web/src/overview-metrics.ts new file mode 100644 index 00000000..5d98a1f9 --- /dev/null +++ b/web/src/overview-metrics.ts @@ -0,0 +1,63 @@ +export interface CooldownMetric { + account_id: string + model_id: string + until: string +} + +export interface ModelCooldown { + model: string + accounts: number + until: string +} + +// groupCooldowns 按模型汇总冷却中的账号数与最早恢复时间,冷却账号多的模型排前面 +export function groupCooldowns(cooldowns: CooldownMetric[]): ModelCooldown[] { + const groups = new Map; until: string }>() + for (const cooldown of cooldowns) { + const model = cooldown.model_id || '*' + const group = groups.get(model) ?? { accounts: new Set(), until: cooldown.until } + group.accounts.add(cooldown.account_id) + if (Date.parse(cooldown.until) < Date.parse(group.until)) group.until = cooldown.until + groups.set(model, group) + } + return [...groups.entries()] + .map(([model, group]) => ({ model, accounts: group.accounts.size, until: group.until })) + .sort((left, right) => right.accounts - left.accounts || left.model.localeCompare(right.model)) +} + +// pacificDayStart 返回给定时刻所在的太平洋时区当天零点 +export function pacificDayStart(now: Date): Date { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/Los_Angeles', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now) + const value = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0) + const midnightUTC = Date.UTC(value('year'), value('month') - 1, value('day')) + return new Date(midnightUTC - zoneOffset(new Date(midnightUTC), 'America/Los_Angeles')) +} + +// zoneOffset 返回指定时刻在该时区相对 UTC 的毫秒偏移 +function zoneOffset(date: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(date) + const value = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0) + const asUTC = Date.UTC( + value('year'), + value('month') - 1, + value('day'), + value('hour') % 24, + value('minute'), + value('second'), + ) + return asUTC - date.getTime() +} diff --git a/web/src/types.ts b/web/src/types.ts index 360da0ad..f6bffc75 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,6 +1,7 @@ export type Locale = 'zh-CN' | 'zh-TW' | 'en' | 'ja' | 'ko' | 'fr' | 'de' export type TabID = + | 'overview' | 'logs' | 'accounts' | 'access'