feat(error_logs): 添加错误日志搜索和日期过滤功能

- 在后端 (`services.py`, `log_routes.py`) 实现按 Gemini 密钥(模糊匹配)、错误类型/内容(模糊匹配)和日期范围(开始/结束日期)过滤错误日志的逻辑。
- 添加新函数 `get_error_logs_count` 以高效获取符合过滤条件的总日志数,用于分页。
- 更新 `/api/logs/errors` API 端点以接受 `key_search`, `error_search`, `start_date`, `end_date` 查询参数。端点现在返回包含过滤后日志和总数的对象。
- 增强前端 (`error_logs.html`, `error_logs.js`, `error_logs.css`):
    - 添加用于密钥搜索、错误/日志搜索和日期范围选择的输入字段。
    - 实现 JavaScript 逻辑以捕获搜索参数,使用过滤器触发 API 调用,并在新搜索时重置到第一页。
    - 更新表格渲染以显示顺序行号而非数据库 ID。
    - 在表格视图中遮罩 Gemini 密钥(显示前/后 4 个字符)以提高可读性,同时仍在详细信息模态框中显示完整密钥。
    - 优化新搜索控件、表格外观(内边距、边框、悬停效果、斑马条纹)和按钮样式的 CSS,以提供更清晰的用户界面。
- 通过使用 `logger.exception` 包含堆栈跟踪来改进后端服务中的错误日志记录。
This commit is contained in:
snaily
2025-04-10 19:16:06 +08:00
parent f05d67939f
commit 69261e98de
5 changed files with 278 additions and 57 deletions
+80 -9
View File
@@ -4,8 +4,9 @@
import datetime import datetime
import json import json
from typing import Dict, List, Optional, Any, Union from typing import Dict, List, Optional, Any, Union
from datetime import date, timedelta # Import date and timedelta
from sqlalchemy import select, insert, update from sqlalchemy import select, insert, update, func # Import func for COUNT
from app.database.connection import database from app.database.connection import database
from app.database.models import Settings, ErrorLog from app.database.models import Settings, ErrorLog
@@ -152,21 +153,91 @@ async def add_error_log(
return False return False
async def get_error_logs(limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]: async def get_error_logs(
limit: int = 20,
offset: int = 0,
key_search: Optional[str] = None,
error_search: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> List[Dict[str, Any]]:
""" """
获取错误日志 获取错误日志,支持搜索和日期过滤
Args: Args:
limit: 限制数量 limit (int): 限制数量
offset: 偏移量 offset (int): 偏移量
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
start_date (Optional[date]): 开始日期
end_date (Optional[date]): 结束日期
Returns: Returns:
List[Dict[str, Any]]: 错误日志列表 List[Dict[str, Any]]: 错误日志列表
""" """
try: try:
query = select(ErrorLog).order_by(ErrorLog.request_time.desc()).limit(limit).offset(offset) query = select(ErrorLog)
# Apply filters
if key_search:
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
if error_search:
query = query.where(
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
(ErrorLog.error_log.ilike(f"%{error_search}%"))
)
if start_date:
query = query.where(ErrorLog.request_time >= start_date)
if end_date:
# Add 1 day to end_date to include the whole day
query = query.where(ErrorLog.request_time < end_date + timedelta(days=1))
# Apply ordering, limit, and offset
query = query.order_by(ErrorLog.request_time.desc()).limit(limit).offset(offset)
result = await database.fetch_all(query) result = await database.fetch_all(query)
return [dict(row) for row in result] return [dict(row) for row in result]
except Exception as e: except Exception as e:
logger.error(f"Failed to get error logs: {str(e)}") logger.exception(f"Failed to get error logs with filters: {str(e)}") # Use exception for stack trace
raise
async def get_error_logs_count(
key_search: Optional[str] = None,
error_search: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> int:
"""
获取符合条件的错误日志总数
Args:
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
start_date (Optional[date]): 开始日期
end_date (Optional[date]): 结束日期
Returns:
int: 日志总数
"""
try:
query = select(func.count()).select_from(ErrorLog)
# Apply the same filters as get_error_logs
if key_search:
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
if error_search:
query = query.where(
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
(ErrorLog.error_log.ilike(f"%{error_search}%"))
)
if start_date:
query = query.where(ErrorLog.request_time >= start_date)
if end_date:
query = query.where(ErrorLog.request_time < end_date + timedelta(days=1))
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.exception(f"Failed to count error logs with filters: {str(e)}") # Use exception for stack trace
raise raise
+35 -9
View File
@@ -1,13 +1,15 @@
""" """
日志路由模块 日志路由模块
""" """
from typing import Any, Dict, List from typing import Any, Dict, List, Optional
from datetime import date
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException, Request, Query from fastapi import APIRouter, HTTPException, Request, Query
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from app.core.security import verify_auth_token from app.core.security import verify_auth_token
from app.log.logger import get_log_routes_logger from app.log.logger import get_log_routes_logger
from app.database.services import get_error_logs from app.database.services import get_error_logs, get_error_logs_count
# 创建路由 # 创建路由
router = APIRouter(prefix="/api/logs", tags=["logs"]) router = APIRouter(prefix="/api/logs", tags=["logs"])
@@ -15,11 +17,20 @@ router = APIRouter(prefix="/api/logs", tags=["logs"])
logger = get_log_routes_logger() logger = get_log_routes_logger()
@router.get("/errors", response_model=List[Dict[str, Any]]) # Define a response model that includes the total count for pagination
class ErrorLogResponse(BaseModel):
logs: List[Dict[str, Any]]
total: int
@router.get("/errors", response_model=ErrorLogResponse)
async def get_error_logs_api( async def get_error_logs_api(
request: Request, request: Request,
limit: int = Query(100, ge=1, le=1000), limit: int = Query(20, ge=1, le=1000), # Default to 20 to match frontend
offset: int = Query(0, ge=0) 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"),
start_date: Optional[date] = Query(None, description="Start date for filtering (YYYY-MM-DD)"),
end_date: Optional[date] = Query(None, description="End date for filtering (YYYY-MM-DD)")
): ):
""" """
获取错误日志 获取错误日志
@@ -30,7 +41,7 @@ async def get_error_logs_api(
offset: 偏移量 offset: 偏移量
Returns: Returns:
List[Dict[str, Any]]: 错误日志列表 ErrorLogResponse: An object containing the list of logs and the total count.
""" """
auth_token = request.cookies.get("auth_token") auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token): if not auth_token or not verify_auth_token(auth_token):
@@ -38,8 +49,23 @@ async def get_error_logs_api(
return RedirectResponse(url="/", status_code=302) return RedirectResponse(url="/", status_code=302)
try: try:
logs = await get_error_logs(limit, offset) # Fetch logs with search parameters
return logs logs = await get_error_logs(
limit=limit,
offset=offset,
key_search=key_search,
error_search=error_search,
start_date=start_date,
end_date=end_date
)
# Fetch total count with the same search parameters
total_count = await get_error_logs_count(
key_search=key_search,
error_search=error_search,
start_date=start_date,
end_date=end_date
)
return ErrorLogResponse(logs=logs, total=total_count)
except Exception as e: except Exception as e:
logger.error(f"Failed to get error logs: {str(e)}") logger.exception(f"Failed to get error logs: {str(e)}") # Use logger.exception for stack trace
raise HTTPException(status_code=500, detail=f"Failed to get error logs: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to get error logs: {str(e)}")
+92 -33
View File
@@ -1,7 +1,15 @@
/* error_logs.css - Styles specific to the error logs page, complementing config_editor.css */ /* error_logs.css - Styles specific to the error logs page, complementing config_editor.css */
/* Inherit body, container, h1, nav-tabs, config-section, scroll-buttons, refresh-btn, copyright from config_editor.css */ /* Inherit body, container, h1, nav-tabs, scroll-buttons, refresh-btn, copyright from config_editor.css */
/* Add padding to the main content section */
.config-section {
padding: 25px; /* Increased padding */
background-color: #fff; /* Ensure white background */
border-radius: 12px; /* Slightly larger radius */
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08); /* Softer shadow */
margin-top: 20px;
}
/* Style the controls container */ /* Style the controls container */
.controls-container { .controls-container {
display: flex; display: flex;
@@ -9,9 +17,9 @@
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 20px;
padding: 15px; padding: 15px;
background-color: rgba(255, 255, 255, 0.8); background-color: #f8f9fa; /* Lighter background */
border-radius: 8px; border-radius: 6px; /* Slightly smaller radius */
border: 1px solid rgba(0,0,0,0.05); border: 1px solid #e9ecef; /* Lighter border */
flex-wrap: wrap; /* Allow wrapping on smaller screens */ flex-wrap: wrap; /* Allow wrapping on smaller screens */
gap: 15px; /* Add gap between items */ gap: 15px; /* Add gap between items */
} }
@@ -67,13 +75,53 @@
font-size: 1em; font-size: 1em;
} }
/* Search Container Styles */
.search-container {
display: flex;
flex-wrap: wrap; /* Allow wrapping on smaller screens */
gap: 10px; /* Space between elements */
align-items: center;
padding: 15px;
background-color: #f8f9fa; /* Consistent light background */
border-radius: 6px;
border: 1px solid #e9ecef; /* Lighter border */
margin-bottom: 25px; /* Increased margin */
}
.search-container input[type="text"],
.search-container input[type="date"] {
padding: 8px 12px;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 6px;
font-size: 14px;
flex-grow: 1; /* Allow inputs to grow */
min-width: 150px; /* Minimum width for inputs */
}
.search-container input[type="date"] {
min-width: 130px; /* Slightly less width for date inputs */
flex-grow: 0; /* Don't let date inputs grow excessively */
}
.search-container span {
color: #2c3e50;
margin: 0 5px;
}
.search-container .action-btn {
padding: 8px 15px; /* Slightly smaller padding for search button */
font-size: 14px;
flex-shrink: 0; /* Prevent button from shrinking */
}
/* Table container and styled table */ /* Table container and styled table */
.table-container { .table-container {
overflow-x: auto; /* Allow horizontal scrolling for table */ overflow-x: auto; /* Allow horizontal scrolling for table */
background-color: white; background-color: #ffffff;
border-radius: 8px; border-radius: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.06); /* Softer shadow */
border: 1px solid rgba(0,0,0,0.05); border: 1px solid #e9ecef; /* Lighter border */
} }
.styled-table { .styled-table {
@@ -84,17 +132,17 @@
} }
.styled-table thead tr { .styled-table thead tr {
background-color: #f8f9fa; /* Light grey header */ background-color: #eef2f7; /* Lighter, slightly blueish header */
text-align: left; text-align: left;
font-weight: bold; font-weight: 600; /* Slightly bolder */
color: #2c3e50; color: #495057; /* Darker grey text */
border-bottom: 2px solid #dee2e6; border-bottom: 1px solid #dee2e6; /* Thinner border */
} }
.styled-table th, .styled-table th,
.styled-table td { .styled-table td {
padding: 12px 15px; padding: 14px 15px; /* Increased padding */
border-bottom: 1px solid #eee; /* Lighter border for rows */ border-bottom: 1px solid #f1f3f5; /* Very light border */
vertical-align: middle; vertical-align: middle;
} }
@@ -103,7 +151,16 @@
} }
.styled-table tbody tr:hover { .styled-table tbody tr:hover {
background-color: #f1f1f1; /* Subtle hover effect */ background-color: #f8f9fa; /* Lighter hover effect */
}
/* Zebra striping for table body */
.styled-table tbody tr:nth-child(even) {
background-color: #fcfdff; /* Very light blue for even rows */
}
.styled-table tbody tr:nth-child(even):hover {
background-color: #f0f4f8; /* Slightly darker hover for even rows */
} }
.styled-table tbody tr:last-of-type { .styled-table tbody tr:last-of-type {
@@ -121,18 +178,20 @@
/* Style for the 'View Details' button */ /* Style for the 'View Details' button */
.btn-view-details { .btn-view-details {
background-color: #667eea; background-color: #e9ecef; /* Light grey background */
color: white; color: #495057; /* Dark grey text */
border: none; border: 1px solid #dee2e6; /* Subtle border */
padding: 5px 10px; padding: 4px 8px; /* Slightly smaller padding */
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
transition: background-color 0.3s ease; transition: all 0.2s ease;
} }
.btn-view-details:hover { .btn-view-details:hover {
background-color: #5a6fd0; background-color: #dee2e6; /* Darker grey on hover */
border-color: #ced4da;
color: #212529;
} }
/* Status Indicators (Loading, No Data, Error) */ /* Status Indicators (Loading, No Data, Error) */
@@ -204,10 +263,10 @@
.pagination .page-link { .pagination .page-link {
display: block; display: block;
padding: 8px 12px; padding: 8px 14px; /* Slightly wider */
color: #667eea; color: #6c757d; /* Grey text */
background-color: white; background-color: white;
border: 1px solid #ddd; border: 1px solid #dee2e6; /* Lighter border */
border-radius: 4px; border-radius: 4px;
text-decoration: none; text-decoration: none;
transition: all 0.3s ease; transition: all 0.3s ease;
@@ -215,15 +274,15 @@
} }
.pagination .page-link:hover { .pagination .page-link:hover {
background-color: #f0f0f0; background-color: #e9ecef; /* Light grey hover */
border-color: #ccc; border-color: #dee2e6;
} }
.pagination .page-item.active .page-link { .pagination .page-item.active .page-link {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #708cf3 0%, #8b60d5 100%); /* Adjusted gradient */
color: white; color: white;
border-color: #667eea; border-color: #708cf3;
font-weight: bold; font-weight: 600;
} }
.pagination .page-item.disabled .page-link { .pagination .page-item.disabled .page-link {
@@ -263,10 +322,10 @@
.detail-item p, .detail-item p,
.detail-item pre { .detail-item pre {
background-color: #f8f9fa; background-color: #e9ecef; /* Lighter background for code blocks */
padding: 10px; padding: 12px;
border-radius: 4px; border-radius: 5px;
border: 1px solid #eee; border: 1px solid #dee2e6; /* Match border color */
font-size: 14px; font-size: 14px;
color: #333; color: #333;
margin: 0; margin: 0;
+59 -6
View File
@@ -37,6 +37,12 @@ let currentPage = 1;
let pageSize = 20; let pageSize = 20;
// let totalPages = 1; // totalPages will be calculated dynamically based on API response if available, or based on fetched data length // let totalPages = 1; // totalPages will be calculated dynamically based on API response if available, or based on fetched data length
let errorLogs = []; // Store fetched logs for details view let errorLogs = []; // Store fetched logs for details view
let currentSearch = { // Store current search parameters
key: '',
error: '',
startDate: '',
endDate: ''
};
// DOM Elements Cache // DOM Elements Cache
let pageSizeSelector; let pageSizeSelector;
@@ -48,6 +54,11 @@ let noDataMessage;
let errorMessage; let errorMessage;
let logDetailModal; let logDetailModal;
let modalCloseBtns; // Collection of close buttons for the modal let modalCloseBtns; // Collection of close buttons for the modal
let keySearchInput;
let errorSearchInput;
let startDateInput;
let endDateInput;
let searchBtn;
// 页面加载完成后执行 // 页面加载完成后执行
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@@ -62,6 +73,11 @@ document.addEventListener('DOMContentLoaded', function() {
logDetailModal = document.getElementById('logDetailModal'); logDetailModal = document.getElementById('logDetailModal');
// Get all elements that should close the modal // Get all elements that should close the modal
modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn'); modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn');
keySearchInput = document.getElementById('keySearch');
errorSearchInput = document.getElementById('errorSearch');
startDateInput = document.getElementById('startDate');
endDateInput = document.getElementById('endDate');
searchBtn = document.getElementById('searchBtn');
// Initialize page size selector // Initialize page size selector
if (pageSizeSelector) { if (pageSizeSelector) {
@@ -86,6 +102,19 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// Initialize search button
if (searchBtn) {
searchBtn.addEventListener('click', function() {
// Update search parameters from input fields
currentSearch.key = keySearchInput ? keySearchInput.value.trim() : '';
currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : '';
currentSearch.startDate = startDateInput ? startDateInput.value : '';
currentSearch.endDate = endDateInput ? endDateInput.value : '';
currentPage = 1; // Reset to first page on new search
loadErrorLogs();
});
}
// Initialize modal close buttons // Initialize modal close buttons
if (logDetailModal && modalCloseBtns) { if (logDetailModal && modalCloseBtns) {
modalCloseBtns.forEach(btn => { modalCloseBtns.forEach(btn => {
@@ -112,7 +141,22 @@ async function loadErrorLogs() {
const offset = (currentPage - 1) * pageSize; const offset = (currentPage - 1) * pageSize;
try { try {
const response = await fetch(`/api/logs/errors?limit=${pageSize}&offset=${offset}`); // Construct the API URL with search parameters
let apiUrl = `/api/logs/errors?limit=${pageSize}&offset=${offset}`;
if (currentSearch.key) {
apiUrl += `&key_search=${encodeURIComponent(currentSearch.key)}`;
}
if (currentSearch.error) {
apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`;
}
if (currentSearch.startDate) {
apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`;
}
if (currentSearch.endDate) {
apiUrl += `&end_date=${encodeURIComponent(currentSearch.endDate)}`;
}
const response = await fetch(apiUrl);
if (!response.ok) { if (!response.ok) {
// Try to get error message from response body // Try to get error message from response body
let errorData; let errorData;
@@ -162,9 +206,11 @@ function renderErrorLogs(logs) {
return; return;
} }
logs.forEach(log => { const startIndex = (currentPage - 1) * pageSize; // Calculate starting index for the current page
const row = document.createElement('tr');
logs.forEach((log, index) => { // Add index parameter to forEach
const row = document.createElement('tr');
const sequentialId = startIndex + index + 1; // Calculate sequential ID for the current page
// Format date // Format date
let formattedTime = 'N/A'; let formattedTime = 'N/A';
try { try {
@@ -181,9 +227,16 @@ function renderErrorLogs(logs) {
// Truncate error log content for display // Truncate error log content for display
const errorLogContent = log.error_log ? log.error_log.substring(0, 100) + (log.error_log.length > 100 ? '...' : '') : '无'; const errorLogContent = log.error_log ? log.error_log.substring(0, 100) + (log.error_log.length > 100 ? '...' : '') : '无';
// Mask the Gemini key for display in the table
const maskKey = (key) => {
if (!key || key.length < 8) return key || '无'; // Don't mask short keys or null
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
};
const maskedKey = maskKey(log.gemini_key);
row.innerHTML = ` row.innerHTML = `
<td>${log.id}</td> <td>${sequentialId}</td> <!-- Use sequential ID -->
<td>${log.gemini_key || ''}</td> <td title="${log.gemini_key || ''}">${maskedKey}</td>
<td>${log.error_type || '未知'}</td> <td>${log.error_type || '未知'}</td>
<td class="error-log-content" title="${log.error_log || ''}">${errorLogContent}</td> <td class="error-log-content" title="${log.error_log || ''}">${errorLogContent}</td>
<td>${log.model_name || '未知'}</td> <td>${log.model_name || '未知'}</td>
@@ -246,7 +299,7 @@ function showLogDetails(logId) {
} }
} }
// Populate modal content // Populate modal content (show full key in modal)
document.getElementById('modalGeminiKey').textContent = log.gemini_key || '无'; document.getElementById('modalGeminiKey').textContent = log.gemini_key || '无';
document.getElementById('modalErrorType').textContent = log.error_type || '未知'; document.getElementById('modalErrorType').textContent = log.error_type || '未知';
document.getElementById('modalErrorLog').textContent = log.error_log || '无'; document.getElementById('modalErrorLog').textContent = log.error_log || '无';
+12
View File
@@ -54,6 +54,18 @@
</button> </button>
</div> </div>
<!-- Search Controls -->
<div class="search-container">
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)">
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志"> <!-- Changed ID -->
<input type="date" id="startDate">
<span></span>
<input type="date" id="endDate">
<button id="searchBtn" class="action-btn">
<i class="fas fa-search"></i> 搜索
</button>
</div>
<div class="table-container"> <!-- New container for table --> <div class="table-container"> <!-- New container for table -->
<table class="styled-table"> <!-- Use a custom table class --> <table class="styled-table"> <!-- Use a custom table class -->
<thead> <thead>