diff --git a/vercel.json b/vercel.json
index 2e68a94..bad49e0 100644
--- a/vercel.json
+++ b/vercel.json
@@ -38,6 +38,18 @@
"source": "/admin/config",
"destination": "/api/index"
},
+ {
+ "source": "/admin/config/(.*)",
+ "destination": "/api/index"
+ },
+ {
+ "source": "/admin/settings",
+ "destination": "/api/index"
+ },
+ {
+ "source": "/admin/settings/(.*)",
+ "destination": "/api/index"
+ },
{
"source": "/admin/keys(.*)",
"destination": "/api/index"
diff --git a/webui/src/App.jsx b/webui/src/App.jsx
index 3f6ad27..2c3f099 100644
--- a/webui/src/App.jsx
+++ b/webui/src/App.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useCallback, useMemo } from 'react'
import {
Routes,
Route,
@@ -29,12 +29,12 @@ import Login from './components/Login'
import LandingPage from './components/LandingPage'
import LanguageToggle from './components/LanguageToggle'
import { useI18n } from './i18n'
+import { detectRuntimeEnv } from './utils/runtimeEnv'
-function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message, onForceLogout }) {
+function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message, onForceLogout, isVercel }) {
const { t } = useI18n()
const [activeTab, setActiveTab] = useState('accounts')
const [sidebarOpen, setSidebarOpen] = useState(false)
- const [loading, setLoading] = useState(false)
const navItems = [
{ id: 'accounts', label: t('nav.accounts.label'), icon: Users, description: t('nav.accounts.desc') },
@@ -44,7 +44,7 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message,
{ id: 'settings', label: t('nav.settings.label'), icon: SettingsIcon, description: t('nav.settings.desc') },
]
- const authFetch = async (url, options = {}) => {
+ const authFetch = useCallback(async (url, options = {}) => {
const headers = {
...options.headers,
'Authorization': `Bearer ${token}`
@@ -56,7 +56,7 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message,
throw new Error(t('auth.expired'))
}
return res
- }
+ }, [onLogout, t, token])
const renderTab = () => {
switch (activeTab) {
@@ -67,9 +67,9 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message,
case 'import':
return
case 'vercel':
- return
+ return
case 'settings':
- return
+ return
default:
return null
}
@@ -213,13 +213,27 @@ export default function App() {
const navigate = useNavigate()
const location = useLocation()
const [config, setConfig] = useState({ keys: [], accounts: [] })
- const [loading, setLoading] = useState(true)
const [message, setMessage] = useState(null)
const [token, setToken] = useState(null)
const [authChecking, setAuthChecking] = useState(true)
const isProduction = import.meta.env.MODE === 'production'
const isAdminRoute = location.pathname.startsWith('/admin') || isProduction
+ const runtimeEnv = useMemo(() => detectRuntimeEnv(), [])
+ const isVercel = runtimeEnv.isVercel
+
+ const showMessage = useCallback((type, text) => {
+ setMessage({ type, text })
+ setTimeout(() => setMessage(null), 5000)
+ }, [])
+
+ const handleLogout = useCallback(() => {
+ setToken(null)
+ localStorage.removeItem('ds2api_token')
+ localStorage.removeItem('ds2api_token_expires')
+ sessionStorage.removeItem('ds2api_token')
+ sessionStorage.removeItem('ds2api_token_expires')
+ }, [])
useEffect(() => {
// Only check auth status on admin routes.
@@ -249,12 +263,11 @@ export default function App() {
setAuthChecking(false)
}
checkAuth()
- }, [isAdminRoute])
+ }, [handleLogout, isAdminRoute])
- const fetchConfig = async () => {
+ const fetchConfig = useCallback(async () => {
if (!token) return
try {
- setLoading(true)
const res = await fetch('/admin/config', {
headers: { 'Authorization': `Bearer ${token}` }
})
@@ -265,34 +278,19 @@ export default function App() {
} catch (e) {
console.error('Failed to fetch config:', e)
showMessage('error', t('errors.fetchConfig', { error: e.message }))
- } finally {
- setLoading(false)
}
- }
+ }, [showMessage, t, token])
useEffect(() => {
if (token) {
fetchConfig()
}
- }, [token])
-
- const showMessage = (type, text) => {
- setMessage({ type, text })
- setTimeout(() => setMessage(null), 5000)
- }
+ }, [fetchConfig, token])
const handleLogin = (newToken) => {
setToken(newToken)
}
- const handleLogout = () => {
- setToken(null)
- localStorage.removeItem('ds2api_token')
- localStorage.removeItem('ds2api_token_expires')
- sessionStorage.removeItem('ds2api_token')
- sessionStorage.removeItem('ds2api_token_expires')
- }
-
// Wait for auth checks on admin routes.
if (isAdminRoute && authChecking) {
return (
@@ -320,6 +318,7 @@ export default function App() {
showMessage={showMessage}
message={message}
onForceLogout={handleLogout}
+ isVercel={isVercel}
/>
) : (
diff --git a/webui/src/components/Settings.jsx b/webui/src/components/Settings.jsx
index b257ed5..927804e 100644
--- a/webui/src/components/Settings.jsx
+++ b/webui/src/components/Settings.jsx
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertTriangle, Download, Lock, Save, Upload } from 'lucide-react'
import { useI18n } from '../i18n'
-export default function Settings({ onRefresh, onMessage, authFetch, onForceLogout }) {
+const MAX_AUTO_FETCH_FAILURES = 3
+
+export default function Settings({ onRefresh, onMessage, authFetch, onForceLogout, isVercel = false }) {
const { t } = useI18n()
const apiFetch = authFetch || fetch
@@ -14,6 +16,9 @@ export default function Settings({ onRefresh, onMessage, authFetch, onForceLogou
const [importMode, setImportMode] = useState('merge')
const [importText, setImportText] = useState('')
const [newPassword, setNewPassword] = useState('')
+ const [consecutiveFailures, setConsecutiveFailures] = useState(0)
+ const [autoFetchPaused, setAutoFetchPaused] = useState(false)
+ const [lastError, setLastError] = useState('')
const [settingsMeta, setSettingsMeta] = useState({ default_password_warning: false, env_backed: false, needs_vercel_sync: false })
const [form, setForm] = useState({
@@ -43,15 +48,38 @@ export default function Settings({ onRefresh, onMessage, authFetch, onForceLogou
return parsed
}
- const loadSettings = useCallback(async () => {
+ const parseJSONResponse = useCallback(async (res) => {
+ const contentType = String(res.headers.get('content-type') || '').toLowerCase()
+ if (!contentType.includes('application/json')) {
+ throw new Error(t('settings.nonJsonResponse', { status: res.status }))
+ }
+ return res.json()
+ }, [t])
+
+ const loadSettings = useCallback(async ({ manual = false } = {}) => {
+ if (isVercel && autoFetchPaused && !manual) {
+ return
+ }
setLoading(true)
try {
const res = await apiFetch('/admin/settings')
- const data = await res.json()
+ const data = await parseJSONResponse(res)
if (!res.ok) {
- onMessage('error', data.detail || t('settings.loadFailed'))
+ const detail = data.detail || t('settings.loadFailed')
+ setLastError(detail)
+ onMessage('error', detail)
+ setConsecutiveFailures((prev) => {
+ const next = prev + 1
+ if (isVercel && next >= MAX_AUTO_FETCH_FAILURES) {
+ setAutoFetchPaused(true)
+ }
+ return next
+ })
return
}
+ setConsecutiveFailures(0)
+ setAutoFetchPaused(false)
+ setLastError('')
setSettingsMeta({
default_password_warning: Boolean(data.admin?.default_password_warning),
env_backed: Boolean(data.env_backed),
@@ -78,18 +106,32 @@ export default function Settings({ onRefresh, onMessage, authFetch, onForceLogou
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
})
} catch (e) {
- onMessage('error', t('settings.loadFailed'))
+ const detail = e?.message || t('settings.loadFailed')
+ setLastError(detail)
+ onMessage('error', detail)
+ setConsecutiveFailures((prev) => {
+ const next = prev + 1
+ if (isVercel && next >= MAX_AUTO_FETCH_FAILURES) {
+ setAutoFetchPaused(true)
+ }
+ return next
+ })
// eslint-disable-next-line no-console
console.error(e)
} finally {
setLoading(false)
}
- }, [apiFetch, onMessage, t])
+ }, [apiFetch, autoFetchPaused, isVercel, onMessage, parseJSONResponse, t])
useEffect(() => {
loadSettings()
}, [loadSettings])
+ const retryLoadSettings = () => {
+ setAutoFetchPaused(false)
+ loadSettings({ manual: true })
+ }
+
const saveSettings = async () => {
let claudeMapping = {}
let modelAliases = {}
@@ -228,6 +270,23 @@ export default function Settings({ onRefresh, onMessage, authFetch, onForceLogou
return (
+ {autoFetchPaused && (
+
+
+
+
+ {t('settings.autoFetchPaused', { count: consecutiveFailures, error: lastError || t('settings.loadFailed') })}
+
+
+
+
+ )}
{settingsMeta.default_password_warning && (
diff --git a/webui/src/components/VercelSync.jsx b/webui/src/components/VercelSync.jsx
index 2f8a548..a50714e 100644
--- a/webui/src/components/VercelSync.jsx
+++ b/webui/src/components/VercelSync.jsx
@@ -3,7 +3,15 @@ import { Cloud, ArrowRight, ExternalLink, Info, CheckCircle2, XCircle, RefreshCw
import clsx from 'clsx'
import { useI18n } from '../i18n'
-export default function VercelSync({ onMessage, authFetch }) {
+const MAX_POLL_FAILURES = 3
+
+function pollDelayMs(attempt) {
+ if (attempt <= 0) return 15000
+ if (attempt === 1) return 30000
+ return 60000
+}
+
+export default function VercelSync({ onMessage, authFetch, isVercel = false }) {
const { t } = useI18n()
const [vercelToken, setVercelToken] = useState('')
const [projectId, setProjectId] = useState('')
@@ -12,20 +20,42 @@ export default function VercelSync({ onMessage, authFetch }) {
const [result, setResult] = useState(null)
const [preconfig, setPreconfig] = useState(null)
const [syncStatus, setSyncStatus] = useState(null)
+ const [pollPaused, setPollPaused] = useState(false)
+ const [pollFailures, setPollFailures] = useState(0)
+ const [nextRetryAt, setNextRetryAt] = useState(null)
const apiFetch = authFetch || fetch
- const fetchSyncStatus = useCallback(async () => {
+ const fetchSyncStatus = useCallback(async ({ manual = false } = {}) => {
try {
const res = await apiFetch('/admin/vercel/status')
- if (res.ok) {
- const data = await res.json()
- setSyncStatus(data)
+ if (!res.ok) {
+ throw new Error(`status ${res.status}`)
}
+ const data = await res.json()
+ setSyncStatus(data)
+ setPollFailures(0)
+ setPollPaused(false)
+ setNextRetryAt(null)
} catch (e) {
+ setPollFailures((prev) => {
+ const next = prev + 1
+ if (isVercel) {
+ if (next >= MAX_POLL_FAILURES) {
+ setPollPaused(true)
+ setNextRetryAt(null)
+ } else {
+ setNextRetryAt(Date.now() + pollDelayMs(next))
+ }
+ }
+ return next
+ })
+ if (manual) {
+ onMessage('error', t('vercel.networkError'))
+ }
console.error('Failed to fetch sync status:', e)
}
- }, [apiFetch])
+ }, [apiFetch, isVercel, onMessage, t])
useEffect(() => {
const loadPreconfig = async () => {
@@ -43,11 +73,32 @@ export default function VercelSync({ onMessage, authFetch }) {
}
loadPreconfig()
fetchSyncStatus()
- // Poll every 15s to detect config changes
- const interval = setInterval(fetchSyncStatus, 15000)
- return () => clearInterval(interval)
}, [fetchSyncStatus])
+ useEffect(() => {
+ if (!isVercel) {
+ const interval = setInterval(() => {
+ fetchSyncStatus()
+ }, 15000)
+ return () => clearInterval(interval)
+ }
+ if (pollPaused) {
+ return undefined
+ }
+ const delay = nextRetryAt && nextRetryAt > Date.now() ? nextRetryAt - Date.now() : pollDelayMs(pollFailures)
+ const timer = setTimeout(() => {
+ fetchSyncStatus()
+ }, Math.max(1000, delay))
+ return () => clearTimeout(timer)
+ }, [fetchSyncStatus, isVercel, nextRetryAt, pollFailures, pollPaused])
+
+ const handleManualRefresh = () => {
+ setPollPaused(false)
+ setPollFailures(0)
+ setNextRetryAt(null)
+ fetchSyncStatus({ manual: true })
+ }
+
const handleSync = async () => {
const tokenToUse = preconfig?.has_token && !vercelToken ? '__USE_PRECONFIG__' : vercelToken
@@ -122,6 +173,20 @@ export default function VercelSync({ onMessage, authFetch }) {
{t('vercel.description')}
+ {pollPaused && (
+
+
+ {t('vercel.pollPaused', { count: pollFailures })}
+
+
+
+ )}
{syncStatus?.last_sync_time && (
diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json
index d5be108..c06a86d 100644
--- a/webui/src/locales/en.json
+++ b/webui/src/locales/en.json
@@ -198,6 +198,7 @@
},
"settings": {
"loadFailed": "Failed to load settings.",
+ "nonJsonResponse": "Unexpected non-JSON response from server (status: {status}).",
"save": "Save settings",
"saving": "Saving...",
"saveSuccess": "Settings saved and hot reloaded.",
@@ -239,7 +240,9 @@
"exportJson": "Export JSON",
"invalidJsonField": "{field} is not a valid JSON object.",
"defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.",
- "vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy."
+ "vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy.",
+ "autoFetchPaused": "Auto loading paused after {count} failures: {error}",
+ "retryLoad": "Retry now"
},
"login": {
"welcome": "Welcome back",
@@ -278,6 +281,8 @@
"statusNotSynced": "Not synced",
"statusNeverSynced": "Never synced",
"lastSyncTime": "Last sync: {time}",
+ "pollPaused": "Status polling paused after {count} failures.",
+ "manualRefresh": "Refresh manually",
"howItWorks": "How it works",
"steps": {
"one": "The current configuration (keys and accounts) is exported as JSON.",
diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json
index fb31188..7d587d7 100644
--- a/webui/src/locales/zh.json
+++ b/webui/src/locales/zh.json
@@ -198,6 +198,7 @@
},
"settings": {
"loadFailed": "加载设置失败",
+ "nonJsonResponse": "服务端返回了非 JSON 响应(状态码:{status})",
"save": "保存设置",
"saving": "保存中...",
"saveSuccess": "设置已保存并热更新生效",
@@ -239,7 +240,9 @@
"exportJson": "导出 JSON",
"invalidJsonField": "{field} 不是有效 JSON 对象",
"defaultPasswordWarning": "当前使用默认密码 admin,请尽快在此修改。",
- "vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。"
+ "vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。",
+ "autoFetchPaused": "自动加载已暂停:连续失败 {count} 次({error})",
+ "retryLoad": "立即重试"
},
"login": {
"welcome": "欢迎回来",
@@ -278,6 +281,8 @@
"statusNotSynced": "未同步",
"statusNeverSynced": "从未同步",
"lastSyncTime": "上次同步: {time}",
+ "pollPaused": "状态轮询已暂停:连续失败 {count} 次。",
+ "manualRefresh": "手动刷新",
"howItWorks": "工作原理",
"steps": {
"one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。",
diff --git a/webui/src/utils/runtimeEnv.js b/webui/src/utils/runtimeEnv.js
new file mode 100644
index 0000000..d7b7889
--- /dev/null
+++ b/webui/src/utils/runtimeEnv.js
@@ -0,0 +1,13 @@
+export function detectRuntimeEnv() {
+ const deployTarget = String(import.meta.env.VITE_DEPLOY_TARGET || '').trim().toLowerCase()
+ if (deployTarget === 'vercel') {
+ return { isVercel: true, source: 'vite_env' }
+ }
+
+ const host = typeof window !== 'undefined' ? String(window.location.hostname || '').toLowerCase() : ''
+ if (host.includes('vercel.app')) {
+ return { isVercel: true, source: 'hostname' }
+ }
+
+ return { isVercel: false, source: 'default' }
+}