diff --git a/app/database/connection.py b/app/database/connection.py index 061ff1a..cfa77e0 100644 --- a/app/database/connection.py +++ b/app/database/connection.py @@ -3,6 +3,7 @@ """ from databases import Database from sqlalchemy import create_engine, MetaData +# from sqlalchemy.orm import sessionmaker # 不再需要 from sqlalchemy.ext.declarative import declarative_base from app.config.config import settings @@ -31,7 +32,9 @@ Base = declarative_base(metadata=metadata) # databases 库会自动处理连接失效后的重连尝试。 database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=1800) # Reduced recycle time to 30 mins +# 移除了 SessionLocal 和 get_db 函数 +# --- Async connection functions for lifespan/async routes --- async def connect_to_db(): """ 连接到数据库 diff --git a/app/database/services.py b/app/database/services.py index 0f760d8..5ae8509 100644 --- a/app/database/services.py +++ b/app/database/services.py @@ -1,14 +1,12 @@ """ 数据库服务模块 """ +from typing import List, Optional, Dict, Any, Union +from datetime import datetime +from sqlalchemy import func, desc, asc, select, insert, update, delete import json -from typing import Dict, List, Optional, Any, Union -from datetime import datetime # Keep this import - -from sqlalchemy import select, insert, update, func - from app.database.connection import database -from app.database.models import Settings, ErrorLog, RequestLog # Import RequestLog +from app.database.models import Settings, ErrorLog, RequestLog from app.log.logger import get_database_logger logger = get_database_logger() @@ -157,12 +155,14 @@ 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 + error_code_search: Optional[str] = None, start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None + end_date: Optional[datetime] = None, + sort_by: str = 'id', # 新增排序字段 + sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc') ) -> List[Dict[str, Any]]: """ - 获取错误日志,支持搜索和日期过滤 + 获取错误日志,支持搜索、日期过滤和排序 Args: limit (int): 限制数量 @@ -172,6 +172,8 @@ async def get_error_logs( error_code_search (Optional[str]): 错误码搜索词 (精确匹配) start_date (Optional[datetime]): 开始日期时间 end_date (Optional[datetime]): 结束日期时间 + sort_by (str): 排序字段 (例如 'id', 'request_time') + sort_order (str): 排序顺序 ('asc' or 'desc') Returns: List[Dict[str, Any]]: 错误日志列表 @@ -212,9 +214,16 @@ async def get_error_logs( # 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) - + # 添加排序逻辑 + sort_column = getattr(ErrorLog, sort_by, ErrorLog.id) # 获取排序字段,默认为 id + if sort_order.lower() == 'asc': + query = query.order_by(asc(sort_column)) + else: + query = query.order_by(desc(sort_column)) + + # Apply limit and offset + query = query.limit(limit).offset(offset) + result = await database.fetch_all(query) return [dict(row) for row in result] except Exception as e: @@ -306,6 +315,68 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]: logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}") raise +# --- 异步删除函数 (使用 databases 库) --- + +async def delete_error_logs_by_ids(log_ids: List[int]) -> int: + """ + 根据提供的 ID 列表批量删除错误日志 (异步)。 + + Args: + log_ids: 要删除的错误日志 ID 列表。 + + Returns: + int: 实际删除的日志数量。 + """ + if not log_ids: + return 0 + try: + # 使用 databases 执行删除 + query = delete(ErrorLog).where(ErrorLog.id.in_(log_ids)) + # execute 返回受影响的行数,但 databases 库的 execute 不直接返回 rowcount + # 我们需要先查询是否存在,或者依赖数据库约束/触发器(如果适用) + # 或者,我们可以执行删除并假设成功,除非抛出异常 + # 为了简单起见,我们执行删除并记录日志,不精确返回删除数量 + # 如果需要精确数量,需要先执行 SELECT COUNT(*) + await database.execute(query) + # 注意:databases 的 execute 不返回 rowcount,所以我们不能直接返回删除的数量 + # 返回 log_ids 的长度作为尝试删除的数量,或者返回 0/1 表示操作尝试 + logger.info(f"Attempted bulk deletion for error logs with IDs: {log_ids}") + return len(log_ids) # 返回尝试删除的数量 + except Exception as e: + # 数据库连接或执行错误 + logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True) + raise # Re-raise the exception for the router to handle + +async def delete_error_log_by_id(log_id: int) -> bool: + """ + 根据 ID 删除单个错误日志 (异步)。 + + Args: + log_id: 要删除的错误日志 ID。 + + Returns: + bool: 如果成功删除返回 True,否则返回 False。 + """ + try: + # 先检查是否存在 (可选,但更明确) + check_query = select(ErrorLog.id).where(ErrorLog.id == log_id) + exists = await database.fetch_one(check_query) + + if not exists: + logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}") + return False # 或者可以抛出 404 异常,由路由处理 + + # 执行删除 + delete_query = delete(ErrorLog).where(ErrorLog.id == log_id) + await database.execute(delete_query) + logger.info(f"Successfully deleted error log with ID: {log_id}") + return True + except Exception as e: + logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True) + raise # Re-raise the exception for the router to handle + +# --- RequestLog Services (保持异步) --- + # 新增函数:添加请求日志 async def add_request_log( model_name: Optional[str], diff --git a/app/router/error_log_routes.py b/app/router/error_log_routes.py index f586e15..92bc36f 100644 --- a/app/router/error_log_routes.py +++ b/app/router/error_log_routes.py @@ -1,15 +1,22 @@ """ 日志路由模块 """ -from typing import List, Optional +from typing import List, Optional, Dict from datetime import datetime from pydantic import BaseModel -from fastapi import APIRouter, HTTPException, Request, Query, Path +from fastapi import APIRouter, HTTPException, Request, Query, Path, Body, Response, status 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, get_error_logs_count, get_error_log_details +from app.database.services import ( + get_error_logs, + get_error_logs_count, + get_error_log_details, + delete_error_logs_by_ids, # 新增导入 + delete_error_log_by_id # 新增导入 +) +# Removed get_db import comment as it's fully removed now # 创建路由 router = APIRouter(prefix="/api/logs", tags=["logs"]) @@ -40,10 +47,12 @@ async def get_error_logs_api( 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") + end_date: Optional[datetime] = Query(None, description="End datetime for filtering"), + sort_by: str = Query('id', description="Field to sort by (e.g., 'id', 'request_time')"), # 新增排序参数 + sort_order: str = Query('desc', description="Sort order ('asc' or 'desc')") # 新增排序参数 ): """ - 获取错误日志列表 (返回错误码) + 获取错误日志列表 (返回错误码),支持过滤和排序 Args: request: 请求对象 @@ -54,6 +63,8 @@ async def get_error_logs_api( error_code_search: 错误码搜索 start_date: 开始日期 end_date: 结束日期 + sort_by: 排序字段 + sort_order: 排序顺序 Returns: ErrorLogListResponse: An object containing the list of logs (with error_code) and the total count. @@ -75,6 +86,8 @@ async def get_error_logs_api( error_code_search=error_code_search, # Pass error code search to DB function start_date=start_date, end_date=end_date, + sort_by=sort_by, # 传递排序参数 + sort_order=sort_order # 传递排序参数 ) # Fetch total count with the same search parameters total_count = await get_error_logs_count( @@ -126,3 +139,63 @@ async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge= except Exception as e: logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to get error log details: {str(e)}") + + +# 新增:批量删除错误日志 +@router.delete("/errors", status_code=status.HTTP_204_NO_CONTENT) +async def delete_error_logs_bulk_api( + request: Request, + payload: Dict[str, List[int]] = Body(...) # Expects {"ids": [1, 2, 3]} + # Ensure db dependency is fully removed +): + """ + 批量删除错误日志 (异步) + """ + auth_token = request.cookies.get("auth_token") + if not auth_token or not verify_auth_token(auth_token): + logger.warning("Unauthorized access attempt to bulk delete error logs") + raise HTTPException(status_code=401, detail="Not authenticated") + + log_ids = payload.get("ids") + if not log_ids: + raise HTTPException(status_code=400, detail="No log IDs provided for deletion.") + + try: + # 调用异步服务函数 + deleted_count = await delete_error_logs_by_ids(log_ids) + # 注意:异步函数返回的是尝试删除的数量,可能不是精确值 + logger.info(f"Attempted bulk deletion for {deleted_count} error logs with IDs: {log_ids}") + return Response(status_code=status.HTTP_204_NO_CONTENT) + except Exception as e: + logger.exception(f"Error bulk deleting error logs with IDs {log_ids}: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error during bulk deletion") + + +# 新增:删除单个错误日志 +@router.delete("/errors/{log_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_error_log_api( + request: Request, + log_id: int = Path(..., ge=1) + # Ensure db dependency is fully removed +): + """ + 删除单个错误日志 (异步) + """ + auth_token = request.cookies.get("auth_token") + if not auth_token or not verify_auth_token(auth_token): + logger.warning(f"Unauthorized access attempt to delete error log ID: {log_id}") + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + # 调用异步服务函数 + success = await delete_error_log_by_id(log_id) + if not success: + # 服务层现在在未找到时返回 False,我们在这里转换为 404 + raise HTTPException(status_code=404, detail=f"Error log with ID {log_id} not found") + logger.info(f"Successfully deleted error log with ID: {log_id}") + return Response(status_code=status.HTTP_204_NO_CONTENT) + except HTTPException as http_exc: + raise http_exc # Re-raise 404 or other HTTP exceptions + except Exception as e: + logger.exception(f"Error deleting error log with ID {log_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error during deletion") diff --git a/app/static/js/error_logs.js b/app/static/js/error_logs.js index 20f8d6f..64d13e0 100644 --- a/app/static/js/error_logs.js +++ b/app/static/js/error_logs.js @@ -17,6 +17,10 @@ let currentPage = 1; let pageSize = 10; // 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 currentSort = { // 新增:存储当前排序状态 + field: 'id', // 默认按 ID 排序 + order: 'desc' // 默认降序 +}; let currentSearch = { // Store current search parameters key: '', error: '', @@ -45,7 +49,16 @@ let pageInput; let goToPageBtn; let selectAllCheckbox; // 新增:全选复选框 let copySelectedKeysBtn; // 新增:复制选中按钮 +let deleteSelectedBtn; // 新增:批量删除按钮 +let sortByIdHeader; // 新增:ID 排序表头 +let sortIcon; // 新增:排序图标 let selectedCountSpan; // 新增:选中计数显示 +let deleteConfirmModal; // 新增:删除确认模态框 +let closeDeleteConfirmModalBtn; // 新增:关闭删除模态框按钮 +let cancelDeleteBtn; // 新增:取消删除按钮 +let confirmDeleteBtn; // 新增:确认删除按钮 +let deleteConfirmMessage; // 新增:删除确认消息元素 +let idsToDeleteGlobally = []; // 新增:存储待删除的ID // 页面加载完成后执行 document.addEventListener('DOMContentLoaded', function() { @@ -70,7 +83,17 @@ document.addEventListener('DOMContentLoaded', function() { goToPageBtn = document.getElementById('goToPageBtn'); selectAllCheckbox = document.getElementById('selectAllCheckbox'); // 新增 copySelectedKeysBtn = document.getElementById('copySelectedKeysBtn'); // 新增 + deleteSelectedBtn = document.getElementById('deleteSelectedBtn'); // 新增 + sortByIdHeader = document.getElementById('sortById'); // 新增 + if (sortByIdHeader) { + sortIcon = sortByIdHeader.querySelector('i'); // 新增 + } selectedCountSpan = document.getElementById('selectedCount'); // 新增 + deleteConfirmModal = document.getElementById('deleteConfirmModal'); // 新增 + closeDeleteConfirmModalBtn = document.getElementById('closeDeleteConfirmModalBtn'); // 新增 + cancelDeleteBtn = document.getElementById('cancelDeleteBtn'); // 新增 + confirmDeleteBtn = document.getElementById('confirmDeleteBtn'); // 新增 + deleteConfirmMessage = document.getElementById('deleteConfirmMessage'); // 新增 // Initialize page size selector if (pageSizeSelector) { @@ -145,8 +168,63 @@ document.addEventListener('DOMContentLoaded', function() { } }); } + + // 新增:为批量删除按钮添加事件监听器 + if (deleteSelectedBtn) { + deleteSelectedBtn.addEventListener('click', handleDeleteSelected); + } + + // 新增:为 ID 排序表头添加事件监听器 + if (sortByIdHeader) { + sortByIdHeader.addEventListener('click', handleSortById); + } + + // 新增:为删除确认模态框按钮添加事件监听器 + if (closeDeleteConfirmModalBtn) { + closeDeleteConfirmModalBtn.addEventListener('click', hideDeleteConfirmModal); + } + if (cancelDeleteBtn) { + cancelDeleteBtn.addEventListener('click', hideDeleteConfirmModal); + } + if (confirmDeleteBtn) { + confirmDeleteBtn.addEventListener('click', handleConfirmDelete); + } + // Optional: Close modal if clicking outside the content + if (deleteConfirmModal) { + deleteConfirmModal.addEventListener('click', function(event) { + if (event.target === deleteConfirmModal) { + hideDeleteConfirmModal(); + } + }); + } }); +// 新增:显示删除确认模态框 +function showDeleteConfirmModal(message) { + if (deleteConfirmModal && deleteConfirmMessage) { + deleteConfirmMessage.textContent = message; + deleteConfirmModal.classList.add('show'); + document.body.style.overflow = 'hidden'; // Prevent body scrolling + } +} + +// 新增:隐藏删除确认模态框 +function hideDeleteConfirmModal() { + if (deleteConfirmModal) { + deleteConfirmModal.classList.remove('show'); + document.body.style.overflow = ''; // Restore body scrolling + idsToDeleteGlobally = []; // 清空待删除ID + } +} + +// 新增:处理确认删除按钮点击 +function handleConfirmDelete() { + if (idsToDeleteGlobally.length > 0) { + performActualDelete(idsToDeleteGlobally); + } + hideDeleteConfirmModal(); // 关闭模态框 +} + // Fallback copy function using document.execCommand function fallbackCopyTextToClipboard(text) { const textArea = document.createElement("textarea"); @@ -280,6 +358,13 @@ function setupBulkSelectionListeners() { if (copySelectedKeysBtn) { copySelectedKeysBtn.addEventListener('click', handleCopySelectedKeys); } + + // 新增:为批量删除按钮添加事件监听器 (如果尚未添加) + // 通常在 DOMContentLoaded 中添加一次即可 + // if (deleteSelectedBtn && !deleteSelectedBtn.hasListener) { + // deleteSelectedBtn.addEventListener('click', handleDeleteSelected); + // deleteSelectedBtn.hasListener = true; // 标记已添加 + // } } // 新增:处理“全选”复选框变化的函数 @@ -313,6 +398,11 @@ function updateSelectedState() { // 可选:根据选中项数量更新按钮标题属性 copySelectedKeysBtn.setAttribute('title', `复制${selectedCount}项选中密钥`); } + // 新增:更新批量删除按钮的禁用状态 + if (deleteSelectedBtn) { + deleteSelectedBtn.disabled = selectedCount === 0; + deleteSelectedBtn.setAttribute('title', `删除${selectedCount}项选中日志`); + } // 更新“全选”复选框的状态 if (selectAllCheckbox) { @@ -369,6 +459,113 @@ function copyTextToClipboard(text, buttonElement = null) { } } +// 修改:处理批量删除按钮点击的函数 - 改为显示模态框 +function handleDeleteSelected() { + const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked'); + const logIdsToDelete = []; + selectedCheckboxes.forEach(checkbox => { + const logId = checkbox.getAttribute('data-log-id'); // 需要在渲染时添加 data-log-id + if (logId) { + logIdsToDelete.push(parseInt(logId)); + } + }); + + if (logIdsToDelete.length === 0) { + showNotification('没有选中的日志可删除', 'warning'); + return; + } + + if (logIdsToDelete.length === 0) { + showNotification('没有选中的日志可删除', 'warning'); + return; + } + + // 存储待删除ID并显示模态框 + idsToDeleteGlobally = logIdsToDelete; + const message = `确定要删除选中的 ${logIdsToDelete.length} 条日志吗?此操作不可恢复!`; + showDeleteConfirmModal(message); +} + +// 新增:执行实际的删除操作(提取自原 handleDeleteSelected 和 handleDeleteLogRow) +async function performActualDelete(logIds) { + if (!logIds || logIds.length === 0) return; + + const isSingleDelete = logIds.length === 1; + const url = isSingleDelete ? `/api/logs/errors/${logIds[0]}` : '/api/logs/errors'; + const method = 'DELETE'; + const body = isSingleDelete ? null : JSON.stringify({ ids: logIds }); + const headers = isSingleDelete ? {} : { 'Content-Type': 'application/json' }; + + try { + // Rename 'response' to 'deleteResponse' and remove duplicate fetch + const deleteResponse = await fetch(url, { + method: method, + headers: headers, + body: body, + }); + // Removed duplicate fetch call below + + if (!deleteResponse.ok) { + let errorData; + try { errorData = await deleteResponse.json(); } catch (e) { /* ignore */ } + const actionText = isSingleDelete ? `删除该条日志` : `批量删除 ${logIds.length} 条日志`; + throw new Error(errorData?.detail || `${actionText}失败: ${deleteResponse.statusText}`); + } + + const successMessage = isSingleDelete ? `成功删除该日志` : `成功删除 ${logIds.length} 条日志`; + showNotification(successMessage, 'success'); + // 取消全选 + if (selectAllCheckbox) selectAllCheckbox.checked = false; + // 重新加载当前页数据 + loadErrorLogs(); + } catch (error) { + console.error('批量删除错误日志失败:', error); + showNotification(`批量删除失败: ${error.message}`, 'error', 5000); + } +} + +// 修改:处理单行删除按钮点击的函数 - 改为显示模态框 +function handleDeleteLogRow(logId) { + if (!logId) return; + + // 存储待删除ID并显示模态框 + idsToDeleteGlobally = [parseInt(logId)]; // 存储为数组 + // 使用通用确认消息,不显示具体ID + const message = `确定要删除这条日志吗?此操作不可恢复!`; + showDeleteConfirmModal(message); +} + +// 新增:处理 ID 排序点击的函数 +function handleSortById() { + if (currentSort.field === 'id') { + // 如果当前是按 ID 排序,切换顺序 + currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc'; + } else { + // 如果当前不是按 ID 排序,切换到按 ID 排序,默认为降序 + currentSort.field = 'id'; + currentSort.order = 'desc'; + } + // 更新图标 + updateSortIcon(); + // 重新加载第一页数据 + currentPage = 1; + loadErrorLogs(); +} + +// 新增:更新排序图标的函数 +function updateSortIcon() { + if (!sortIcon) return; + // 移除所有可能的排序类 + sortIcon.classList.remove('fa-sort', 'fa-sort-up', 'fa-sort-down', 'text-gray-400', 'text-primary-600'); + + if (currentSort.field === 'id') { + sortIcon.classList.add(currentSort.order === 'asc' ? 'fa-sort-up' : 'fa-sort-down'); + sortIcon.classList.add('text-primary-600'); // 高亮显示 + } else { + // 如果不是按 ID 排序,显示默认图标 + sortIcon.classList.add('fa-sort', 'text-gray-400'); + } +} // 加载错误日志数据 async function loadErrorLogs() { @@ -384,8 +581,12 @@ async function loadErrorLogs() { const offset = (currentPage - 1) * pageSize; try { - // Construct the API URL with search parameters + // Construct the API URL with search and sort parameters let apiUrl = `/api/logs/errors?limit=${pageSize}&offset=${offset}`; + // 添加排序参数 + apiUrl += `&sort_by=${currentSort.field}&sort_order=${currentSort.order}`; + + // 添加搜索参数 if (currentSearch.key) { apiUrl += `&key_search=${encodeURIComponent(currentSearch.key)}`; } @@ -484,9 +685,9 @@ function renderErrorLogs(logs) { row.innerHTML = ` - + - ${sequentialId} + ${sequentialId} ${maskedKey} @@ -499,8 +700,11 @@ function renderErrorLogs(logs) { ${log.model_name || '未知'} ${formattedTime} - + `; @@ -516,6 +720,14 @@ function renderErrorLogs(logs) { }); }); + // 新增:为新渲染的删除按钮添加事件监听器 + document.querySelectorAll('.btn-delete-row').forEach(button => { + button.addEventListener('click', function() { + const logId = this.getAttribute('data-log-id'); + handleDeleteLogRow(logId); + }); + }); + // Re-initialize copy buttons specifically for the newly rendered table rows setupCopyButtons('#errorLogsTable'); // Update selected state after rendering diff --git a/app/templates/error_logs.html b/app/templates/error_logs.html index b6bb111..6a45c2e 100644 --- a/app/templates/error_logs.html +++ b/app/templates/error_logs.html @@ -49,27 +49,24 @@ } /* 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; + input[type="text"], input[type="datetime-local"], select, button { + height: 36px !important; } - /* 为搜索按钮添加额外的左边距 */ - #searchBtn { - margin-left: 1rem !important; + /* 日期选择器样式优化 */ + .date-range-container { + display: flex; + align-items: center; + gap: 0.5rem; + } + + /* 确保所有输入框在小屏幕上正确显示 */ + @media (max-width: 640px) { + input[type="datetime-local"] { + min-width: 0; + width: 100%; + } } {% endblock %} @@ -107,21 +104,34 @@ -
- - - -
- - - +
+ +
+ + + + +
+
+ + +
+
+ + +
+
-
- - +
@@ -134,13 +144,15 @@ - ID + + ID + Gemini密钥 错误类型 错误码 模型名称 请求时间 - 操作 + 操作 @@ -274,6 +286,22 @@
+ + {% endblock %} {% block body_scripts %}