feat: Implement Vercel environment detection and pause settings auto-fetch after consecutive failures to prevent excessive API calls.

This commit is contained in:
CJACK
2026-02-20 03:22:27 +08:00
parent 1d2a6bf281
commit 2781951ce7
7 changed files with 203 additions and 45 deletions

View File

@@ -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" />

View File

@@ -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" />