mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-05-06 20:32:47 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || '无';
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user