mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 08:25:26 +08:00
feat: Implement Vercel environment detection and pause settings auto-fetch after consecutive failures to prevent excessive API calls.
This commit is contained in:
12
vercel.json
12
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"
|
||||
|
||||
@@ -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 <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
|
||||
case 'vercel':
|
||||
return <VercelSync onMessage={showMessage} authFetch={authFetch} />
|
||||
return <VercelSync onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} />
|
||||
case 'settings':
|
||||
return <Settings onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} />
|
||||
return <Settings onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} isVercel={isVercel} />
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-screen flex flex-col bg-background relative overflow-hidden">
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
{autoFetchPaused && (
|
||||
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/10 text-destructive flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span className="text-sm">
|
||||
{t('settings.autoFetchPaused', { count: consecutiveFailures, error: lastError || t('settings.loadFailed') })}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retryLoadSettings}
|
||||
className="px-3 py-1.5 text-xs rounded-md border border-destructive/40 hover:bg-destructive/10"
|
||||
>
|
||||
{t('settings.retryLoad')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{settingsMeta.default_password_warning && (
|
||||
<div className="p-4 rounded-lg border border-amber-300/30 bg-amber-500/10 text-amber-700 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
|
||||
@@ -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 }) {
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{t('vercel.description')}
|
||||
</p>
|
||||
{pollPaused && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs text-destructive">
|
||||
{t('vercel.pollPaused', { count: pollFailures })}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleManualRefresh}
|
||||
className="px-2 py-1 text-xs rounded border border-border hover:bg-secondary/50"
|
||||
>
|
||||
{t('vercel.manualRefresh')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{syncStatus?.last_sync_time && (
|
||||
<p className="text-xs text-muted-foreground/60 mt-1.5 flex items-center gap-1">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 字符串。",
|
||||
|
||||
13
webui/src/utils/runtimeEnv.js
Normal file
13
webui/src/utils/runtimeEnv.js
Normal file
@@ -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' }
|
||||
}
|
||||
Reference in New Issue
Block a user