mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-07-08 08:11:36 +08:00
feat(stats): 添加 API 调用详情查看功能
- 在 keys_status 页面添加了 API 调用统计卡片(1分钟/1小时/24小时)的可点击功能。 - 点击卡片会弹出一个模态框,显示对应时间段内的详细 API 调用记录,包括时间戳、部分 API 密钥、模型名称和调用状态(成功/失败)。 - 后端新增 `/api/stats/details` API 端点,用于根据请求的时间段('1m', '1h', '24h')从数据库查询并返回调用详情。 - 新增 `stats_service.get_api_call_details` 服务函数处理数据查询和格式化逻辑。 - 前端 `keys_status.js` 添加了 fetch 调用、模态框显示/隐藏以及数据渲染逻辑。 - 为 `keys_status` 页面添加了每 60 秒自动刷新的功能。 - 优化数据库连接配置,在 `create_engine` 中添加 `pool_pre_ping=True` 以提高连接可靠性。
This commit is contained in:
@@ -14,7 +14,8 @@ logger = get_database_logger()
|
||||
DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
|
||||
|
||||
# 创建数据库引擎
|
||||
engine = create_engine(DATABASE_URL)
|
||||
# pool_pre_ping=True: 在从连接池获取连接前执行简单的 "ping" 测试,确保连接有效
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
|
||||
# 创建元数据对象
|
||||
metadata = MetaData()
|
||||
@@ -23,7 +24,11 @@ metadata = MetaData()
|
||||
Base = declarative_base(metadata=metadata)
|
||||
|
||||
# 创建数据库连接池,并配置连接池参数
|
||||
# pool_recycle=3600: 回收空闲超过1小时的连接,防止MySQL服务器超时断开
|
||||
# min_size/max_size: 连接池的最小/最大连接数
|
||||
# pool_recycle=3600: 连接在池中允许存在的最大秒数(生命周期)。
|
||||
# 设置为 3600 秒(1小时),确保在 MySQL 默认的 wait_timeout (通常8小时) 或其他网络超时之前回收连接。
|
||||
# 如果遇到连接失效问题,可以尝试调低此值,使其小于实际的 wait_timeout 或网络超时时间。
|
||||
# databases 库会自动处理连接失效后的重连尝试。
|
||||
database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=3600)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 # 新增导入
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
from app.service.stats_service import get_api_usage_stats # <-- Import stats service
|
||||
from app.service.stats_service import get_api_usage_stats, get_api_call_details # <-- Import stats service and details function
|
||||
|
||||
logger = get_routes_logger()
|
||||
|
||||
@@ -38,6 +38,7 @@ def setup_routers(app: FastAPI) -> None:
|
||||
|
||||
# 添加健康检查路由
|
||||
setup_health_routes(app)
|
||||
setup_api_stats_routes(app) # Add API stats routes
|
||||
|
||||
|
||||
def setup_page_routes(app: FastAPI) -> None:
|
||||
@@ -158,3 +159,32 @@ def setup_health_routes(app: FastAPI) -> None:
|
||||
"""健康检查端点"""
|
||||
logger.info("Health check endpoint called")
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
def setup_api_stats_routes(app: FastAPI) -> None:
|
||||
"""
|
||||
设置 API 统计相关的路由
|
||||
|
||||
Args:
|
||||
app: FastAPI应用程序实例
|
||||
"""
|
||||
@app.get("/api/stats/details")
|
||||
async def api_stats_details(request: Request, period: str):
|
||||
"""获取指定时间段内的 API 调用详情"""
|
||||
try:
|
||||
# 验证认证
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to API stats details")
|
||||
# Returning JSON error instead of redirect for API endpoint
|
||||
return {"error": "Unauthorized"}, 401
|
||||
|
||||
logger.info(f"Fetching API call details for period: {period}")
|
||||
details = await get_api_call_details(period)
|
||||
return details
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}")
|
||||
return {"error": str(e)}, 400
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching API stats details for period {period}: {str(e)}")
|
||||
return {"error": "Internal server error"}, 500
|
||||
|
||||
@@ -66,4 +66,58 @@ async def get_api_usage_stats() -> dict:
|
||||
"calls_1h": 0,
|
||||
"calls_24h": 0,
|
||||
"calls_month": 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def get_api_call_details(period: str) -> list[dict]:
|
||||
"""
|
||||
获取指定时间段内的 API 调用详情
|
||||
|
||||
Args:
|
||||
period: 时间段标识 ('1m', '1h', '24h')
|
||||
|
||||
Returns:
|
||||
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
|
||||
|
||||
Raises:
|
||||
ValueError: 如果 period 无效
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
if period == '1m':
|
||||
start_time = now - datetime.timedelta(minutes=1)
|
||||
elif period == '1h':
|
||||
start_time = now - datetime.timedelta(hours=1)
|
||||
elif period == '24h':
|
||||
start_time = now - datetime.timedelta(hours=24)
|
||||
else:
|
||||
raise ValueError(f"无效的时间段标识: {period}")
|
||||
|
||||
try:
|
||||
query = select(
|
||||
RequestLog.request_time.label("timestamp"),
|
||||
RequestLog.api_key.label("key"),
|
||||
RequestLog.model_name.label("model"),
|
||||
RequestLog.status_code # We might need to map this to 'success'/'failure' later
|
||||
).where(
|
||||
RequestLog.request_time >= start_time
|
||||
).order_by(RequestLog.request_time.desc()) # Order by most recent first
|
||||
|
||||
results = await database.fetch_all(query)
|
||||
|
||||
# Convert results to list of dicts and map status_code
|
||||
details = []
|
||||
for row in results:
|
||||
status = 'success' if 200 <= row['status_code'] < 300 else 'failure'
|
||||
details.append({
|
||||
"timestamp": row['timestamp'].isoformat(), # Use ISO format for JS compatibility
|
||||
"key": row['key'],
|
||||
"model": row['model'],
|
||||
"status": status
|
||||
})
|
||||
logger.info(f"Retrieved {len(details)} API call details for period '{period}'")
|
||||
return details
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get API call details for period '{period}': {e}")
|
||||
# Re-raise the exception to be handled by the route
|
||||
raise
|
||||
@@ -460,6 +460,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// 初始加载时应用一次筛选
|
||||
filterValidKeys();
|
||||
}
|
||||
|
||||
// 添加自动刷新功能,每60秒刷新一次
|
||||
const autoRefreshInterval = 60000; // 60秒
|
||||
setInterval(() => {
|
||||
console.log('自动刷新 keys_status 页面...');
|
||||
location.reload();
|
||||
}, autoRefreshInterval);
|
||||
});
|
||||
|
||||
// Service Worker registration
|
||||
@@ -493,3 +500,117 @@ function toggleKeyVisibility(button) {
|
||||
button.title = '显示密钥';
|
||||
}
|
||||
}
|
||||
|
||||
// --- API 调用详情模态框逻辑 ---
|
||||
|
||||
// 显示 API 调用详情模态框
|
||||
async function showApiCallDetails(period) {
|
||||
const modal = document.getElementById('apiCallDetailsModal');
|
||||
const contentArea = document.getElementById('apiCallDetailsContent');
|
||||
const titleElement = document.getElementById('apiCallDetailsModalTitle');
|
||||
|
||||
if (!modal || !contentArea || !titleElement) {
|
||||
console.error('无法找到 API 调用详情模态框元素');
|
||||
showNotification('无法显示详情,页面元素缺失', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置标题
|
||||
let periodText = '';
|
||||
switch (period) {
|
||||
case '1m': periodText = '最近 1 分钟'; break;
|
||||
case '1h': periodText = '最近 1 小时'; break;
|
||||
case '24h': periodText = '最近 24 小时'; break;
|
||||
default: periodText = '指定时间段';
|
||||
}
|
||||
titleElement.textContent = `${periodText} API 调用详情`;
|
||||
|
||||
// 显示模态框并设置加载状态
|
||||
modal.classList.remove('hidden');
|
||||
contentArea.innerHTML = `
|
||||
<div class="text-center py-10">
|
||||
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
|
||||
<p class="text-gray-500 mt-2">加载中...</p>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
// 调用后端 API 获取数据
|
||||
const response = await fetch(`/api/stats/details?period=${period}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`服务器错误: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// 渲染数据
|
||||
renderApiCallDetails(data, contentArea);
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取 API 调用详情失败:', error);
|
||||
contentArea.innerHTML = `
|
||||
<div class="text-center py-10 text-danger-500">
|
||||
<i class="fas fa-exclamation-triangle text-3xl"></i>
|
||||
<p class="mt-2">加载失败: ${error.message}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭 API 调用详情模态框
|
||||
function closeApiCallDetailsModal() {
|
||||
const modal = document.getElementById('apiCallDetailsModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染 API 调用详情到模态框
|
||||
function renderApiCallDetails(data, container) {
|
||||
if (!data || data.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-10 text-gray-500">
|
||||
<i class="fas fa-info-circle text-3xl"></i>
|
||||
<p class="mt-2">该时间段内没有 API 调用记录。</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建表格
|
||||
let tableHtml = `
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">密钥 (部分)</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
`;
|
||||
|
||||
// 填充表格行
|
||||
data.forEach(call => {
|
||||
const timestamp = new Date(call.timestamp).toLocaleString();
|
||||
const keyDisplay = call.key ? `${call.key.substring(0, 4)}...${call.key.substring(call.key.length - 4)}` : 'N/A';
|
||||
const statusClass = call.status === 'success' ? 'text-success-600' : 'text-danger-600';
|
||||
const statusIcon = call.status === 'success' ? 'fa-check-circle' : 'fa-times-circle';
|
||||
|
||||
tableHtml += `
|
||||
<tr>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-700">${timestamp}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 font-mono">${keyDisplay}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500">${call.model || 'N/A'}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm ${statusClass}">
|
||||
<i class="fas ${statusIcon} mr-1"></i>
|
||||
${call.status}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tableHtml += `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
@@ -272,17 +272,17 @@
|
||||
<span class="text-xs text-gray-500">本月: {{ api_stats.calls_month }}</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item stat-warning" title="1分钟内API调用">
|
||||
<div class="stat-item stat-warning cursor-pointer hover:bg-amber-100" title="点击查看1分钟内调用详情" data-period="1m" onclick="showApiCallDetails('1m')">
|
||||
<div class="stat-value">{{ api_stats.calls_1m }}</div>
|
||||
<div class="stat-label">1分钟调用</div>
|
||||
<i class="stat-icon fas fa-stopwatch"></i>
|
||||
</div>
|
||||
<div class="stat-item stat-info" title="1小时内API调用">
|
||||
<div class="stat-item stat-info cursor-pointer hover:bg-blue-100" title="点击查看1小时内调用详情" data-period="1h" onclick="showApiCallDetails('1h')">
|
||||
<div class="stat-value">{{ api_stats.calls_1h }}</div>
|
||||
<div class="stat-label">1小时调用</div>
|
||||
<i class="stat-icon fas fa-hourglass-half"></i>
|
||||
</div>
|
||||
<div class="stat-item stat-primary" title="24小时内API调用">
|
||||
<div class="stat-item stat-primary cursor-pointer hover:bg-indigo-100" title="点击查看24小时内调用详情" data-period="24h" onclick="showApiCallDetails('24h')">
|
||||
<div class="stat-value">{{ api_stats.calls_24h }}</div>
|
||||
<div class="stat-label">24小时调用</div>
|
||||
<i class="stat-icon fas fa-calendar-day"></i>
|
||||
@@ -484,6 +484,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 调用详情模态框 -->
|
||||
<div id="apiCallDetailsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg p-6 shadow-xl max-w-3xl w-full animate-fade-in"> <!-- Increased max-width -->
|
||||
<div class="flex items-center justify-between mb-4 border-b pb-3">
|
||||
<h3 class="text-xl font-semibold text-gray-800" id="apiCallDetailsModalTitle">API 调用详情</h3>
|
||||
<button onclick="closeApiCallDetailsModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none text-xl">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="apiCallDetailsContent" class="mb-6 max-h-[60vh] overflow-y-auto pr-2"> <!-- Increased max-height and added padding-right -->
|
||||
<!-- 详细数据将加载到这里 -->
|
||||
<div class="text-center py-10">
|
||||
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
|
||||
<p class="text-gray-500 mt-2">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4 border-t">
|
||||
<button onclick="closeApiCallDetailsModal()" class="px-5 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg transition-colors text-sm font-medium">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer is now in base.html -->
|
||||
|
||||
|
||||
Reference in New Issue
Block a user