Merge pull request #244 from icesixgod:feature/proxy-health-check

feat: 实现代理健康检查功能
This commit is contained in:
snaily
2025-07-25 12:16:08 +08:00
committed by GitHub
5 changed files with 713 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
from app.core.security import verify_auth_token
from app.log.logger import Logger, get_config_routes_logger
from app.service.config.config_service import ConfigService
from app.service.proxy.proxy_check_service import get_proxy_check_service, ProxyCheckResult
from app.utils.helpers import redact_key_for_logging
router = APIRouter(prefix="/api/config", tags=["config"])
@@ -132,3 +133,93 @@ async def get_ui_models(request: Request):
status_code=500,
detail=f"An unexpected error occurred while fetching UI models: {str(e)}",
)
class ProxyCheckRequest(BaseModel):
"""Proxy check request"""
proxy: str = Field(..., description="Proxy address to check")
use_cache: bool = Field(True, description="Whether to use cached results")
class ProxyBatchCheckRequest(BaseModel):
"""Batch proxy check request"""
proxies: List[str] = Field(..., description="List of proxy addresses to check")
use_cache: bool = Field(True, description="Whether to use cached results")
max_concurrent: int = Field(5, description="Maximum concurrent check count", ge=1, le=10)
@router.post("/proxy/check", response_model=ProxyCheckResult)
async def check_single_proxy(proxy_request: ProxyCheckRequest, request: Request):
"""Check if a single proxy is available"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to proxy check")
return RedirectResponse(url="/", status_code=302)
try:
logger.info(f"Checking single proxy: {proxy_request.proxy}")
proxy_service = get_proxy_check_service()
result = await proxy_service.check_single_proxy(
proxy_request.proxy,
proxy_request.use_cache
)
return result
except Exception as e:
logger.error(f"Proxy check failed: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Proxy check failed: {str(e)}")
@router.post("/proxy/check-all", response_model=List[ProxyCheckResult])
async def check_all_proxies(batch_request: ProxyBatchCheckRequest, request: Request):
"""Check multiple proxies availability"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to batch proxy check")
return RedirectResponse(url="/", status_code=302)
try:
logger.info(f"Batch checking {len(batch_request.proxies)} proxies")
proxy_service = get_proxy_check_service()
results = await proxy_service.check_multiple_proxies(
batch_request.proxies,
batch_request.use_cache,
batch_request.max_concurrent
)
return results
except Exception as e:
logger.error(f"Batch proxy check failed: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Batch proxy check failed: {str(e)}")
@router.get("/proxy/cache-stats")
async def get_proxy_cache_stats(request: Request):
"""Get proxy check cache statistics"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to proxy cache stats")
return RedirectResponse(url="/", status_code=302)
try:
proxy_service = get_proxy_check_service()
stats = proxy_service.get_cache_stats()
return stats
except Exception as e:
logger.error(f"Get proxy cache stats failed: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Get cache stats failed: {str(e)}")
@router.post("/proxy/clear-cache")
async def clear_proxy_cache(request: Request):
"""Clear proxy check cache"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to clear proxy cache")
return RedirectResponse(url="/", status_code=302)
try:
proxy_service = get_proxy_check_service()
proxy_service.clear_cache()
return {"success": True, "message": "Proxy check cache cleared"}
except Exception as e:
logger.error(f"Clear proxy cache failed: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Clear cache failed: {str(e)}")

View File

@@ -0,0 +1,7 @@
"""
Proxy service module
"""
from .proxy_check_service import ProxyCheckService
__all__ = ["ProxyCheckService"]

View File

@@ -0,0 +1,219 @@
"""
Proxy detection service module
"""
import asyncio
import time
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse
import httpx
from pydantic import BaseModel
from app.log.logger import get_config_routes_logger
logger = get_config_routes_logger()
class ProxyCheckResult(BaseModel):
"""Proxy check result model"""
proxy: str
is_available: bool
response_time: Optional[float] = None
error_message: Optional[str] = None
checked_at: float
class ProxyCheckService:
"""Proxy detection service class"""
# Target URL for checking
CHECK_URL = "https://www.google.com"
# Timeout in seconds
TIMEOUT_SECONDS = 10
# Cache duration in seconds
CACHE_DURATION = 10 # 10s
def __init__(self):
self._cache: Dict[str, ProxyCheckResult] = {}
def _is_valid_proxy_format(self, proxy: str) -> bool:
"""Validate proxy format"""
try:
parsed = urlparse(proxy)
return parsed.scheme in ['http', 'https', 'socks5'] and parsed.hostname
except Exception:
return False
def _get_cached_result(self, proxy: str) -> Optional[ProxyCheckResult]:
"""Get cached check result"""
if proxy in self._cache:
result = self._cache[proxy]
# Check if cache is expired
if time.time() - result.checked_at < self.CACHE_DURATION:
logger.debug(f"Using cached proxy check result: {proxy}")
return result
else:
# Remove expired cache
del self._cache[proxy]
return None
def _cache_result(self, result: ProxyCheckResult) -> None:
"""Cache check result"""
self._cache[result.proxy] = result
async def check_single_proxy(self, proxy: str, use_cache: bool = True) -> ProxyCheckResult:
"""
Check if a single proxy is available
Args:
proxy: Proxy address in format like http://host:port or socks5://host:port
use_cache: Whether to use cached results
Returns:
ProxyCheckResult: Check result
"""
# Check cache first
if use_cache:
cached = self._get_cached_result(proxy)
if cached:
return cached
# Validate proxy format
if not self._is_valid_proxy_format(proxy):
result = ProxyCheckResult(
proxy=proxy,
is_available=False,
error_message="Invalid proxy format",
checked_at=time.time()
)
self._cache_result(result)
return result
# Perform check
start_time = time.time()
try:
logger.info(f"Starting proxy check: {proxy}")
timeout = httpx.Timeout(self.TIMEOUT_SECONDS, read=self.TIMEOUT_SECONDS)
async with httpx.AsyncClient(timeout=timeout, proxy=proxy) as client:
response = await client.head(self.CHECK_URL)
response_time = time.time() - start_time
# Check response status
is_available = response.status_code in [200, 204, 301, 302, 307, 308]
result = ProxyCheckResult(
proxy=proxy,
is_available=is_available,
response_time=round(response_time, 3),
error_message=None if is_available else f"HTTP {response.status_code}",
checked_at=time.time()
)
logger.info(f"Proxy check completed: {proxy}, available: {is_available}, response_time: {response_time:.3f}s")
except asyncio.TimeoutError:
result = ProxyCheckResult(
proxy=proxy,
is_available=False,
error_message="Connection timeout",
checked_at=time.time()
)
logger.warning(f"Proxy check timeout: {proxy}")
except Exception as e:
result = ProxyCheckResult(
proxy=proxy,
is_available=False,
error_message=str(e),
checked_at=time.time()
)
logger.error(f"Proxy check failed: {proxy}, error: {str(e)}")
# Cache result
self._cache_result(result)
return result
async def check_multiple_proxies(
self,
proxies: List[str],
use_cache: bool = True,
max_concurrent: int = 5
) -> List[ProxyCheckResult]:
"""
Check multiple proxies concurrently
Args:
proxies: List of proxy addresses
use_cache: Whether to use cached results
max_concurrent: Maximum concurrent check count
Returns:
List[ProxyCheckResult]: List of check results
"""
if not proxies:
return []
logger.info(f"Starting batch proxy check for {len(proxies)} proxies")
# Use semaphore to limit concurrency
semaphore = asyncio.Semaphore(max_concurrent)
async def check_with_semaphore(proxy: str) -> ProxyCheckResult:
async with semaphore:
return await self.check_single_proxy(proxy, use_cache)
# Execute checks concurrently
tasks = [check_with_semaphore(proxy) for proxy in proxies]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Handle exception results
final_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.error(f"Proxy check task exception: {proxies[i]}, error: {str(result)}")
final_results.append(ProxyCheckResult(
proxy=proxies[i],
is_available=False,
error_message=f"Check task exception: {str(result)}",
checked_at=time.time()
))
else:
final_results.append(result)
available_count = sum(1 for r in final_results if r.is_available)
logger.info(f"Batch proxy check completed: {available_count}/{len(proxies)} proxies available")
return final_results
def get_cache_stats(self) -> Dict[str, int]:
"""Get cache statistics"""
current_time = time.time()
valid_cache_count = sum(
1 for result in self._cache.values()
if current_time - result.checked_at < self.CACHE_DURATION
)
return {
"total_cached": len(self._cache),
"valid_cached": valid_cache_count,
"expired_cached": len(self._cache) - valid_cache_count
}
def clear_cache(self) -> None:
"""Clear all cache"""
self._cache.clear()
logger.info("Proxy check cache cleared")
# Global instance
_proxy_check_service: Optional[ProxyCheckService] = None
def get_proxy_check_service() -> ProxyCheckService:
"""Get proxy check service instance"""
global _proxy_check_service
if _proxy_check_service is None:
_proxy_check_service = ProxyCheckService()
return _proxy_check_service

View File

@@ -184,6 +184,13 @@ document.addEventListener("DOMContentLoaded", function () {
const closeProxyModalBtn = document.getElementById("closeProxyModalBtn");
const cancelAddProxyBtn = document.getElementById("cancelAddProxyBtn");
const confirmAddProxyBtn = document.getElementById("confirmAddProxyBtn");
// Proxy Check Elements and Events
const checkAllProxiesBtn = document.getElementById("checkAllProxiesBtn");
const proxyCheckModal = document.getElementById("proxyCheckModal");
const closeProxyCheckModalBtn = document.getElementById("closeProxyCheckModalBtn");
const closeProxyCheckBtn = document.getElementById("closeProxyCheckBtn");
const retryFailedProxiesBtn = document.getElementById("retryFailedProxiesBtn");
if (addProxyBtn) {
addProxyBtn.addEventListener("click", () => {
@@ -191,6 +198,25 @@ document.addEventListener("DOMContentLoaded", function () {
if (proxyBulkInput) proxyBulkInput.value = "";
});
}
if (checkAllProxiesBtn) {
checkAllProxiesBtn.addEventListener("click", checkAllProxies);
}
if (closeProxyCheckModalBtn) {
closeProxyCheckModalBtn.addEventListener("click", () => closeModal(proxyCheckModal));
}
if (closeProxyCheckBtn) {
closeProxyCheckBtn.addEventListener("click", () => closeModal(proxyCheckModal));
}
if (retryFailedProxiesBtn) {
retryFailedProxiesBtn.addEventListener("click", () => {
// 重试失败的代理检测
checkAllProxies();
});
}
if (closeProxyModalBtn)
closeProxyModalBtn.addEventListener("click", () => closeModal(proxyModal));
if (cancelAddProxyBtn)
@@ -1455,6 +1481,45 @@ function createRemoveButton() {
return removeBtn;
}
/**
* Creates a proxy status icon for displaying proxy check status.
* @returns {HTMLSpanElement} The status icon element.
*/
function createProxyStatusIcon() {
const statusIcon = document.createElement("span");
statusIcon.className = "proxy-status-icon px-2 py-2 text-gray-400";
statusIcon.innerHTML = '<i class="fas fa-question-circle" title="未检测"></i>';
statusIcon.setAttribute("data-status", "unknown");
return statusIcon;
}
/**
* Creates a proxy check button for individual proxy checking.
* @returns {HTMLButtonElement} The check button element.
*/
function createProxyCheckButton() {
const checkBtn = document.createElement("button");
checkBtn.type = "button";
checkBtn.className =
"proxy-check-btn px-2 py-2 text-blue-500 hover:text-blue-700 focus:outline-none transition-colors duration-150 rounded-r-md";
checkBtn.innerHTML = '<i class="fas fa-globe"></i>';
checkBtn.title = "检测此代理";
// 添加点击事件监听器
checkBtn.addEventListener("click", function(e) {
e.preventDefault();
e.stopPropagation();
const inputElement = this.closest('.flex').querySelector('.array-input');
if (inputElement && inputElement.value.trim()) {
checkSingleProxy(inputElement.value.trim(), this);
} else {
showNotification("请先输入代理地址", "warning");
}
});
return checkBtn;
}
/**
* Adds a new item to an array configuration section (e.g., API_KEYS, ALLOWED_TOKENS).
* This function is typically called by a "+" button.
@@ -1486,6 +1551,7 @@ function addArrayItemWithValue(key, value) {
const isThinkingModel = key === "THINKING_MODELS";
const isAllowedToken = key === "ALLOWED_TOKENS";
const isVertexApiKey = key === "VERTEX_API_KEYS"; // 新增判断
const isProxy = key === "PROXIES"; // 新增代理判断
const isSensitive = key === "API_KEYS" || isAllowedToken || isVertexApiKey; // 更新敏感判断
const modelId = isThinkingModel ? generateUUID() : null;
@@ -1513,6 +1579,13 @@ function addArrayItemWithValue(key, value) {
if (isAllowedToken) {
const generateBtn = createGenerateTokenButton();
inputWrapper.appendChild(generateBtn);
} else if (isProxy) {
// 为代理添加状态显示和检测按钮
const proxyStatusIcon = createProxyStatusIcon();
inputWrapper.appendChild(proxyStatusIcon);
const proxyCheckBtn = createProxyCheckButton();
inputWrapper.appendChild(proxyCheckBtn);
} else {
// Ensure right-side rounding if no button is present
input.classList.add("rounded-r-md");
@@ -2299,3 +2372,241 @@ function handleModelSelection(selectedModelId) {
}
// -- End Model Helper Functions --
// -- Proxy Check Functions --
/**
* 检测单个代理是否可用
* @param {string} proxy - 代理地址
* @param {HTMLElement} buttonElement - 触发检测的按钮元素
*/
async function checkSingleProxy(proxy, buttonElement) {
const statusIcon = buttonElement.parentElement.querySelector('.proxy-status-icon');
const originalButtonContent = buttonElement.innerHTML;
try {
// 更新UI状态为检测中
buttonElement.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
buttonElement.disabled = true;
if (statusIcon) {
statusIcon.className = "proxy-status-icon px-2 py-2 text-blue-500";
statusIcon.innerHTML = '<i class="fas fa-spinner fa-spin" title="检测中..."></i>';
statusIcon.setAttribute("data-status", "checking");
}
const response = await fetch('/api/config/proxy/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
proxy: proxy,
use_cache: true
})
});
if (!response.ok) {
throw new Error(`检测请求失败: ${response.status}`);
}
const result = await response.json();
updateProxyStatus(statusIcon, result);
// 显示检测结果通知
if (result.is_available) {
showNotification(`代理可用 (${result.response_time}s)`, "success");
} else {
showNotification(`代理不可用: ${result.error_message}`, "error");
}
} catch (error) {
console.error('代理检测失败:', error);
if (statusIcon) {
statusIcon.className = "proxy-status-icon px-2 py-2 text-red-500";
statusIcon.innerHTML = '<i class="fas fa-times-circle" title="检测失败"></i>';
statusIcon.setAttribute("data-status", "error");
}
showNotification(`检测失败: ${error.message}`, "error");
} finally {
// 恢复按钮状态
buttonElement.innerHTML = originalButtonContent;
buttonElement.disabled = false;
}
}
/**
* 更新代理状态图标
* @param {HTMLElement} statusIcon - 状态图标元素
* @param {Object} result - 检测结果
*/
function updateProxyStatus(statusIcon, result) {
if (!statusIcon) return;
if (result.is_available) {
statusIcon.className = "proxy-status-icon px-2 py-2 text-green-500";
statusIcon.innerHTML = `<i class="fas fa-check-circle" title="可用 (${result.response_time}s)"></i>`;
statusIcon.setAttribute("data-status", "available");
} else {
statusIcon.className = "proxy-status-icon px-2 py-2 text-red-500";
statusIcon.innerHTML = `<i class="fas fa-times-circle" title="不可用: ${result.error_message}"></i>`;
statusIcon.setAttribute("data-status", "unavailable");
}
}
/**
* 检测所有代理
*/
async function checkAllProxies() {
const proxyContainer = document.getElementById("PROXIES_container");
if (!proxyContainer) return;
const proxyInputs = proxyContainer.querySelectorAll('.array-input');
const proxies = Array.from(proxyInputs)
.map(input => input.value.trim())
.filter(proxy => proxy.length > 0);
if (proxies.length === 0) {
showNotification("没有代理需要检测", "warning");
return;
}
// 打开检测结果模态框
const proxyCheckModal = document.getElementById("proxyCheckModal");
if (proxyCheckModal) {
openModal(proxyCheckModal);
// 显示进度
const progressContainer = document.getElementById("proxyCheckProgress");
const summaryContainer = document.getElementById("proxyCheckSummary");
const resultsContainer = document.getElementById("proxyCheckResults");
if (progressContainer) progressContainer.classList.remove("hidden");
if (summaryContainer) summaryContainer.classList.add("hidden");
if (resultsContainer) resultsContainer.innerHTML = "";
// 更新总数
const totalCountElement = document.getElementById("totalCount");
if (totalCountElement) totalCountElement.textContent = proxies.length;
try {
const response = await fetch('/api/config/proxy/check-all', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
proxies: proxies,
use_cache: true,
max_concurrent: 5
})
});
if (!response.ok) {
throw new Error(`批量检测请求失败: ${response.status}`);
}
const results = await response.json();
displayProxyCheckResults(results);
updateProxyStatusInList(results);
} catch (error) {
console.error('批量代理检测失败:', error);
showNotification(`批量检测失败: ${error.message}`, "error");
if (resultsContainer) {
resultsContainer.innerHTML = `<div class="text-red-500 text-center py-4">检测失败: ${error.message}</div>`;
}
} finally {
// 隐藏进度
if (progressContainer) progressContainer.classList.add("hidden");
}
}
}
/**
* 显示代理检测结果
* @param {Array} results - 检测结果数组
*/
function displayProxyCheckResults(results) {
const summaryContainer = document.getElementById("proxyCheckSummary");
const resultsContainer = document.getElementById("proxyCheckResults");
const availableCountElement = document.getElementById("availableCount");
const unavailableCountElement = document.getElementById("unavailableCount");
const retryButton = document.getElementById("retryFailedProxiesBtn");
if (!resultsContainer) return;
// 统计结果
const availableCount = results.filter(r => r.is_available).length;
const unavailableCount = results.length - availableCount;
// 更新概览
if (availableCountElement) availableCountElement.textContent = availableCount;
if (unavailableCountElement) unavailableCountElement.textContent = unavailableCount;
if (summaryContainer) summaryContainer.classList.remove("hidden");
// 显示重试按钮(如果有失败的代理)
if (retryButton) {
if (unavailableCount > 0) {
retryButton.classList.remove("hidden");
} else {
retryButton.classList.add("hidden");
}
}
// 清空并填充结果
resultsContainer.innerHTML = "";
results.forEach(result => {
const resultItem = document.createElement("div");
resultItem.className = `flex items-center justify-between p-3 border rounded-lg ${
result.is_available ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'
}`;
const statusIcon = result.is_available ?
'<i class="fas fa-check-circle text-green-500"></i>' :
'<i class="fas fa-times-circle text-red-500"></i>';
const responseTimeText = result.response_time ?
` (${result.response_time}s)` : '';
const errorText = result.error_message ?
`<span class="text-red-600 text-sm ml-2">${result.error_message}</span>` : '';
resultItem.innerHTML = `
<div class="flex items-center gap-3">
${statusIcon}
<span class="font-mono text-sm">${result.proxy}</span>
${responseTimeText}
</div>
<div class="flex items-center">
<span class="text-sm ${result.is_available ? 'text-green-700' : 'text-red-700'}">
${result.is_available ? '可用' : '不可用'}
</span>
${errorText}
</div>
`;
resultsContainer.appendChild(resultItem);
});
}
/**
* 根据检测结果更新代理列表中的状态图标
* @param {Array} results - 检测结果数组
*/
function updateProxyStatusInList(results) {
const proxyContainer = document.getElementById("PROXIES_container");
if (!proxyContainer) return;
results.forEach(result => {
const proxyInputs = proxyContainer.querySelectorAll('.array-input');
proxyInputs.forEach(input => {
if (input.value.trim() === result.proxy) {
const statusIcon = input.parentElement.querySelector('.proxy-status-icon');
updateProxyStatus(statusIcon, result);
}
});
});
}
// -- End Proxy Check Functions --

View File

@@ -1232,6 +1232,13 @@ endblock %} {% block head_extra_styles %}
>
<i class="fas fa-trash-alt"></i> 删除代理
</button>
<button
type="button"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2"
id="checkAllProxiesBtn"
>
<i class="fas fa-globe"></i> 检测所有代理
</button>
<button
type="button"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2"
@@ -2614,6 +2621,84 @@ endblock %} {% block head_extra_styles %}
</div>
</div>
<!-- Proxy Check Results Modal -->
<div id="proxyCheckModal" class="modal">
<div
class="w-full max-w-4xl mx-auto rounded-2xl shadow-2xl overflow-hidden animate-fade-in"
style="
background-color: rgba(255, 255, 255, 0.98);
color: #374151;
border: 1px solid rgba(0, 0, 0, 0.12);
"
>
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800">代理检测结果</h2>
<button
id="closeProxyCheckModalBtn"
class="text-gray-300 hover:text-gray-800 text-xl"
>
&times;
</button>
</div>
<!-- 检测状态和进度 -->
<div id="proxyCheckProgress" class="mb-4 hidden">
<div class="flex items-center gap-2 mb-2">
<div class="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<span class="text-sm text-gray-600">检测中...</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div id="progressBar" class="bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div class="text-xs text-gray-500 mt-1">
<span id="progressText">准备开始检测...</span>
</div>
</div>
<!-- 检测结果概览 -->
<div id="proxyCheckSummary" class="mb-4 hidden">
<div class="grid grid-cols-3 gap-4 text-center">
<div class="bg-green-50 border border-green-200 rounded-lg p-3">
<div class="text-2xl font-bold text-green-600" id="availableCount">0</div>
<div class="text-sm text-green-700">可用</div>
</div>
<div class="bg-red-50 border border-red-200 rounded-lg p-3">
<div class="text-2xl font-bold text-red-600" id="unavailableCount">0</div>
<div class="text-sm text-red-700">不可用</div>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="text-2xl font-bold text-blue-600" id="totalCount">0</div>
<div class="text-sm text-blue-700">总数</div>
</div>
</div>
</div>
<!-- 检测结果列表 -->
<div id="proxyCheckResults" class="space-y-2" style="max-height: 400px; overflow-y: auto;">
<!-- 检测结果将在这里动态添加 -->
</div>
<div class="flex justify-end gap-3 mt-6">
<button
type="button"
id="retryFailedProxiesBtn"
class="bg-orange-600 hover:bg-orange-700 text-white px-6 py-2 rounded-lg font-medium transition hidden"
>
重试失败的代理
</button>
<button
type="button"
id="closeProxyCheckBtn"
class="bg-gray-500 bg-opacity-50 hover:bg-opacity-70 text-gray-200 px-6 py-2 rounded-lg font-medium transition"
>
关闭
</button>
</div>
</div>
</div>
</div>
<!-- Model Helper Modal -->
<div id="modelHelperModal" class="modal">
<div