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

View File

@@ -4,8 +4,9 @@
import datetime
import json
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.models import Settings, ErrorLog
@@ -152,21 +153,91 @@ async def add_error_log(
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:
limit: 限制数量
offset: 偏移量
limit (int): 限制数量
offset (int): 偏移量
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
start_date (Optional[date]): 开始日期
end_date (Optional[date]): 结束日期
Returns:
List[Dict[str, Any]]: 错误日志列表
"""
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)
return [dict(row) for row in result]
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

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.responses import RedirectResponse
from app.core.security import verify_auth_token
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"])
@@ -15,11 +17,20 @@ router = APIRouter(prefix="/api/logs", tags=["logs"])
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(
request: Request,
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0)
limit: int = Query(20, ge=1, le=1000), # Default to 20 to match frontend
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: 偏移量
Returns:
List[Dict[str, Any]]: 错误日志列表
ErrorLogResponse: An object containing the list of logs and the total count.
"""
auth_token = request.cookies.get("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)
try:
logs = await get_error_logs(limit, offset)
return logs
# Fetch logs with search parameters
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:
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)}")

View File

@@ -1,7 +1,15 @@
/* 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 */
.controls-container {
display: flex;
@@ -9,9 +17,9 @@
align-items: center;
margin-bottom: 20px;
padding: 15px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.05);
background-color: #f8f9fa; /* Lighter background */
border-radius: 6px; /* Slightly smaller radius */
border: 1px solid #e9ecef; /* Lighter border */
flex-wrap: wrap; /* Allow wrapping on smaller screens */
gap: 15px; /* Add gap between items */
}
@@ -67,13 +75,53 @@
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 {
overflow-x: auto; /* Allow horizontal scrolling for table */
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
border: 1px solid rgba(0,0,0,0.05);
background-color: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06); /* Softer shadow */
border: 1px solid #e9ecef; /* Lighter border */
}
.styled-table {
@@ -84,17 +132,17 @@
}
.styled-table thead tr {
background-color: #f8f9fa; /* Light grey header */
background-color: #eef2f7; /* Lighter, slightly blueish header */
text-align: left;
font-weight: bold;
color: #2c3e50;
border-bottom: 2px solid #dee2e6;
font-weight: 600; /* Slightly bolder */
color: #495057; /* Darker grey text */
border-bottom: 1px solid #dee2e6; /* Thinner border */
}
.styled-table th,
.styled-table td {
padding: 12px 15px;
border-bottom: 1px solid #eee; /* Lighter border for rows */
padding: 14px 15px; /* Increased padding */
border-bottom: 1px solid #f1f3f5; /* Very light border */
vertical-align: middle;
}
@@ -103,7 +151,16 @@
}
.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 {
@@ -121,18 +178,20 @@
/* Style for the 'View Details' button */
.btn-view-details {
background-color: #667eea;
color: white;
border: none;
padding: 5px 10px;
background-color: #e9ecef; /* Light grey background */
color: #495057; /* Dark grey text */
border: 1px solid #dee2e6; /* Subtle border */
padding: 4px 8px; /* Slightly smaller padding */
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.3s ease;
transition: all 0.2s ease;
}
.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) */
@@ -204,10 +263,10 @@
.pagination .page-link {
display: block;
padding: 8px 12px;
color: #667eea;
padding: 8px 14px; /* Slightly wider */
color: #6c757d; /* Grey text */
background-color: white;
border: 1px solid #ddd;
border: 1px solid #dee2e6; /* Lighter border */
border-radius: 4px;
text-decoration: none;
transition: all 0.3s ease;
@@ -215,15 +274,15 @@
}
.pagination .page-link:hover {
background-color: #f0f0f0;
border-color: #ccc;
background-color: #e9ecef; /* Light grey hover */
border-color: #dee2e6;
}
.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;
border-color: #667eea;
font-weight: bold;
border-color: #708cf3;
font-weight: 600;
}
.pagination .page-item.disabled .page-link {
@@ -263,10 +322,10 @@
.detail-item p,
.detail-item pre {
background-color: #f8f9fa;
padding: 10px;
border-radius: 4px;
border: 1px solid #eee;
background-color: #e9ecef; /* Lighter background for code blocks */
padding: 12px;
border-radius: 5px;
border: 1px solid #dee2e6; /* Match border color */
font-size: 14px;
color: #333;
margin: 0;

View File

@@ -37,6 +37,12 @@ let currentPage = 1;
let pageSize = 20;
// 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 currentSearch = { // Store current search parameters
key: '',
error: '',
startDate: '',
endDate: ''
};
// DOM Elements Cache
let pageSizeSelector;
@@ -48,6 +54,11 @@ let noDataMessage;
let errorMessage;
let logDetailModal;
let modalCloseBtns; // Collection of close buttons for the modal
let keySearchInput;
let errorSearchInput;
let startDateInput;
let endDateInput;
let searchBtn;
// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
@@ -62,6 +73,11 @@ document.addEventListener('DOMContentLoaded', function() {
logDetailModal = document.getElementById('logDetailModal');
// Get all elements that should close the modal
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
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
if (logDetailModal && modalCloseBtns) {
modalCloseBtns.forEach(btn => {
@@ -112,7 +141,22 @@ async function loadErrorLogs() {
const offset = (currentPage - 1) * pageSize;
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) {
// Try to get error message from response body
let errorData;
@@ -162,9 +206,11 @@ function renderErrorLogs(logs) {
return;
}
logs.forEach(log => {
const row = document.createElement('tr');
const startIndex = (currentPage - 1) * pageSize; // Calculate starting index for the current page
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
let formattedTime = 'N/A';
try {
@@ -181,9 +227,16 @@ function renderErrorLogs(logs) {
// Truncate error log content for display
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 = `
<td>${log.id}</td>
<td>${log.gemini_key || ''}</td>
<td>${sequentialId}</td> <!-- Use sequential ID -->
<td title="${log.gemini_key || ''}">${maskedKey}</td>
<td>${log.error_type || '未知'}</td>
<td class="error-log-content" title="${log.error_log || ''}">${errorLogContent}</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('modalErrorType').textContent = log.error_type || '未知';
document.getElementById('modalErrorLog').textContent = log.error_log || '无';

View File

@@ -54,6 +54,18 @@
</button>
</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 -->
<table class="styled-table"> <!-- Use a custom table class -->
<thead>