From 2270f6d998eb6459a733a22e7a9428794c884ff4 Mon Sep 17 00:00:00 2001 From: snaily Date: Thu, 24 Jul 2025 23:23:11 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E7=8A=B6=E6=80=81=E9=A1=B5=E9=9D=A2=E4=B8=BA=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E5=8A=A8=E6=80=81=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 key_routes.py 分离密钥相关路由逻辑 - 将密钥列表从服务器端渲染改为 JavaScript 动态加载 - 优化 keys_status 页面错误处理,提供默认数据结构 - 在 KeyManager 中添加 get_all_keys_with_fail_count 方法 - 移除服务器端模板中的静态密钥渲染代码 这次重构提升了页面加载性能和用户体验,同时改善了错误处理机制。 --- app/router/key_routes.py | 63 ++++++ app/router/routes.py | 27 ++- app/service/key/key_manager.py | 12 ++ app/static/js/keys_status.js | 349 +++++++++++++++++++-------------- app/templates/keys_status.html | 176 +---------------- 5 files changed, 301 insertions(+), 326 deletions(-) create mode 100644 app/router/key_routes.py diff --git a/app/router/key_routes.py b/app/router/key_routes.py new file mode 100644 index 0000000..98993e0 --- /dev/null +++ b/app/router/key_routes.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Depends, Request +from app.service.key.key_manager import KeyManager, get_key_manager_instance +from app.core.security import verify_auth_token +from fastapi.responses import JSONResponse + +router = APIRouter() + +@router.get("/api/keys") +async def get_keys_paginated( + request: Request, + page: int = 1, + limit: int = 10, + search: str = None, + fail_count_threshold: int = None, + status: str = "all", # 'valid', 'invalid', 'all' + key_manager: KeyManager = Depends(get_key_manager_instance), +): + """ + Get paginated, filtered, and searched keys. + """ + auth_token = request.cookies.get("auth_token") + if not auth_token or not verify_auth_token(auth_token): + return JSONResponse(status_code=401, content={"detail": "Unauthorized"}) + + all_keys_with_status = await key_manager.get_all_keys_with_fail_count() + + # Filter by status + if status == "valid": + keys_to_filter = all_keys_with_status["valid_keys"] + elif status == "invalid": + keys_to_filter = all_keys_with_status["invalid_keys"] + else: + # Combine both for 'all' status, which might be useful for a unified view if ever needed + keys_to_filter = {**all_keys_with_status["valid_keys"], **all_keys_with_status["invalid_keys"]} + + + # Further filtering (search and fail_count_threshold) + filtered_keys = {} + for key, fail_count in keys_to_filter.items(): + search_match = True + if search: + search_match = search.lower() in key.lower() + + fail_count_match = True + if fail_count_threshold is not None: + fail_count_match = fail_count >= fail_count_threshold + + if search_match and fail_count_match: + filtered_keys[key] = fail_count + + # Pagination + keys_list = list(filtered_keys.items()) + total_items = len(keys_list) + start_index = (page - 1) * limit + end_index = start_index + limit + paginated_keys = dict(keys_list[start_index:end_index]) + + return { + "keys": paginated_keys, + "total_items": total_items, + "total_pages": (total_items + limit - 1) // limit, + "current_page": page, + } diff --git a/app/router/routes.py b/app/router/routes.py index 7ab24e7..ae319f3 100644 --- a/app/router/routes.py +++ b/app/router/routes.py @@ -9,7 +9,7 @@ from fastapi.templating import Jinja2Templates from app.core.security import verify_auth_token from app.config.config import settings from app.log.logger import get_routes_logger -from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes, vertex_express_routes, files_routes +from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes, vertex_express_routes, files_routes, key_routes from app.service.key.key_manager import get_key_manager_instance from app.service.stats.stats_service import StatsService @@ -36,6 +36,7 @@ def setup_routers(app: FastAPI) -> None: app.include_router(openai_compatiable_routes.router) app.include_router(vertex_express_routes.router) app.include_router(files_routes.router) + app.include_router(key_routes.router) setup_page_routes(app) @@ -103,8 +104,8 @@ def setup_page_routes(app: FastAPI) -> None: "keys_status.html", { "request": request, - "valid_keys": keys_status["valid_keys"], - "invalid_keys": keys_status["invalid_keys"], + "valid_keys": {}, + "invalid_keys": {}, "total_keys": total_keys, "valid_key_count": valid_key_count, "invalid_key_count": invalid_key_count, @@ -113,7 +114,25 @@ def setup_page_routes(app: FastAPI) -> None: ) except Exception as e: logger.error(f"Error retrieving keys status or API stats: {str(e)}") - raise + # Even if there's an error, render the page with whatever data is available + # or with empty/default values, so the frontend can still load. + return templates.TemplateResponse( + "keys_status.html", + { + "request": request, + "valid_keys": {}, + "invalid_keys": {}, + "total_keys": 0, + "valid_key_count": 0, + "invalid_key_count": 0, + "api_stats": { # Provide a default structure for api_stats + "calls_1m": {"total": 0, "success": 0, "failure": 0}, + "calls_1h": {"total": 0, "success": 0, "failure": 0}, + "calls_24h": {"total": 0, "success": 0, "failure": 0}, + "calls_month": {"total": 0, "success": 0, "failure": 0}, + }, + }, + ) @app.get("/config", response_class=HTMLResponse) async def config_page(request: Request): diff --git a/app/service/key/key_manager.py b/app/service/key/key_manager.py index 0925ba9..efa0d11 100644 --- a/app/service/key/key_manager.py +++ b/app/service/key/key_manager.py @@ -141,6 +141,18 @@ class KeyManager: """获取指定 Vertex 密钥的失败次数""" return self.vertex_key_failure_counts.get(key, 0) + async def get_all_keys_with_fail_count(self) -> dict: + """获取所有API key及其失败次数""" + all_keys = {} + async with self.failure_count_lock: + for key in self.api_keys: + all_keys[key] = self.key_failure_counts.get(key, 0) + + valid_keys = {k: v for k, v in all_keys.items() if v < self.MAX_FAILURES} + invalid_keys = {k: v for k, v in all_keys.items() if v >= self.MAX_FAILURES} + + return {"valid_keys": valid_keys, "invalid_keys": invalid_keys, "all_keys": all_keys} + async def get_keys_by_status(self) -> dict: """获取分类后的API key列表,包括失败次数""" valid_keys = {} diff --git a/app/static/js/keys_status.js b/app/static/js/keys_status.js index bdb3d20..0c53ef7 100644 --- a/app/static/js/keys_status.js +++ b/app/static/js/keys_status.js @@ -1100,62 +1100,198 @@ function initializeAutoRefreshControls() { } } -// These variables are used by pagination and search, define them in a scope accessible by initializeKeyPaginationAndSearch +// Debounce function +function debounce(func, delay) { + let timeout; + return function(...args) { + const context = this; + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(context, args), delay); + }; +} + + +// --- Key List Display & Pagination --- + +/** + * Fetches and displays keys. + * @param {string} type 'valid' or 'invalid' + * @param {number} page Page number (1-based) + */ +async function fetchAndDisplayKeys(type, page = 1) { + const listElement = document.getElementById(`${type}Keys`); + const paginationControls = document.getElementById(`${type}PaginationControls`); + if (!listElement || !paginationControls) return; + + // Show loading indicator + listElement.innerHTML = `
  • Loading...
  • `; + + const itemsPerPageSelect = document.getElementById("itemsPerPageSelect"); + const limit = itemsPerPageSelect ? parseInt(itemsPerPageSelect.value, 10) : 10; + + const searchInput = document.getElementById("keySearchInput"); + const searchTerm = searchInput ? searchInput.value : ''; + + const thresholdInput = document.getElementById("failCountThreshold"); + const failCountThreshold = thresholdInput ? (thresholdInput.value === '' ? null : parseInt(thresholdInput.value, 10)) : null; + + try { + const params = new URLSearchParams({ + page: page, + limit: limit, + status: type, + }); + if (searchTerm) { + params.append('search', searchTerm); + } + if (failCountThreshold !== null) { + params.append('fail_count_threshold', failCountThreshold); + } + + const data = await fetchAPI(`/api/keys?${params.toString()}`); + + listElement.innerHTML = ""; // Clear loading indicator + + const keys = data.keys || {}; + if (Object.keys(keys).length > 0) { + Object.entries(keys).forEach(([key, fail_count]) => { + const listItem = createKeyListItem(key, fail_count, type); + listElement.appendChild(listItem); + }); + } else { + listElement.innerHTML = `
  • No keys found.
  • `; + } + + setupPaginationControls(type, data.current_page, data.total_pages); + updateBatchActions(type); + + } catch (error) { + console.error(`Error fetching ${type} keys:`, error); + listElement.innerHTML = `
  • Error loading keys.
  • `; + } +} + + +/** + * Creates a single key list item element. + * @param {string} key The API key. + * @param {number} fail_count The failure count for the key. + * @param {string} type 'valid' or 'invalid'. + * @returns {HTMLElement} The created list item element. + */ +function createKeyListItem(key, fail_count, type) { + const li = document.createElement("li"); + li.className = `bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border ${type === 'valid' ? 'hover:border-success-300' : 'hover:border-danger-300'} transform hover:-translate-y-1`; + li.dataset.key = key; + li.dataset.failCount = fail_count; + + const statusBadge = type === 'valid' + ? ` 有效` + : ` 无效`; + + li.innerHTML = ` + +
    +
    +
    + ${statusBadge} +
    + ${key.substring(0, 4)}...${key.substring(key.length - 4)} + +
    + + + 失败: ${fail_count} + +
    +
    + + + + + +
    +
    +
    + `; + return li; +} + + +/** + * Sets up pagination controls. + * @param {string} type 'valid' or 'invalid' + * @param {number} currentPage Current page number + * @param {number} totalPages Total number of pages + */ +function setupPaginationControls(type, currentPage, totalPages) { + const controlsContainer = document.getElementById(`${type}PaginationControls`); + if (!controlsContainer) return; + + controlsContainer.innerHTML = ""; + + if (totalPages <= 1) return; + + // Previous Button + const prevButton = document.createElement("button"); + prevButton.innerHTML = ''; + prevButton.className = `pagination-button px-3 py-1 rounded text-sm transition-colors duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed`; + prevButton.disabled = currentPage === 1; + prevButton.onclick = () => fetchAndDisplayKeys(type, currentPage - 1); + controlsContainer.appendChild(prevButton); + + // Page Number Buttons + for (let i = 1; i <= totalPages; i++) { + // Simple pagination for now, can be improved with ellipsis for many pages + const pageButton = document.createElement("button"); + pageButton.textContent = i; + pageButton.className = `pagination-button px-3 py-1 rounded text-sm transition-colors duration-150 ease-in-out ${i === currentPage ? 'active font-semibold' : ''}`; + pageButton.onclick = () => fetchAndDisplayKeys(type, i); + controlsContainer.appendChild(pageButton); + } + + // Next Button + const nextButton = document.createElement("button"); + nextButton.innerHTML = ''; + nextButton.className = `pagination-button px-3 py-1 rounded text-sm transition-colors duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed`; + nextButton.disabled = currentPage === totalPages; + nextButton.onclick = () => fetchAndDisplayKeys(type, currentPage + 1); + controlsContainer.appendChild(nextButton); +} let allValidKeys = []; -let allInvalidKeys = []; -let filteredValidKeys = []; -let itemsPerPage = 10; // Default -let validCurrentPage = 1; // Also used by displayPage -let invalidCurrentPage = 1; // Also used by displayPage - + let allInvalidKeys = []; + let filteredValidKeys = []; + let itemsPerPage = 10; // Default + let validCurrentPage = 1; // Also used by displayPage + let invalidCurrentPage = 1; // Also used by displayPage + function initializeKeyPaginationAndSearch() { - const validKeysListElement = document.getElementById("validKeys"); - const invalidKeysListElement = document.getElementById("invalidKeys"); - const searchInput = document.getElementById("keySearchInput"); - const itemsPerPageSelect = document.getElementById("itemsPerPageSelect"); - const thresholdInput = document.getElementById("failCountThreshold"); // Already used by initializeKeyFilterControls + const debouncedFetchValidKeys = debounce(() => fetchAndDisplayKeys('valid', 1), 300); + const debouncedFetchInvalidKeys = debounce(() => fetchAndDisplayKeys('invalid', 1), 300); - if (validKeysListElement) { - allValidKeys = Array.from( - validKeysListElement.querySelectorAll("li[data-key]") - ); - allValidKeys.forEach((li) => { - const keyTextSpan = li.querySelector(".key-text"); - if (keyTextSpan && keyTextSpan.dataset.fullKey) { - li.dataset.key = keyTextSpan.dataset.fullKey; - } - }); - filteredValidKeys = [...allValidKeys]; - } - if (invalidKeysListElement) { - allInvalidKeys = Array.from( - invalidKeysListElement.querySelectorAll("li[data-key]") - ); - allInvalidKeys.forEach((li) => { - const keyTextSpan = li.querySelector(".key-text"); - if (keyTextSpan && keyTextSpan.dataset.fullKey) { - li.dataset.key = keyTextSpan.dataset.fullKey; - } - }); - } + const searchInput = document.getElementById("keySearchInput"); + if (searchInput) { + searchInput.addEventListener("input", debouncedFetchValidKeys); + } - if (itemsPerPageSelect) { - itemsPerPage = parseInt(itemsPerPageSelect.value, 10); // Initialize itemsPerPage - itemsPerPageSelect.addEventListener("change", () => { - itemsPerPage = parseInt(itemsPerPageSelect.value, 10); - filterAndSearchValidKeys(); // Re-filter and display page 1 for valid keys - displayPage("invalid", 1, allInvalidKeys); // Reset invalid keys to page 1 - }); - } + const thresholdInput = document.getElementById("failCountThreshold"); + if (thresholdInput) { + thresholdInput.addEventListener("input", debouncedFetchValidKeys); + } + + const itemsPerPageSelect = document.getElementById("itemsPerPageSelect"); + if (itemsPerPageSelect) { + itemsPerPageSelect.addEventListener("change", () => { + fetchAndDisplayKeys('valid', 1); + fetchAndDisplayKeys('invalid', 1); + }); + } - // Initial display calls - filterAndSearchValidKeys(); - displayPage("invalid", 1, allInvalidKeys); - - // Event listeners for search and filter (thresholdInput listener is in initializeKeyFilterControls) - if (searchInput) { - searchInput.addEventListener("input", filterAndSearchValidKeys); - } + // Initial fetch + fetchAndDisplayKeys('valid'); + fetchAndDisplayKeys('invalid'); } function registerServiceWorker() { @@ -1649,77 +1785,11 @@ function displayPage(type, page, keyItemsArray) { ); if (!listElement || !paginationControls) return; - // Update current page based on type - if (type === "valid") { - validCurrentPage = page; - // Read itemsPerPage from the select specifically for valid keys - const itemsPerPageSelect = document.getElementById("itemsPerPageSelect"); - itemsPerPage = itemsPerPageSelect - ? parseInt(itemsPerPageSelect.value, 10) - : 10; - } else { - invalidCurrentPage = page; - // For invalid keys, use a fixed itemsPerPage or the same global one - // itemsPerPage = 10; // Or read from a different select if needed - } - - const totalItems = keyItemsArray.length; - const totalPages = Math.ceil(totalItems / itemsPerPage); - page = Math.max(1, Math.min(page, totalPages || 1)); // Ensure page is valid - - // Update current page variable again after validation - if (type === "valid") { - validCurrentPage = page; - } else { - invalidCurrentPage = page; - } - - const startIndex = (page - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - - listElement.innerHTML = ""; // Clear current list content - - const pageItems = keyItemsArray.slice(startIndex, endIndex); - - if (pageItems.length > 0) { - pageItems.forEach((originalMasterItem) => { - const listItemClone = originalMasterItem.cloneNode(true); - // The checkbox's 'checked' state is cloned from the master item. - // Now, ensure the 'selected' class on the clone matches this cloned checkbox state. - const checkboxInClone = listItemClone.querySelector(".key-checkbox"); - if (checkboxInClone) { - listItemClone.classList.toggle("selected", checkboxInClone.checked); - } - listElement.appendChild(listItemClone); - }); - } else if ( - totalItems === 0 && - type === "valid" && - (document.getElementById("failCountThreshold").value !== "0" || - document.getElementById("keySearchInput").value !== "") - ) { - // Handle empty state after filtering/searching for valid keys - const noMatchMsgId = "no-valid-keys-msg"; - let noMatchMsg = listElement.querySelector(`#${noMatchMsgId}`); - if (!noMatchMsg) { - noMatchMsg = document.createElement("li"); - noMatchMsg.id = noMatchMsgId; - noMatchMsg.className = "text-center text-gray-500 py-4 col-span-full"; - noMatchMsg.textContent = "没有符合条件的有效密钥"; - listElement.appendChild(noMatchMsg); - } - noMatchMsg.style.display = ""; - } else if (totalItems === 0) { - // Handle empty state for initially empty lists - const emptyMsg = document.createElement("li"); - emptyMsg.className = "text-center text-gray-500 py-4 col-span-full"; - emptyMsg.textContent = `暂无${type === "valid" ? "有效" : "无效"}密钥`; - listElement.appendChild(emptyMsg); - } - - setupPaginationControls(type, page, totalPages, keyItemsArray); + // This function is now mostly handled by fetchAndDisplayKeys. + // We can simplify this or remove it if all display logic is in fetchAndDisplayKeys. + // For now, let's keep it for rendering the pagination controls as a separate step. + setupPaginationControls(type, page, totalPages); updateBatchActions(type); // Update batch actions based on the currently displayed page - // Re-attach event listeners for buttons inside the newly added list items if needed (using event delegation is better) } /** @@ -1729,7 +1799,7 @@ function displayPage(type, page, keyItemsArray) { * @param {number} totalPages Total number of pages * @param {Array} keyItemsArray The array of li elements being paginated */ -function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) { +function setupPaginationControls(type, currentPage, totalPages) { const controlsContainer = document.getElementById( `${type}PaginationControls` ); @@ -1754,7 +1824,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) { prevButton.innerHTML = ''; prevButton.className = `${baseButtonClasses} disabled:opacity-50 disabled:cursor-not-allowed`; prevButton.disabled = currentPage === 1; - prevButton.onclick = () => displayPage(type, currentPage - 1, keyItemsArray); + prevButton.onclick = () => fetchAndDisplayKeys(type, currentPage - 1); controlsContainer.appendChild(prevButton); // Page Number Buttons (Logic for ellipsis) @@ -1771,7 +1841,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) { const firstPageButton = document.createElement("button"); firstPageButton.textContent = "1"; firstPageButton.className = `${baseButtonClasses}`; - firstPageButton.onclick = () => displayPage(type, 1, keyItemsArray); + firstPageButton.onclick = () => fetchAndDisplayKeys(type, 1); controlsContainer.appendChild(firstPageButton); if (startPage > 2) { const ellipsis = document.createElement("span"); @@ -1790,7 +1860,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) { ? "active font-semibold" // Relies on .pagination-button.active CSS for styling : "" // Non-active buttons just use .pagination-button style }`; - pageButton.onclick = () => displayPage(type, i, keyItemsArray); + pageButton.onclick = () => fetchAndDisplayKeys(type, i); controlsContainer.appendChild(pageButton); } @@ -1805,7 +1875,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) { const lastPageButton = document.createElement("button"); lastPageButton.textContent = totalPages; lastPageButton.className = `${baseButtonClasses}`; - lastPageButton.onclick = () => displayPage(type, totalPages, keyItemsArray); + lastPageButton.onclick = () => fetchAndDisplayKeys(type, totalPages); controlsContainer.appendChild(lastPageButton); } @@ -1814,7 +1884,7 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) { nextButton.innerHTML = ''; nextButton.className = `${baseButtonClasses} disabled:opacity-50 disabled:cursor-not-allowed`; nextButton.disabled = currentPage === totalPages; - nextButton.onclick = () => displayPage(type, currentPage + 1, keyItemsArray); + nextButton.onclick = () => fetchAndDisplayKeys(type, currentPage + 1); controlsContainer.appendChild(nextButton); } @@ -1825,26 +1895,5 @@ function setupPaginationControls(type, currentPage, totalPages, keyItemsArray) { * Updates the `filteredValidKeys` array and redisplays the first page. */ function filterAndSearchValidKeys() { - const thresholdInput = document.getElementById("failCountThreshold"); - const searchInput = document.getElementById("keySearchInput"); - - const threshold = parseInt(thresholdInput.value, 10); - const filterThreshold = isNaN(threshold) || threshold < 0 ? 0 : threshold; - const searchTerm = searchInput.value.trim().toLowerCase(); - - // Filter from the original full list (allValidKeys) - filteredValidKeys = allValidKeys.filter((item) => { - const failCount = parseInt(item.dataset.failCount, 10); - const fullKey = item.dataset.key || ""; // Use data-key which should hold the full key - - const failCountMatch = failCount >= filterThreshold; - const searchMatch = - searchTerm === "" || fullKey.toLowerCase().includes(searchTerm); - - return failCountMatch && searchMatch; - }); - - // Reset to the first page after filtering/searching - validCurrentPage = 1; - displayPage("valid", validCurrentPage, filteredValidKeys); -} + fetchAndDisplayKeys('valid', 1); +} \ No newline at end of file diff --git a/app/templates/keys_status.html b/app/templates/keys_status.html index b7ce5e1..e935b17 100644 --- a/app/templates/keys_status.html +++ b/app/templates/keys_status.html @@ -1319,95 +1319,10 @@ endblock %} {% block head_extra_styles %}
      - {# Initial keys rendered by server-side #} {# JS will replace this - content with paginated results #} {% if invalid_keys %} {% for key, - fail_count in invalid_keys.items() %} -
    • - - - -
      -
      -
      - - 无效 - -
      - {{ key[:4] + '...' + key[-4:] }} - -
      - - - 失败: {{ fail_count }} - -
      -
      - - - - - -
      -
      -
      -
    • - {% endfor %} {% else %} + {# This content is now loaded via JavaScript #}
    • - 暂无无效密钥 + Loading keys...
    • - {% endif %}