From dbe50628b37856f85193c811e59cdbe518b201c4 Mon Sep 17 00:00:00 2001 From: snaily Date: Wed, 23 Apr 2025 18:31:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(error-logs):=20=E5=A2=9E=E5=BC=BA=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=97=A5=E5=BF=97=E5=8A=9F=E8=83=BD=E5=92=8CUI?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增错误码搜索功能,支持精确匹配错误码 - 重构复制功能,支持批量选择和复制密钥 - 优化UI布局和交互体验,添加悬停复制按钮 - 重构路由结构,将log_routes.py重命名为error_log_routes.py --- app/database/services.py | 25 ++ .../{log_routes.py => error_log_routes.py} | 4 + app/router/routes.py | 4 +- app/static/js/error_logs.js | 281 +++++++++++++++--- app/templates/error_logs.html | 88 ++++-- 5 files changed, 337 insertions(+), 65 deletions(-) rename app/router/{log_routes.py => error_log_routes.py} (93%) diff --git a/app/database/services.py b/app/database/services.py index 8d38b17..0f760d8 100644 --- a/app/database/services.py +++ b/app/database/services.py @@ -157,6 +157,7 @@ async def get_error_logs( offset: int = 0, key_search: Optional[str] = None, error_search: Optional[str] = None, + error_code_search: Optional[str] = None, # Added error code search start_date: Optional[datetime] = None, end_date: Optional[datetime] = None ) -> List[Dict[str, Any]]: @@ -168,6 +169,7 @@ async def get_error_logs( offset (int): 偏移量 key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配) error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配) + error_code_search (Optional[str]): 错误码搜索词 (精确匹配) start_date (Optional[datetime]): 开始日期时间 end_date (Optional[datetime]): 结束日期时间 @@ -198,6 +200,17 @@ async def get_error_logs( if end_date: # Use the datetime object directly for comparison query = query.where(ErrorLog.request_time < end_date) + if error_code_search: + try: + # Attempt to convert search string to integer for exact match + error_code_int = int(error_code_search) + query = query.where(ErrorLog.error_code == error_code_int) + except ValueError: + # If conversion fails, log a warning and potentially skip this filter + # or handle as needed (e.g., return no results for invalid code format) + logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.") + # Optionally, force no results if the format is invalid: + # query = query.where(False) # This ensures no rows are returned # Apply ordering, limit, and offset query = query.order_by(ErrorLog.id.desc()).limit(limit).offset(offset) @@ -212,6 +225,7 @@ async def get_error_logs( async def get_error_logs_count( key_search: Optional[str] = None, error_search: Optional[str] = None, + error_code_search: Optional[str] = None, # Added error code search start_date: Optional[datetime] = None, end_date: Optional[datetime] = None ) -> int: @@ -221,6 +235,7 @@ async def get_error_logs_count( Args: key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配) error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配) + error_code_search (Optional[str]): 错误码搜索词 (精确匹配) start_date (Optional[datetime]): 开始日期时间 end_date (Optional[datetime]): 结束日期时间 @@ -243,6 +258,16 @@ async def get_error_logs_count( if end_date: # Use the datetime object directly for comparison query = query.where(ErrorLog.request_time < end_date) + if error_code_search: + try: + # Attempt to convert search string to integer for exact match + error_code_int = int(error_code_search) + query = query.where(ErrorLog.error_code == error_code_int) + except ValueError: + # If conversion fails, log a warning and potentially skip this filter + logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.") + # Optionally, force count to 0 if the format is invalid: + # return 0 # Or query = query.where(False) before fetching count_result = await database.fetch_one(query) return count_result[0] if count_result else 0 diff --git a/app/router/log_routes.py b/app/router/error_log_routes.py similarity index 93% rename from app/router/log_routes.py rename to app/router/error_log_routes.py index a7c4a7e..f586e15 100644 --- a/app/router/log_routes.py +++ b/app/router/error_log_routes.py @@ -38,6 +38,7 @@ async def get_error_logs_api( offset: int = Query(0, ge=0), key_search: Optional[str] = Query(None, description="Search term for Gemini key (partial match)"), error_search: Optional[str] = Query(None, description="Search term for error type or log message"), # 数据库查询需处理 + error_code_search: Optional[str] = Query(None, description="Search term for error code"), # Added error code search parameter start_date: Optional[datetime] = Query(None, description="Start datetime for filtering"), end_date: Optional[datetime] = Query(None, description="End datetime for filtering") ): @@ -50,6 +51,7 @@ async def get_error_logs_api( offset: 偏移量 key_search: 密钥搜索 error_search: 错误搜索 (可能搜索类型或日志内容,由DB层决定) + error_code_search: 错误码搜索 start_date: 开始日期 end_date: 结束日期 @@ -70,6 +72,7 @@ async def get_error_logs_api( offset=offset, key_search=key_search, error_search=error_search, # 数据库查询需要处理这个 + error_code_search=error_code_search, # Pass error code search to DB function start_date=start_date, end_date=end_date, ) @@ -77,6 +80,7 @@ async def get_error_logs_api( total_count = await get_error_logs_count( key_search=key_search, error_search=error_search, + error_code_search=error_code_search, # Pass error code search to DB count function start_date=start_date, end_date=end_date ) diff --git a/app/router/routes.py b/app/router/routes.py index 21d755f..7693ad0 100644 --- a/app/router/routes.py +++ b/app/router/routes.py @@ -8,7 +8,7 @@ from fastapi.templating import Jinja2Templates from app.core.security import verify_auth_token from app.log.logger import get_routes_logger -from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes, stats_routes # 新增导入 stats_routes +from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes # 新增导入 stats_routes from app.service.key.key_manager import get_key_manager_instance from app.service.stats_service import StatsService @@ -30,7 +30,7 @@ def setup_routers(app: FastAPI) -> None: app.include_router(gemini_routes.router) app.include_router(gemini_routes.router_v1beta) app.include_router(config_routes.router) - app.include_router(log_routes.router) + app.include_router(error_log_routes.router) app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由 app.include_router(stats_routes.router) # 包含 stats API 路由 diff --git a/app/static/js/error_logs.js b/app/static/js/error_logs.js index 462b5e8..20f8d6f 100644 --- a/app/static/js/error_logs.js +++ b/app/static/js/error_logs.js @@ -20,6 +20,7 @@ let errorLogs = []; // Store fetched logs for details view let currentSearch = { // Store current search parameters key: '', error: '', + errorCode: '', // Added error code search startDate: '', endDate: '' }; @@ -36,11 +37,15 @@ let logDetailModal; let modalCloseBtns; // Collection of close buttons for the modal let keySearchInput; let errorSearchInput; +let errorCodeSearchInput; // Added error code input let startDateInput; let endDateInput; let searchBtn; -let pageInput; // 新增:页码输入框 -let goToPageBtn; // 新增:跳转按钮 +let pageInput; +let goToPageBtn; +let selectAllCheckbox; // 新增:全选复选框 +let copySelectedKeysBtn; // 新增:复制选中按钮 +let selectedCountSpan; // 新增:选中计数显示 // 页面加载完成后执行 document.addEventListener('DOMContentLoaded', function() { @@ -57,11 +62,15 @@ document.addEventListener('DOMContentLoaded', function() { modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn'); keySearchInput = document.getElementById('keySearch'); errorSearchInput = document.getElementById('errorSearch'); + errorCodeSearchInput = document.getElementById('errorCodeSearch'); // Get error code input startDateInput = document.getElementById('startDate'); endDateInput = document.getElementById('endDate'); searchBtn = document.getElementById('searchBtn'); - pageInput = document.getElementById('pageInput'); // 新增 - goToPageBtn = document.getElementById('goToPageBtn'); // 新增 + pageInput = document.getElementById('pageInput'); + goToPageBtn = document.getElementById('goToPageBtn'); + selectAllCheckbox = document.getElementById('selectAllCheckbox'); // 新增 + copySelectedKeysBtn = document.getElementById('copySelectedKeysBtn'); // 新增 + selectedCountSpan = document.getElementById('selectedCount'); // 新增 // Initialize page size selector if (pageSizeSelector) { @@ -81,6 +90,7 @@ document.addEventListener('DOMContentLoaded', function() { // Update search parameters from input fields currentSearch.key = keySearchInput ? keySearchInput.value.trim() : ''; currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : ''; + currentSearch.errorCode = errorCodeSearchInput ? errorCodeSearchInput.value.trim() : ''; // Get error code value currentSearch.startDate = startDateInput ? startDateInput.value : ''; currentSearch.endDate = endDateInput ? endDateInput.value : ''; currentPage = 1; // Reset to first page on new search @@ -104,8 +114,11 @@ document.addEventListener('DOMContentLoaded', function() { // Initial load of error logs loadErrorLogs(); - // Add event listeners for copy buttons inside the modal - setupCopyButtons(); + // Add event listeners for copy buttons inside the modal and table + setupCopyButtons(); // This will now also handle table copy buttons if called after render + + // Add event listeners for bulk selection + setupBulkSelectionListeners(); // 新增:设置批量选择监听器 // 新增:为页码跳转按钮添加事件监听器 if (goToPageBtn && pageInput) { @@ -174,44 +187,196 @@ function handleCopyResult(buttonElement, success) { setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class } -// Function to set up copy button listeners (using modern API with fallback) -function setupCopyButtons() { - const copyButtons = document.querySelectorAll('.copy-btn'); +// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons +function setupCopyButtons(containerSelector = 'body') { + // Find buttons within the specified container (defaults to body) + const container = document.querySelector(containerSelector); + if (!container) return; + + const copyButtons = container.querySelectorAll('.copy-btn'); copyButtons.forEach(button => { - button.addEventListener('click', function() { - const targetId = this.getAttribute('data-target'); - const targetElement = document.getElementById(targetId); - - if (targetElement) { - const textToCopy = targetElement.textContent; - let copySuccess = false; - - // Try modern clipboard API first (requires HTTPS or localhost) - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(textToCopy).then(() => { - handleCopyResult(this, true); // Use helper for feedback - }).catch(err => { - console.error('Clipboard API failed, attempting fallback:', err); - // Attempt fallback if modern API fails - copySuccess = fallbackCopyTextToClipboard(textToCopy); - handleCopyResult(this, copySuccess); // Use helper for feedback - }); - } else { - // Use fallback if modern API is not available or context is insecure - console.warn("Clipboard API not available or context insecure. Using fallback copy method."); - copySuccess = fallbackCopyTextToClipboard(textToCopy); - handleCopyResult(this, copySuccess); // Use helper for feedback - } - } else { - console.error('Target element not found:', targetId); - showNotification('复制出错:找不到目标元素', 'error'); - } - }); + // Remove existing listener to prevent duplicates if called multiple times + button.removeEventListener('click', handleCopyButtonClick); + // Add the listener + button.addEventListener('click', handleCopyButtonClick); }); } +// Extracted click handler logic for reusability and removing listeners +function handleCopyButtonClick() { + const button = this; // 'this' refers to the button clicked + const targetId = button.getAttribute('data-target'); + const textToCopyDirect = button.getAttribute('data-copy-text'); // For direct text copy (e.g., table key) + let textToCopy = ''; + + if (textToCopyDirect) { + textToCopy = textToCopyDirect; + } else if (targetId) { + const targetElement = document.getElementById(targetId); + if (targetElement) { + textToCopy = targetElement.textContent; + } else { + console.error('Target element not found:', targetId); + showNotification('复制出错:找不到目标元素', 'error'); + return; // Exit if target element not found + } + } else { + console.error('No data-target or data-copy-text attribute found on button:', button); + showNotification('复制出错:未指定复制内容', 'error'); + return; // Exit if no source specified + } + + + if (textToCopy) { + let copySuccess = false; + // Try modern clipboard API first (requires HTTPS or localhost) + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(textToCopy).then(() => { + handleCopyResult(button, true); // Use helper for feedback + }).catch(err => { + console.error('Clipboard API failed, attempting fallback:', err); + // Attempt fallback if modern API fails + copySuccess = fallbackCopyTextToClipboard(textToCopy); + handleCopyResult(button, copySuccess); // Use helper for feedback + }); + } else { + // Use fallback if modern API is not available or context is insecure + console.warn("Clipboard API not available or context insecure. Using fallback copy method."); + copySuccess = fallbackCopyTextToClipboard(textToCopy); + handleCopyResult(button, copySuccess); // Use helper for feedback + } + } else { + console.warn('No text found to copy for target:', targetId || 'direct text'); + showNotification('没有内容可复制', 'warning'); + } +} // End of handleCopyButtonClick function + +// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons +function setupCopyButtons(containerSelector = 'body') { + // Find buttons within the specified container (defaults to body) + const container = document.querySelector(containerSelector); + if (!container) return; + + const copyButtons = container.querySelectorAll('.copy-btn'); + copyButtons.forEach(button => { + // Remove existing listener to prevent duplicates if called multiple times + button.removeEventListener('click', handleCopyButtonClick); + // Add the listener + button.addEventListener('click', handleCopyButtonClick); + }); +} + +// 新增:设置批量选择相关的事件监听器 +function setupBulkSelectionListeners() { + if (selectAllCheckbox) { + selectAllCheckbox.addEventListener('change', handleSelectAllChange); + } + + if (tableBody) { + // 使用事件委托处理行复选框的点击 + tableBody.addEventListener('change', handleRowCheckboxChange); + } + + if (copySelectedKeysBtn) { + copySelectedKeysBtn.addEventListener('click', handleCopySelectedKeys); + } +} + +// 新增:处理“全选”复选框变化的函数 +function handleSelectAllChange() { + const isChecked = selectAllCheckbox.checked; + const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox'); + rowCheckboxes.forEach(checkbox => { + checkbox.checked = isChecked; + }); + updateSelectedState(); +} + +// 新增:处理行复选框变化的函数 (事件委托) +function handleRowCheckboxChange(event) { + if (event.target.classList.contains('row-checkbox')) { + updateSelectedState(); + } +} + +// 新增:更新选中状态(计数、按钮状态、全选框状态) +function updateSelectedState() { + const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox'); + const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked'); + const selectedCount = selectedCheckboxes.length; + + // 移除了数字显示,不再更新selectedCountSpan + // 仍然更新复制按钮的禁用状态 + if (copySelectedKeysBtn) { + copySelectedKeysBtn.disabled = selectedCount === 0; + + // 可选:根据选中项数量更新按钮标题属性 + copySelectedKeysBtn.setAttribute('title', `复制${selectedCount}项选中密钥`); + } + + // 更新“全选”复选框的状态 + if (selectAllCheckbox) { + if (rowCheckboxes.length > 0 && selectedCount === rowCheckboxes.length) { + selectAllCheckbox.checked = true; + selectAllCheckbox.indeterminate = false; + } else if (selectedCount > 0) { + selectAllCheckbox.checked = false; + selectAllCheckbox.indeterminate = true; // 部分选中状态 + } else { + selectAllCheckbox.checked = false; + selectAllCheckbox.indeterminate = false; + } + } +} + +// 新增:处理“复制选中密钥”按钮点击的函数 +function handleCopySelectedKeys() { + const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked'); + const keysToCopy = []; + selectedCheckboxes.forEach(checkbox => { + const key = checkbox.getAttribute('data-key'); + if (key) { + keysToCopy.push(key); + } + }); + + if (keysToCopy.length > 0) { + const textToCopy = keysToCopy.join('\n'); // 每行一个密钥 + copyTextToClipboard(textToCopy, copySelectedKeysBtn); // 使用通用复制函数 + } else { + showNotification('没有选中的密钥可复制', 'warning'); + } +} + +// 新增:通用的文本复制函数(结合现有逻辑) +function copyTextToClipboard(text, buttonElement = null) { + let copySuccess = false; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => { + if (buttonElement) handleCopyResult(buttonElement, true); + else showNotification('已复制到剪贴板', 'success'); + }).catch(err => { + console.error('Clipboard API failed, attempting fallback:', err); + copySuccess = fallbackCopyTextToClipboard(text); + if (buttonElement) handleCopyResult(buttonElement, copySuccess); + else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error'); + }); + } else { + console.warn("Clipboard API not available or context insecure. Using fallback copy method."); + copySuccess = fallbackCopyTextToClipboard(text); + if (buttonElement) handleCopyResult(buttonElement, copySuccess); + else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error'); + } +} + + // 加载错误日志数据 async function loadErrorLogs() { + // 重置选择状态 + if (selectAllCheckbox) selectAllCheckbox.checked = false; + if (selectAllCheckbox) selectAllCheckbox.indeterminate = false; + updateSelectedState(); // 更新按钮状态和计数 + showLoading(true); showError(false); showNoData(false); @@ -227,6 +392,9 @@ async function loadErrorLogs() { if (currentSearch.error) { apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`; } + if (currentSearch.errorCode) { // Add error code to API request + apiUrl += `&error_code_search=${encodeURIComponent(currentSearch.errorCode)}`; + } if (currentSearch.startDate) { apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`; } @@ -274,6 +442,12 @@ function renderErrorLogs(logs) { if (!tableBody) return; tableBody.innerHTML = ''; // Clear previous entries + // 重置全选复选框状态(在清空表格后) + if (selectAllCheckbox) { + selectAllCheckbox.checked = false; + selectAllCheckbox.indeterminate = false; + } + if (!logs || logs.length === 0) { // Handled by showNoData return; @@ -306,10 +480,20 @@ function renderErrorLogs(logs) { return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`; }; const maskedKey = maskKey(log.gemini_key); + const fullKey = log.gemini_key || ''; // Store the full key row.innerHTML = ` + + + ${sequentialId} - ${maskedKey} + + ${maskedKey} + + + ${log.error_type || '未知'} ${errorCodeContent} ${log.model_name || '未知'} @@ -331,6 +515,11 @@ function renderErrorLogs(logs) { showLogDetails(logId); }); }); + + // Re-initialize copy buttons specifically for the newly rendered table rows + setupCopyButtons('#errorLogsTable'); + // Update selected state after rendering + updateSelectedState(); } // 显示错误日志详情 (从 API 获取) @@ -403,6 +592,9 @@ async function showLogDetails(logId) { document.getElementById('modalModelName').textContent = logDetails.model_name || '未知'; document.getElementById('modalRequestTime').textContent = formattedTime; + // Re-initialize copy buttons specifically for the modal after content is loaded + setupCopyButtons('#logDetailModal'); + } catch (error) { console.error('获取日志详情失败:', error); // Show error in modal @@ -547,10 +739,17 @@ function showError(show, message = '加载错误日志失败,请稍后重试 // Function to show temporary status notifications (like copy success) function showNotification(message, type = 'success', duration = 3000) { - const notificationElement = document.getElementById('copyStatus'); // Or a more generic ID if needed - if (!notificationElement) return; + const notificationElement = document.getElementById('notification'); // Use the correct ID from base.html + if (!notificationElement) { + console.error("Notification element with ID 'notification' not found."); + return; + } + // Set message and type class notificationElement.textContent = message; + // Remove previous type classes before adding the new one + notificationElement.classList.remove('success', 'error', 'warning', 'info'); + notificationElement.classList.add(type); // Add the type class for styling notificationElement.className = `notification ${type} show`; // Add 'show' class // Hide after duration diff --git a/app/templates/error_logs.html b/app/templates/error_logs.html index 1c63ae7..b6bb111 100644 --- a/app/templates/error_logs.html +++ b/app/templates/error_logs.html @@ -48,6 +48,29 @@ } } /* Modal styles are in base.html */ + + /* 自定义样式调整:减小搜索按钮和复制按钮的高度和宽度 */ + #searchBtn, #copySelectedKeysBtn { + padding-top: 0.4rem !important; + padding-bottom: 0.4rem !important; + width: 96px !important; + } + + /* 增加搜索按钮和时间控件之间的间距 */ + .search-controls-grid { + gap: 1rem !important; + } + + /* 确保输入框和按钮高度一致 */ + .search-controls-grid input, .date-range-container input { + padding-top: 0.4rem !important; + padding-bottom: 0.4rem !important; + } + + /* 为搜索按钮添加额外的左边距 */ + #searchBtn { + margin-left: 1rem !important; + } {% endblock %} @@ -83,26 +106,35 @@ - -
- - + +
+ + +
- + - + +
+
+ +
-
- +
- + + @@ -186,17 +218,23 @@
-
+
Gemini密钥:

-                    
- -
-
错误类型:
-

+
+
错误类型:
+

+ +
+ +
错误日志:

                         
                     
-
+
请求消息:

                          
                     
-
+
模型名称:
-

+

+
-
+
请求时间:
-

+

+
ID + + ID Gemini密钥 错误类型 错误码