Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export function fetchOverview(keyOverride?: string, options?: { refreshQuota?: b
return api<Overview>(path, {}, keyOverride)
}

export function fetchOverviewSummary(keyOverride?: string) {
return api<Overview>('/api/overview/summary', {}, keyOverride)
}

export function fetchAccounts(refresh = false) {
return api<{ object?: string; data?: NonNullable<Overview['accounts']> }>(
`/api/accounts?refresh=${refresh ? '1' : '0'}`,
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@ export type Overview = {
rewarm_count?: number
lastError?: string
last_error?: string
account_count?: number
ready_count?: number
hot_count?: number
cooling_count?: number
in_flight?: number
}
model_count?: number
routing?: {
strategy?: string
session_affinity?: {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ export function AppSidebar({ mobileOpen, onClose }: Props) {
const proxyOk = Boolean(overview?.proxy?.ok)
const workerOk = Boolean(overview?.worker?.ok)
const healthy = proxyOk && workerOk
const accountCount = overview?.accounts?.length ?? 0
const hotCount = overview?.accounts?.filter((account) => account.hot).length ?? 0
const accountCount = overview?.worker?.account_count ?? 0
const hotCount = overview?.worker?.hot_count ?? 0
const showStatusSkeleton = loading

function toggleCollapsed() {
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/hooks/useOverview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { fetchOverview } from '@/api/overview'
import { fetchOverviewSummary } from '@/api/overview'
import { isUnauthorized } from '@/api/client'
import { useApiKey } from '@/hooks/useApiKey'
import type { Overview } from '@/api/types'
Expand Down Expand Up @@ -34,10 +34,9 @@ export function OverviewProvider({ children }: { children: ReactNode }) {
throw new Error('missing_api_key')
}
const silent = Boolean(options?.silent)
const refreshQuota = options?.refreshQuota ?? !silent
if (!silent) setLoading(true)
try {
const data = await fetchOverview(key, { refreshQuota })
const data = await fetchOverviewSummary(key)
setOverview(data)
setError(null)
return data
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/pages/AccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
} from '@phosphor-icons/react'
import { useI18n } from '@/hooks/useI18n'
import { useOverview } from '@/hooks/useOverview'
import { fetchModels, testChat } from '@/api/overview'
import type { ModelInfo } from '@/api/types'
import { fetchAccounts, fetchModels, testChat } from '@/api/overview'
import type { ModelInfo, Overview } from '@/api/types'
import { absUrl } from '@/lib/url'
import { EmptyPanel } from '@/components/ui/EmptyPanel'
import { PageAlert } from '@/components/ui/PageAlert'
Expand Down Expand Up @@ -98,8 +98,17 @@ function PlaygroundSelect({
export function AccessPage() {
const { t } = useI18n()
const { overview, loading } = useOverview()
const poolModels = overview?.models || []
const accounts = overview?.accounts || []
const [poolModels, setPoolModels] = useState<ModelInfo[]>([])
const [accounts, setAccounts] = useState<NonNullable<Overview['accounts']>>([])
useEffect(() => {
let cancelled = false
void Promise.allSettled([fetchModels(), fetchAccounts(false)]).then(([modelsResult, accountsResult]) => {
if (cancelled) return
if (modelsResult.status === 'fulfilled') setPoolModels(modelsResult.value.data || [])
if (accountsResult.status === 'fulfilled') setAccounts(accountsResult.value.data || [])
})
return () => { cancelled = true }
}, [])
const base = absUrl(overview?.access?.openai_base_url || '/v1')
const chatPath = overview?.access?.chat_completions || '/v1/chat/completions'
const chatEndpoint = absUrl(chatPath)
Expand Down
16 changes: 10 additions & 6 deletions frontend/src/pages/KeysPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Card, Checkbox, Chip, Description, Form, Input, Label, Modal } from '@heroui/react'
import { Copy, Key, Plus, TrashSimple, X } from '@phosphor-icons/react'
import { createAPIKey, deleteAPIKey, fetchAPIKeys, updateAPIKey, type APIKeyRecord } from '@/api/keys'
import { fetchAccounts } from '@/api/overview'
import { BrandMark } from '@/components/BrandMark'
import { ProviderMark } from '@/components/ProviderMark'
import { CompactSwitch } from '@/components/ui/CompactSwitch'
Expand All @@ -10,7 +11,6 @@ import { EmptyPanel } from '@/components/ui/EmptyPanel'
import { PageAlert } from '@/components/ui/PageAlert'
import { KeysPageSkeleton, SkeletonBlock } from '@/components/ui/PageSkeletons'
import { useI18n } from '@/hooks/useI18n'
import { useOverview } from '@/hooks/useOverview'
import { accountProviderFamilyLabel } from '@/lib/provider'

const PROVIDER_IDS = ['qoder', 'workbuddy', 'trae']
Expand All @@ -29,7 +29,7 @@ function formatTime(value?: string) {

export function KeysPage() {
const { t } = useI18n()
const { overview } = useOverview()
const [accountProviders, setAccountProviders] = useState<string[]>([])
const [keys, setKeys] = useState<APIKeyRecord[]>([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState('')
Expand All @@ -40,13 +40,17 @@ export function KeysPage() {
const [revealed, setRevealed] = useState<APIKeyRecord | null>(null)
const [copied, setCopied] = useState(false)

useEffect(() => {
void fetchAccounts(false).then((result) => {
setAccountProviders([...new Set((result.data || []).map((account) => account.provider).filter(Boolean) as string[])])
}).catch(() => undefined)
}, [])

const availableProviders = useMemo(() => {
const ids = new Set(PROVIDER_IDS)
for (const account of overview?.accounts || []) {
if (account.provider) ids.add(account.provider)
}
for (const provider of accountProviders) ids.add(provider)
return [...ids]
}, [overview?.accounts])
}, [accountProviders])

async function load() {
setLoading(true)
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/pages/LogsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
type RequestLog,
type RuntimeLogEntry,
} from '@/api/logs'
import { fetchAccounts, fetchModels } from '@/api/overview'
import type { Overview } from '@/api/types'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { EmptyPanel } from '@/components/ui/EmptyPanel'
import { FilterSearchSelect } from '@/components/ui/FilterSearchSelect'
Expand All @@ -41,7 +43,6 @@ import { PageAlert } from '@/components/ui/PageAlert'
import { LogsPageSkeleton, LogsRequestListSkeleton, LogsRuntimeListSkeleton } from '@/components/ui/PageSkeletons'
import { SearchBar } from '@/components/ui/SearchBar'
import { useI18n } from '@/hooks/useI18n'
import { useOverview } from '@/hooks/useOverview'
import { accountProviderLabel } from '@/lib/provider'

type PageTab = 'requests' | 'runtime'
Expand Down Expand Up @@ -120,9 +121,8 @@ function rangeFromPreset(preset: TimeRange) {

export function LogsPage() {
const { t, lang } = useI18n()
const { overview } = useOverview()
const accounts = overview?.accounts
const models = overview?.models
const [accounts, setAccounts] = useState<NonNullable<Overview['accounts']>>([])
const [models, setModels] = useState<NonNullable<Overview['models']>>([])
const [tab, setTab] = useState<PageTab>('requests')
const [loading, setLoading] = useState(true)
const [booted, setBooted] = useState(false)
Expand Down Expand Up @@ -151,6 +151,16 @@ export function LogsPage() {
const [clearOpen, setClearOpen] = useState(false)
const [busy, setBusy] = useState(false)

useEffect(() => {
let cancelled = false
void Promise.allSettled([fetchAccounts(false), fetchModels()]).then(([accountsResult, modelsResult]) => {
if (cancelled) return
if (accountsResult.status === 'fulfilled') setAccounts(accountsResult.value.data || [])
if (modelsResult.status === 'fulfilled') setModels(modelsResult.value.data || [])
})
return () => { cancelled = true }
}, [])

const accountNameById = useMemo(() => {
const names = new Map<string, string>()
for (const account of accounts || []) names.set(account.id, account.name || account.id)
Expand Down
27 changes: 20 additions & 7 deletions frontend/src/pages/OverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { PageAlert } from '@/components/ui/PageAlert'
import { OverviewPageSkeleton, RankListSkeleton, SkeletonBlock, TrafficChartSkeleton } from '@/components/ui/PageSkeletons'
import { useI18n } from '@/hooks/useI18n'
import { useOverview } from '@/hooks/useOverview'
import { fetchAccounts, fetchModels } from '@/api/overview'
import { formatCompact, formatLatency, formatPercent } from '@/lib/format'
import { accountProviderFamilyLabel, accountProviderLabel } from '@/lib/provider'
import { ProviderMark } from '@/components/ProviderMark'
Expand Down Expand Up @@ -74,17 +75,29 @@ export function OverviewPage() {
const [stats, setStats] = useState<RequestStats | null>(null)
const [statsError, setStatsError] = useState('')
const [statsLoading, setStatsLoading] = useState(true)
const [accounts, setAccounts] = useState<NonNullable<Overview['accounts']>>([])
const [accountsLoading, setAccountsLoading] = useState(true)
const [modelCount, setModelCount] = useState(0)

const proxyOk = Boolean(overview?.proxy?.ok)
const workerOk = Boolean(overview?.worker?.ok)
const accounts = overview?.accounts || []
const readyAccounts = accounts.filter((account) => account.ready).length
const hotAccounts = accounts.filter((account) => account.hot).length
const coolingAccounts = accounts.filter((account) => account.down_until || account.cooldown_until).length
const inFlight = accounts.reduce((total, account) => total + (account.in_flight ?? account.inFlight ?? 0), 0)
const modelCount = overview?.models?.length ?? 0
const readyAccounts = overview?.worker?.ready_count ?? 0
const hotAccounts = overview?.worker?.hot_count ?? 0
const coolingAccounts = overview?.worker?.cooling_count ?? 0
const inFlight = overview?.worker?.in_flight ?? 0
const traffic = stats ?? EMPTY_STATS

useEffect(() => {
let cancelled = false
void Promise.allSettled([fetchAccounts(false), fetchModels()]).then(([accountsResult, modelsResult]) => {
if (cancelled) return
if (accountsResult.status === 'fulfilled') setAccounts(accountsResult.value.data || [])
if (modelsResult.status === 'fulfilled') setModelCount((modelsResult.value.data || []).length)
})
.finally(() => { if (!cancelled) setAccountsLoading(false) })
return () => { cancelled = true }
}, [])

useEffect(() => {
let cancelled = false
void fetchRequestStats({ hours })
Expand Down Expand Up @@ -244,7 +257,7 @@ export function OverviewPage() {
))}
</div>
<div className="divide-y divide-separator">
{loading ? (
{accountsLoading ? (
<RankListSkeleton />
) : accounts.length === 0 ? (
<div className="px-5 py-8 text-sm text-muted">{t('noAccounts')}</div>
Expand Down
27 changes: 18 additions & 9 deletions frontend/src/pages/ProvidersPage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Button, Card, Chip, Input, Table, Tooltip } from '@heroui/react'
import { Cube, ArrowClockwise, ArrowCounterClockwise, FloppyDisk, MagnifyingGlass, Info } from '@phosphor-icons/react'
import { useI18n } from '@/hooks/useI18n'
import { useOverview } from '@/hooks/useOverview'
import { refreshModels, updateModelContext, updateProviderReasoning, updateTraeMaxMode } from '@/api/overview'
import { fetchModels, refreshModels, updateModelContext, updateProviderReasoning, updateTraeMaxMode } from '@/api/overview'
import type { Overview } from '@/api/types'
import { ProviderMark } from '@/components/ProviderMark'
import { ModelDetailsModal, formatTokens } from '@/components/ModelDetailsModal'
Expand Down Expand Up @@ -167,7 +167,7 @@ function ModelActions({

export function ProvidersPage() {
const { t } = useI18n()
const { overview, loading, setOverview } = useOverview()
const { overview, loading } = useOverview()
const [filter, setFilter] = useState('')
const [providerFilter, setProviderFilter] = useState('')
const [page, setPage] = useState(1)
Expand All @@ -178,7 +178,16 @@ export function ProvidersPage() {
const [messageError, setMessageError] = useState(false)
const [drafts, setDrafts] = useState<Record<string, string>>({})
const [detailModel, setDetailModel] = useState<ModelInfo | null>(null)
const models = useMemo(() => overview?.models || [], [overview?.models])
const [models, setModels] = useState<ModelInfo[]>([])
const [modelsLoading, setModelsLoading] = useState(true)
useEffect(() => {
let cancelled = false
void fetchModels()
.then((data) => { if (!cancelled) setModels(data.data || []) })
.catch(() => undefined)
.finally(() => { if (!cancelled) setModelsLoading(false) })
return () => { cancelled = true }
}, [])
const providers = useMemo(() => {
const ids = new Set<string>()
for (const model of models) ids.add(modelProvider(model))
Expand Down Expand Up @@ -216,7 +225,7 @@ export function ProvidersPage() {
? t('logsShownTotal', { shown: `${shownFrom}–${shownTo}`, total: filtered.length })
: t('shownTotal', { shown: 0, total: models.length })

if (loading && !overview) return <ProvidersPageSkeleton />
if ((loading && !overview) || modelsLoading) return <ProvidersPageSkeleton />

function updateModelInOverview(model: ModelInfo, result: Awaited<ReturnType<typeof updateModelContext>>) {
const key = modelSettingsKey(model)
Expand All @@ -229,7 +238,7 @@ export function ProvidersPage() {
context_custom: result.context_custom,
}
: item)
setOverview({ ...(overview || {}), models: nextModels })
setModels(nextModels)
setDrafts((current) => ({ ...current, [key]: String(result.context_length) }))
}

Expand All @@ -247,7 +256,7 @@ export function ProvidersPage() {
context_length: maxMode && max ? max : dev,
}
})
setOverview({ ...(overview || {}), models: nextModels })
setModels(nextModels)
}

function updateReasoningInOverview(model: ModelInfo, effort: string) {
Expand All @@ -261,7 +270,7 @@ export function ProvidersPage() {
context_custom: Boolean(item.max_mode) || Boolean(effort && effort !== item.reasoning_default),
}
})
setOverview({ ...(overview || {}), models: nextModels })
setModels(nextModels)
}

async function onRefresh() {
Expand All @@ -270,7 +279,7 @@ export function ProvidersPage() {
setMessageError(false)
try {
const data = await refreshModels()
setOverview({ ...(overview || {}), models: data.data || [] })
setModels(data.data || [])
} catch (error) {
setMessageError(true)
setMessage(error instanceof Error ? error.message : String(error))
Expand Down
37 changes: 37 additions & 0 deletions internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func TestManagementRoutesRequireAPIKey(t *testing.T) {

for _, path := range []string{
"/api/overview",
"/api/overview/summary",
"/api/models",
"/api/chat",
"/api/accounts",
Expand All @@ -65,6 +66,42 @@ func TestManagementRoutesRequireAPIKey(t *testing.T) {
}
}

func TestOverviewSummaryReturnsLightweightSnapshot(t *testing.T) {
dir := t.TempDir()
srv := New(config.Config{
Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret",
QoderHome: dir, DataDir: dir,
})
defer srv.Close()

req := httptest.NewRequest(http.MethodGet, "/api/overview/summary", nil)
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var summary struct {
Proxy struct {
Service string `json:"service"`
} `json:"proxy"`
Worker struct {
AccountCount int `json:"account_count"`
} `json:"worker"`
Models []map[string]any `json:"models"`
Accounts []map[string]any `json:"accounts"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &summary); err != nil {
t.Fatal(err)
}
if summary.Proxy.Service != "cli2api" || summary.Worker.AccountCount != 0 {
t.Fatalf("summary = %+v", summary)
}
if summary.Models != nil || summary.Accounts != nil {
t.Fatalf("summary must not include detail collections: %+v", summary)
}
}

func TestCanonicalModelIDNormalizesWithoutAliases(t *testing.T) {
for input, want := range map[string]string{
"MiniMax-M3": "minimax-m3",
Expand Down
Loading
Loading