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'
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.empty') }}
+
+
+
+
+
+
{{ account.label || account.id }}
+
+ {{ account.benefit_tier || t('accounts.tierUnknown') }} ·
+ {{ account.models.length }} {{ t('overview.models') }}
+
+
+
+ {{ t(stateKeys[account.state]) }}
+
+
+
+
+
+
+
+
+ {{ t('overview.noRequests') }}
+
+
+
+ {{ model }}
+ {{ count }} {{ t('overview.requests') }}
+
+
+
+
+
+
+
+
+
+ {{ t('overview.noCooldowns') }}
+
+
+
+
+
+ {{ item.model }}
+
+ {{ item.accounts }} {{ t('overview.cooldownAccounts') }}
+
+
+
+ {{ t('overview.recoverAt') }} {{ formatTime(item.until) }}
+
+
+
+
+
+
+
+
+ {{ t('overview.noErrors') }}
+
+
+
+
+ {{
+ request.status || 'ERR'
+ }}
+ {{
+ request.model || request.id
+ }}
+
+
+ {{ request.error || t('overview.requestFailed') }}
+
+
+
+
+
+
+
+
+ {{ t('overview.noRequests') }}
+
+
+
+
+
+ {{ record.model || '—' }}
+ {{
+ record.account || '—'
+ }}
+
+
{{ formatTime(record.time) }}
+
+
+
+
+
+
+
+
+ {{ todayUsageError }}
+
+
+ {{ t('common.loading') }}
+
+
+ {{ t('overview.noRequests') }}
+
+
+
+ {{ group.key }}
+ {{
+ t('overview.todayTotals')
+ .replace('{requests}', compactNumber(group.requests))
+ .replace('{tokens}', compactNumber(group.total_tokens))
+ }}
+
+
+
+
+
+
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'