From 7b4652c8023a2771c07330378b4c67c20785f0ca Mon Sep 17 00:00:00 2001 From: snaily Date: Fri, 11 Apr 2025 14:45:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(monitoring):=20=E6=B7=BB=E5=8A=A0=20API=20?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E7=BB=9F=E8=AE=A1=E5=92=8C=E7=9B=91=E6=8E=A7?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交引入了 API 请求统计功能,并将原“密钥状态”页面重构为功能更全面的“监控面板”。 主要变更包括: - **数据库与服务层:** - 新增 `RequestLog` 数据模型 (`app/database/models.py`),用于存储 API 请求的详细信息(时间、模型、密钥、成功状态、状态码、耗时)。 - 在 `app/database/services.py` 中添加 `add_request_log` 和 `get_request_stats` 函数,分别用于记录单次请求和获取时间窗口内的统计数据。 - 新增 `app/service/stats_service.py`,封装了获取 API 调用统计逻辑。 - **API 请求日志记录:** - 在 Gemini (`gemini_chat_service.py`) 和 OpenAI (`openai_chat_service.py`) 聊天服务中,于 API 调用前后添加了 `add_request_log` 调用,以记录请求的成功与否及耗时。 - **前端监控面板:** - 将 `/keys` 路由对应的页面 (`keys_status.html`) 从“密钥状态”重构为“监控面板”。 - 页面顶部新增统计卡片区域,展示: - 密钥统计:总数、有效数、无效数。 - API 调用统计:1分钟内、1小时内、24小时内、本月调用次数。 - 密钥列表(有效/无效)采用响应式网格布局 (`grid`),并增加了悬停动效和边框高亮。 - 优化了有效密钥列表的筛选逻辑,在无匹配项时显示提示信息。 - 为新的统计卡片和列表项添加了相应的 CSS 样式。 - 更新了 `keys_status.js` 以支持筛选无结果时的提示。 - **路由与导航:** - 在 `app/router/routes.py` 中添加了 `/stats` 端点,用于获取 API 统计数据。 - 更新了 `config_editor.html` 和 `error_logs.html` 中的导航链接,使其指向新的“监控面板”。 - **日志配置:** - 在 `app/log/logger.py` 中,为 `sqlalchemy.exc` 设置了 WARNING 日志级别。 这些更改旨在提供更好的系统可观测性,方便用户监控 API 密钥状态和请求频率。 --- app/database/models.py | 20 +- app/database/services.py | 53 +++- app/log/logger.py | 4 + app/router/routes.py | 21 +- app/service/chat/gemini_chat_service.py | 194 +++++++++----- app/service/chat/openai_chat_service.py | 218 ++++++++++------ app/service/stats_service.py | 69 +++++ app/static/js/config_editor.js | 26 +- app/static/js/keys_status.js | 210 ++++++++++----- app/templates/config_editor.html | 10 +- app/templates/error_logs.html | 4 +- app/templates/keys_status.html | 333 +++++++++++++++++++++--- 12 files changed, 879 insertions(+), 283 deletions(-) create mode 100644 app/service/stats_service.py diff --git a/app/database/models.py b/app/database/models.py index 162e3cb..d0073a1 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -2,7 +2,7 @@ 数据库模型模块 """ import datetime -from sqlalchemy import Column, Integer, String, Text, DateTime, JSON +from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Boolean # 添加 Boolean from app.database.connection import Base @@ -41,3 +41,21 @@ class ErrorLog(Base): def __repr__(self): return f"" + +# 新增 RequestLog 模型 +class RequestLog(Base): + """ + API 请求日志表 + """ + __tablename__ = "t_request_log" + + id = Column(Integer, primary_key=True, autoincrement=True) + request_time = Column(DateTime, default=datetime.datetime.now, comment="请求时间") + model_name = Column(String(100), nullable=True, comment="模型名称") + api_key = Column(String(100), nullable=True, comment="使用的API密钥") # 考虑安全性,后续可优化 + is_success = Column(Boolean, nullable=False, comment="请求是否成功") + status_code = Column(Integer, nullable=True, comment="API响应状态码") + latency_ms = Column(Integer, nullable=True, comment="请求耗时(毫秒)") + + def __repr__(self): + return f"" diff --git a/app/database/services.py b/app/database/services.py index b14a814..40cd6f2 100644 --- a/app/database/services.py +++ b/app/database/services.py @@ -3,12 +3,12 @@ """ import json from typing import Dict, List, Optional, Any, Union -from datetime import datetime +from datetime import datetime # Keep this import -from sqlalchemy import select, insert, update, func # Import func for COUNT +from sqlalchemy import select, insert, update, func from app.database.connection import database -from app.database.models import Settings, ErrorLog +from app.database.models import Settings, ErrorLog, RequestLog # Import RequestLog from app.log.logger import get_database_logger logger = get_database_logger() @@ -73,7 +73,7 @@ async def update_setting(key: str, value: str, description: Optional[str] = None .values( value=value, description=description if description else setting["description"], - updated_at=datetime.datetime.now() + updated_at=datetime.now() # Use datetime.now() ) ) await database.execute(query) @@ -87,8 +87,8 @@ async def update_setting(key: str, value: str, description: Optional[str] = None key=key, value=value, description=description, - created_at=datetime.datetime.now(), - updated_at=datetime.datetime.now() + created_at=datetime.now(), # Use datetime.now() + updated_at=datetime.now() # Use datetime.now() ) ) await database.execute(query) @@ -241,3 +241,44 @@ async def get_error_logs_count( except Exception as e: logger.exception(f"Failed to count error logs with filters: {str(e)}") # Use exception for stack trace raise + +# 新增函数:添加请求日志 +async def add_request_log( + model_name: Optional[str], + api_key: Optional[str], + is_success: bool, + status_code: Optional[int] = None, + latency_ms: Optional[int] = None, + request_time: Optional[datetime] = None +) -> bool: + """ + 添加 API 请求日志 + + Args: + model_name: 模型名称 + api_key: 使用的 API 密钥 + is_success: 请求是否成功 + status_code: API 响应状态码 + latency_ms: 请求耗时(毫秒) + request_time: 请求发生时间 (如果为 None, 则使用当前时间) + + Returns: + bool: 是否添加成功 + """ + try: + log_time = request_time if request_time else datetime.now() + + query = insert(RequestLog).values( + request_time=log_time, + model_name=model_name, + api_key=api_key, + is_success=is_success, + status_code=status_code, + latency_ms=latency_ms + ) + await database.execute(query) + # logger.debug(f"Added request log: key={api_key[:4]}..., success={is_success}, model={model_name}") # Use debug level + return True + except Exception as e: + logger.error(f"Failed to add request log: {str(e)}") + return False diff --git a/app/log/logger.py b/app/log/logger.py index bf72872..d105589 100644 --- a/app/log/logger.py +++ b/app/log/logger.py @@ -169,3 +169,7 @@ def get_database_logger(): def get_log_routes_logger(): return Logger.setup_logger("log_routes") + + +def get_stats_logger(): + return Logger.setup_logger("stats") \ No newline at end of file diff --git a/app/router/routes.py b/app/router/routes.py index 68712dc..8051094 100644 --- a/app/router/routes.py +++ b/app/router/routes.py @@ -10,6 +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 logger = get_routes_logger() @@ -86,19 +87,31 @@ def setup_page_routes(app: FastAPI) -> None: key_manager = await get_key_manager_instance() keys_status = await key_manager.get_keys_by_status() - total = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"]) - logger.info(f"Keys status retrieved successfully. Total keys: {total}") + total_keys = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"]) + valid_key_count = len(keys_status["valid_keys"]) + invalid_key_count = len(keys_status["invalid_keys"]) + + # Get API usage stats + api_stats = await get_api_usage_stats() + logger.info(f"API stats retrieved: {api_stats}") + + logger.info(f"Keys status retrieved successfully. Total keys: {total_keys}") return templates.TemplateResponse( "keys_status.html", { "request": request, "valid_keys": keys_status["valid_keys"], "invalid_keys": keys_status["invalid_keys"], - "total": total, + "total_keys": total_keys, # Renamed for clarity + "valid_key_count": valid_key_count, # Added count + "invalid_key_count": invalid_key_count, # Added count + "api_stats": api_stats, # <-- Pass stats to template }, ) except Exception as e: - logger.error(f"Error retrieving keys status: {str(e)}") + logger.error(f"Error retrieving keys status or API stats: {str(e)}") + # Optionally, render template with error or default stats + # For now, re-raise to show error page raise @app.get("/config", response_class=HTMLResponse) diff --git a/app/service/chat/gemini_chat_service.py b/app/service/chat/gemini_chat_service.py index 844af4f..5cc883c 100644 --- a/app/service/chat/gemini_chat_service.py +++ b/app/service/chat/gemini_chat_service.py @@ -2,8 +2,9 @@ import json import re +import datetime # Add datetime import +import time # Add time import from typing import Any, AsyncGenerator, Dict, List - from app.config.config import settings from app.domain.gemini_models import GeminiRequest from app.handler.response_handler import GeminiResponseHandler @@ -11,7 +12,7 @@ from app.handler.stream_optimizer import gemini_optimizer from app.log.logger import get_gemini_logger from app.service.client.api_client import GeminiApiClient from app.service.key.key_manager import KeyManager -from app.database.services import add_error_log +from app.database.services import add_error_log, add_request_log # Import add_request_log logger = get_gemini_logger() @@ -147,27 +148,54 @@ class GeminiChatService: ) -> Dict[str, Any]: """生成内容""" payload = _build_payload(model, request) + start_time = time.perf_counter() + request_datetime = datetime.datetime.now() # Record request time + is_success = False + status_code = None + response = None + try: response = await self.api_client.generate_content(payload, model, api_key) + # Assuming success if no exception is raised and response is received + # The actual status code might be within the response structure or headers, + # but api_client doesn't seem to expose it directly here. + # We'll assume 200 for success if no exception. + is_success = True + status_code = 200 # Assume 200 on success return self.response_handler.handle_response(response, model, stream=False) except Exception as e: - logger.error(f"Normal API call failed with error: {str(e)}") - error_code = None + is_success = False error_log_msg = str(e) - # 尝试从异常消息中解析状态码 + logger.error(f"Normal API call failed with error: {error_log_msg}") + # Try to parse status code from exception match = re.search(r"status code (\d+)", error_log_msg) if match: - error_code = int(match.group(1)) - + status_code = int(match.group(1)) + else: + status_code = 500 # Default to 500 if parsing fails + + # Log error to error log table await add_error_log( gemini_key=api_key, model_name=model, error_type="gemini_chat_service", error_log=error_log_msg, - error_code=error_code, + error_code=status_code, request_msg=payload ) - raise e # 重新抛出异常,以便上层处理 + raise e # Re-throw exception for upstream handling + finally: + end_time = time.perf_counter() + latency_ms = int((end_time - start_time) * 1000) + # Log request to request log table + await add_request_log( + model_name=model, + api_key=api_key, + is_success=is_success, + status_code=status_code, + latency_ms=latency_ms, + request_time=request_datetime + ) async def stream_generate_content( self, model: str, request: GeminiRequest, api_key: str @@ -176,60 +204,96 @@ class GeminiChatService: retries = 0 max_retries = settings.MAX_RETRIES payload = _build_payload(model, request) - while retries < max_retries: - try: - async for line in self.api_client.stream_generate_content( - payload, model, api_key - ): - # print(line) - if line.startswith("data:"): - line = line[6:] - response_data = self.response_handler.handle_response( - json.loads(line), model, stream=True - ) - text = self._extract_text_from_response(response_data) - # 如果有文本内容,且开启了流式输出优化器,则使用流式输出优化器处理 - if text and settings.STREAM_OPTIMIZER_ENABLED: - # 使用流式输出优化器处理文本输出 - async for ( - optimized_chunk - ) in gemini_optimizer.optimize_stream_output( - text, - lambda t: self._create_char_response(response_data, t), - lambda c: "data: " + json.dumps(c) + "\n\n", - ): - yield optimized_chunk - else: - # 如果没有文本内容(如工具调用等),整块输出 - yield "data: " + json.dumps(response_data) + "\n\n" - logger.info("Streaming completed successfully") - break - except Exception as e: - retries += 1 - error_log_msg = str(e) - logger.warning( - f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}" - ) - # 解析错误信息并记录到数据库 - error_code = None - match = re.search(r"status code (\d+)", error_log_msg) - if match: - error_code = int(match.group(1)) - - await add_error_log( - gemini_key=api_key, - model_name=model, - error_log=error_log_msg, - error_code=error_code, - request_msg=payload - ) - - # 尝试切换 API Key - api_key = await self.key_manager.handle_api_failure(api_key,retries) - if api_key: - logger.info(f"Switched to new API key: {api_key}") - if retries >= max_retries: - logger.error( - f"Max retries ({max_retries}) reached for streaming. Raising error" + start_time = time.perf_counter() # Record start time before loop + request_datetime = datetime.datetime.now() + is_success = False + status_code = None + final_api_key = api_key # Store the initial key + + try: + while retries < max_retries: + current_attempt_key = api_key # Key used for this attempt + final_api_key = current_attempt_key # Update final key used + try: + async for line in self.api_client.stream_generate_content( + payload, model, current_attempt_key + ): + # print(line) + if line.startswith("data:"): + line = line[6:] + response_data = self.response_handler.handle_response( + json.loads(line), model, stream=True + ) + text = self._extract_text_from_response(response_data) + # 如果有文本内容,且开启了流式输出优化器,则使用流式输出优化器处理 + if text and settings.STREAM_OPTIMIZER_ENABLED: + # 使用流式输出优化器处理文本输出 + async for ( + optimized_chunk + ) in gemini_optimizer.optimize_stream_output( + text, + lambda t: self._create_char_response(response_data, t), + lambda c: "data: " + json.dumps(c) + "\n\n", + ): + yield optimized_chunk + else: + # 如果没有文本内容(如工具调用等),整块输出 + yield "data: " + json.dumps(response_data) + "\n\n" + logger.info("Streaming completed successfully") + is_success = True + status_code = 200 # Assume 200 on success + break # Exit loop on success + except Exception as e: + retries += 1 + is_success = False # Mark as failed for this attempt + error_log_msg = str(e) + logger.warning( + f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}" ) - break + # Parse error code for logging + match = re.search(r"status code (\d+)", error_log_msg) + if match: + status_code = int(match.group(1)) + else: + status_code = 500 # Default if parsing fails + + # Log error to error log table + await add_error_log( + gemini_key=current_attempt_key, # Log key used for this failed attempt + model_name=model, + error_log=error_log_msg, + error_code=status_code, + request_msg=payload + ) + + # Attempt to switch API Key + api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries) + if api_key: + logger.info(f"Switched to new API key: {api_key}") + else: # No more keys or retries exceeded by handle_api_failure logic + logger.error(f"No valid API key available after {retries} retries.") + break # Exit loop if no key available + + if retries >= max_retries: + logger.error( + f"Max retries ({max_retries}) reached for streaming." + ) + break # Exit loop after max retries + finally: + # Log the final outcome of the streaming request + end_time = time.perf_counter() + latency_ms = int((end_time - start_time) * 1000) + await add_request_log( + model_name=model, + api_key=final_api_key, # Log the last key used + is_success=is_success, # Log the final success status + status_code=status_code, # Log the last known status code + latency_ms=latency_ms, # Log total time including retries + request_time=request_datetime + ) + # If the loop finished due to failure, ensure an exception is raised if not already handled + if not is_success and retries >= max_retries: + # We need to raise an exception here if the loop exited due to max retries failure + # However, the original code structure doesn't explicitly raise here after the loop. + # For now, we just log. Consider raising HTTPException if needed. + pass diff --git a/app/service/chat/openai_chat_service.py b/app/service/chat/openai_chat_service.py index a217255..7d5c455 100644 --- a/app/service/chat/openai_chat_service.py +++ b/app/service/chat/openai_chat_service.py @@ -2,6 +2,8 @@ import json import re +import datetime # Add datetime import +import time # Add time import from copy import deepcopy from typing import Any, AsyncGenerator, Dict, List, Optional, Union @@ -14,7 +16,7 @@ from app.log.logger import get_openai_logger from app.service.client.api_client import GeminiApiClient from app.service.image.image_create_service import ImageCreateService from app.service.key.key_manager import KeyManager -from app.database.services import add_error_log +from app.database.services import add_error_log, add_request_log # Import add_request_log logger = get_openai_logger() @@ -191,28 +193,49 @@ class OpenAIChatService: self, model: str, payload: Dict[str, Any], api_key: str ) -> Dict[str, Any]: """处理普通聊天完成""" + start_time = time.perf_counter() + request_datetime = datetime.datetime.now() + is_success = False + status_code = None + response = None try: response = await self.api_client.generate_content(payload, model, api_key) + is_success = True + status_code = 200 # Assume 200 on success return self.response_handler.handle_response( response, model, stream=False, finish_reason="stop" ) except Exception as e: - logger.error(f"Normal API call failed with error: {str(e)}") - error_code = None + is_success = False error_log_msg = str(e) - # 尝试从异常消息中解析状态码 + logger.error(f"Normal API call failed with error: {error_log_msg}") + # Try to parse status code from exception match = re.search(r"status code (\d+)", error_log_msg) if match: - error_code = int(match.group(1)) - + status_code = int(match.group(1)) + else: + status_code = 500 # Default if parsing fails + await add_error_log( - gemini_key=api_key, + gemini_key=api_key, # Note: Parameter name is gemini_key in add_error_log model_name=model, + error_type="openai_chat_service", # Indicate service type error_log=error_log_msg, - error_code=error_code, + error_code=status_code, request_msg=payload ) - raise e # 重新抛出异常,以便上层处理 + raise e # Re-throw exception + finally: + end_time = time.perf_counter() + latency_ms = int((end_time - start_time) * 1000) + await add_request_log( + model_name=model, + api_key=api_key, + is_success=is_success, + status_code=status_code, + latency_ms=latency_ms, + request_time=request_datetime + ) async def _handle_stream_completion( self, model: str, payload: Dict[str, Any], api_key: str @@ -220,77 +243,114 @@ class OpenAIChatService: """处理流式聊天完成,添加重试逻辑""" retries = 0 max_retries = settings.MAX_RETRIES - while retries < max_retries: - try: - tool_call_flag = False - async for line in self.api_client.stream_generate_content( - payload, model, api_key - ): - # print(line) - if line.startswith("data:"): - chunk = json.loads(line[6:]) - openai_chunk = self.response_handler.handle_response( - chunk, model, stream=True, finish_reason=None - ) - if openai_chunk: - # 提取文本内容 - text = self._extract_text_from_openai_chunk(openai_chunk) - if text and settings.STREAM_OPTIMIZER_ENABLED: - # 使用流式输出优化器处理文本输出 - async for ( - optimized_chunk - ) in openai_optimizer.optimize_stream_output( - text, - lambda t: self._create_char_openai_chunk( - openai_chunk, t - ), - lambda c: f"data: {json.dumps(c)}\n\n", - ): - yield optimized_chunk - else: - # 如果没有文本内容(如工具调用等),整块输出 - if "tool_calls" in json.dumps(openai_chunk): - tool_call_flag = True - yield f"data: {json.dumps(openai_chunk)}\n\n" - if tool_call_flag: - yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n" - else: - yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n" - yield "data: [DONE]\n\n" - logger.info("Streaming completed successfully") - break # 成功后退出循环 - except Exception as e: - retries += 1 - error_log_msg = str(e) - logger.warning( - f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}" - ) - # 解析错误信息并记录到数据库 - error_code = None - match = re.search(r"status code (\d+)", error_log_msg) - if match: - error_code = int(match.group(1)) - print(model) - await add_error_log( - gemini_key=api_key, - model_name=model, - error_type="openai_chat_service", - error_log=error_log_msg, - error_code=error_code, - request_msg=payload - ) - - # 尝试切换 API Key - api_key = await self.key_manager.handle_api_failure(api_key,retries) - if api_key: - logger.info(f"Switched to new API key: {api_key}") - if retries >= max_retries: - logger.error( - f"Max retries ({max_retries}) reached for streaming. Raising error" - ) - yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n" + start_time = time.perf_counter() # Record start time before loop + request_datetime = datetime.datetime.now() + is_success = False + status_code = None + final_api_key = api_key # Store the initial key + + try: + while retries < max_retries: + current_attempt_key = api_key # Key used for this attempt + final_api_key = current_attempt_key # Update final key used + try: + tool_call_flag = False + async for line in self.api_client.stream_generate_content( + payload, model, current_attempt_key + ): + # print(line) + if line.startswith("data:"): + chunk = json.loads(line[6:]) + openai_chunk = self.response_handler.handle_response( + chunk, model, stream=True, finish_reason=None + ) + if openai_chunk: + # 提取文本内容 + text = self._extract_text_from_openai_chunk(openai_chunk) + if text and settings.STREAM_OPTIMIZER_ENABLED: + # 使用流式输出优化器处理文本输出 + async for ( + optimized_chunk + ) in openai_optimizer.optimize_stream_output( + text, + lambda t: self._create_char_openai_chunk( + openai_chunk, t + ), + lambda c: f"data: {json.dumps(c)}\n\n", + ): + yield optimized_chunk + else: + # 如果没有文本内容(如工具调用等),整块输出 + if "tool_calls" in json.dumps(openai_chunk): + tool_call_flag = True + yield f"data: {json.dumps(openai_chunk)}\n\n" + if tool_call_flag: + yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n" + else: + yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n" yield "data: [DONE]\n\n" - break + logger.info("Streaming completed successfully") + is_success = True + status_code = 200 # Assume 200 on success + break # 成功后退出循环 + except Exception as e: + retries += 1 + is_success = False # Mark as failed for this attempt + error_log_msg = str(e) + logger.warning( + f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}" + ) + # Parse error code for logging + match = re.search(r"status code (\d+)", error_log_msg) + if match: + status_code = int(match.group(1)) + else: + status_code = 500 # Default if parsing fails + + # Log error to error log table + await add_error_log( + gemini_key=current_attempt_key, # Note: Parameter name is gemini_key + model_name=model, + error_type="openai_chat_service", # Indicate service type + error_log=error_log_msg, + error_code=status_code, + request_msg=payload + ) + + # Attempt to switch API Key + # Ensure key_manager is available (might need adjustment if not always passed) + if self.key_manager: + api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries) + if api_key: + logger.info(f"Switched to new API key: {api_key}") + else: + logger.error(f"No valid API key available after {retries} retries.") + break # Exit loop if no key available + else: + logger.error("KeyManager not available for retry logic.") + break # Exit loop if key manager is missing + + if retries >= max_retries: + logger.error( + f"Max retries ({max_retries}) reached for streaming." + ) + break # Exit loop after max retries + finally: + # Log the final outcome of the streaming request + end_time = time.perf_counter() + latency_ms = int((end_time - start_time) * 1000) + await add_request_log( + model_name=model, + api_key=final_api_key, # Log the last key used + is_success=is_success, # Log the final success status + status_code=status_code, # Log the last known status code + latency_ms=latency_ms, # Log total time including retries + request_time=request_datetime + ) + # If the loop finished due to failure, yield error and DONE + if not is_success and retries >= max_retries: + yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n" + yield "data: [DONE]\n\n" async def create_image_chat_completion( self, diff --git a/app/service/stats_service.py b/app/service/stats_service.py new file mode 100644 index 0000000..1e67ffb --- /dev/null +++ b/app/service/stats_service.py @@ -0,0 +1,69 @@ +# app/service/stats_service.py + +import datetime +from sqlalchemy import select, func + +from app.database.connection import database +from app.database.models import RequestLog +from app.log.logger import get_stats_logger + +logger = get_stats_logger() + +async def get_calls_in_last_seconds(seconds: int) -> int: + """获取过去 N 秒内的调用次数 (包括成功和失败)""" + try: + cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds) + query = select(func.count(RequestLog.id)).where( + RequestLog.request_time >= cutoff_time + ) + count_result = await database.fetch_one(query) + return count_result[0] if count_result else 0 + except Exception as e: + logger.error(f"Failed to get calls in last {seconds} seconds: {e}") + return 0 # Return 0 on error + +async def get_calls_in_last_minutes(minutes: int) -> int: + """获取过去 N 分钟内的调用次数 (包括成功和失败)""" + return await get_calls_in_last_seconds(minutes * 60) + +async def get_calls_in_last_hours(hours: int) -> int: + """获取过去 N 小时内的调用次数 (包括成功和失败)""" + return await get_calls_in_last_seconds(hours * 3600) + +async def get_calls_in_current_month() -> int: + """获取当前自然月内的调用次数 (包括成功和失败)""" + try: + now = datetime.datetime.now() + start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + query = select(func.count(RequestLog.id)).where( + RequestLog.request_time >= start_of_month + ) + count_result = await database.fetch_one(query) + return count_result[0] if count_result else 0 + except Exception as e: + logger.error(f"Failed to get calls in current month: {e}") + return 0 # Return 0 on error + +async def get_api_usage_stats() -> dict: + """获取所有需要的 API 使用统计数据""" + try: + calls_1m = await get_calls_in_last_minutes(1) + calls_1h = await get_calls_in_last_hours(1) + calls_24h = await get_calls_in_last_hours(24) + calls_month = await get_calls_in_current_month() + + return { + "calls_1m": calls_1m, + "calls_1h": calls_1h, + "calls_24h": calls_24h, + "calls_month": calls_month, + } + except Exception as e: + logger.error(f"Failed to get API usage stats: {e}") + # Return default values on error + return { + "calls_1m": 0, + "calls_1h": 0, + "calls_24h": 0, + "calls_month": 0, + } \ No newline at end of file diff --git a/app/static/js/config_editor.js b/app/static/js/config_editor.js index 0513062..7d310f4 100644 --- a/app/static/js/config_editor.js +++ b/app/static/js/config_editor.js @@ -469,15 +469,7 @@ async function saveConfig() { const result = await response.json(); - // 显示保存状态 - const saveStatus = document.getElementById('saveStatus'); - saveStatus.style.opacity = "1"; - saveStatus.style.transform = "translate(-50%, -50%) scale(1.1)"; - - setTimeout(() => { - saveStatus.style.opacity = "0"; - saveStatus.style.transform = "translate(-50%, -50%) scale(0.95)"; - }, 3000); + // 移除居中的 saveStatus 提示 showNotification('配置保存成功', 'success'); @@ -488,21 +480,7 @@ async function saveConfig() { console.error('保存配置失败:', error); // 保存失败时,也尝试重启定时任务,以防万一 await startScheduler(); - // 显示错误状态 - const saveStatus = document.getElementById('saveStatus'); - saveStatus.style.backgroundColor = "#ef4444"; // 红色背景 - saveStatus.style.opacity = "1"; - saveStatus.style.transform = "translate(-50%, -50%) scale(1.1)"; - saveStatus.querySelector('.status-icon i').className = 'fas fa-times-circle'; - saveStatus.querySelector('.status-text').textContent = '配置保存失败'; - - setTimeout(() => { - saveStatus.style.opacity = "0"; - saveStatus.style.transform = "translate(-50%, -50%) scale(0.95)"; - setTimeout(() => { - saveStatus.style.backgroundColor = "#22c55e"; // 恢复绿色背景 - }, 300); - }, 3000); + // 移除居中的 saveStatus 提示 showNotification('保存配置失败: ' + error.message, 'error'); } diff --git a/app/static/js/keys_status.js b/app/static/js/keys_status.js index 412d5ac..b00e601 100644 --- a/app/static/js/keys_status.js +++ b/app/static/js/keys_status.js @@ -1,3 +1,5 @@ +// 统计数据可视化交互效果 + function copyToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text); @@ -25,11 +27,35 @@ function copyToClipboard(text) { } } +// 添加统计项动画效果 +function initStatItemAnimations() { + const statItems = document.querySelectorAll('.stat-item'); + statItems.forEach(item => { + item.addEventListener('mouseenter', () => { + item.style.transform = 'scale(1.05)'; + const icon = item.querySelector('.stat-icon'); + if (icon) { + icon.style.opacity = '0.2'; + icon.style.transform = 'scale(1.1) rotate(0deg)'; + } + }); + + item.addEventListener('mouseleave', () => { + item.style.transform = ''; + const icon = item.querySelector('.stat-icon'); + if (icon) { + icon.style.opacity = ''; + icon.style.transform = ''; + } + }); + }); +} + function copyKeys(type) { const keys = Array.from(document.querySelectorAll(`#${type}Keys .key-text`)).map(span => span.dataset.fullKey); if (keys.length === 0) { - showCopyStatus('没有可复制的密钥', true); + showNotification('没有可复制的密钥', 'error'); return; } @@ -37,48 +63,26 @@ function copyKeys(type) { copyToClipboard(keysText) .then(() => { - showCopyStatus(`已成功复制${keys.length}个${type === 'valid' ? '有效' : '无效'}密钥到剪贴板`); + showNotification(`已成功复制${keys.length}个${type === 'valid' ? '有效' : '无效'}密钥`); }) .catch((err) => { console.error('无法复制文本: ', err); - showCopyStatus('复制失败,请重试', true); + showNotification('复制失败,请重试', 'error'); }); } function copyKey(key) { copyToClipboard(key) .then(() => { - showCopyStatus(`已成功复制密钥到剪贴板`); + showNotification(`已成功复制密钥`); }) .catch((err) => { console.error('无法复制文本: ', err); - showCopyStatus('复制失败,请重试', true); + showNotification('复制失败,请重试', 'error'); }); } -function showCopyStatus(message, isError = false) { - const statusElement = document.getElementById('copyStatus'); - statusElement.textContent = message; - - // 添加适当的样式类 - if (isError) { - statusElement.classList.add('bg-danger-500'); - statusElement.classList.remove('bg-black'); - } else { - statusElement.classList.remove('bg-danger-500'); - statusElement.classList.add('bg-black'); - } - - // 应用过渡效果 - statusElement.style.opacity = "1"; - statusElement.style.transform = "translate(-50%, 0)"; - - // 设置自动消失 - setTimeout(() => { - statusElement.style.opacity = "0"; - statusElement.style.transform = "translate(-50%, 10px)"; - }, 3000); -} +// 移除 showCopyStatus 函数,因为它已被 showNotification 替代 async function verifyKey(key, button) { try { @@ -140,25 +144,38 @@ async function resetKeyFailCount(key, button) { // 根据重置结果更新UI if (data.success) { - showCopyStatus('失败计数重置成功'); + showNotification('失败计数重置成功'); + // 成功时保留绿色背景一会儿 button.style.backgroundColor = '#27ae60'; - setTimeout(() => location.reload(), 1500); + // 稍后刷新页面 + setTimeout(() => location.reload(), 1000); } else { const errorMsg = data.message || '重置失败'; - showCopyStatus('重置失败: ' + errorMsg, true); + showNotification('重置失败: ' + errorMsg, 'error'); + // 失败时保留红色背景一会儿 button.style.backgroundColor = '#e74c3c'; } - // 1秒后恢复按钮原始状态 - setTimeout(() => { - button.innerHTML = originalHtml; - button.disabled = false; - button.style.backgroundColor = ''; - }, 1000); + // 立即恢复按钮状态,除非成功或失败时需要短暂显示颜色 + if (!data.success) { + // 如果失败,1秒后恢复按钮 + setTimeout(() => { + button.innerHTML = originalHtml; + button.disabled = false; + button.style.backgroundColor = ''; + }, 1000); + } else { + // 如果成功,在刷新前恢复按钮(虽然用户可能看不到) + button.innerHTML = originalHtml; + button.disabled = false; + // 背景色会在刷新时重置 + } } catch (error) { console.error('重置失败:', error); - showCopyStatus('重置请求失败: ' + error.message, true); + showNotification('重置请求失败: ' + error.message, 'error'); + // 确保在捕获到错误时恢复按钮状态 + button.innerHTML = originalHtml; // 需要确保 originalHtml 在此作用域可用 button.disabled = false; button.innerHTML = ' 重置'; } @@ -280,11 +297,9 @@ async function executeResetAll(type) { console.error('API请求失败:', fetchError); showResultModal(false, '批量重置请求失败: ' + fetchError.message); } finally { - // 恢复按钮原始状态 - setTimeout(() => { - resetButton.innerHTML = originalHtml; - resetButton.disabled = false; - }, 500); + // 立即恢复按钮状态 + resetButton.innerHTML = originalHtml; + resetButton.disabled = false; } } catch (error) { console.error('批量重置失败:', error); @@ -315,12 +330,40 @@ function refreshPage(button) { }, 300); } +// 重写切换区域显示/隐藏函数,以更好地支持新样式 function toggleSection(header, sectionId) { const toggleIcon = header.querySelector('.toggle-icon'); const content = header.nextElementSibling; - toggleIcon.classList.toggle('collapsed'); - content.classList.toggle('collapsed'); + if (toggleIcon && content) { + // 添加旋转动画 + toggleIcon.classList.toggle('collapsed'); + + // 控制内容区域的可见性 + if (!content.classList.contains('collapsed')) { + // 收起内容 + content.style.maxHeight = '0px'; + content.style.opacity = '0'; + content.style.overflow = 'hidden'; + content.classList.add('collapsed'); + + // 为动画添加延迟 + setTimeout(() => { + content.style.padding = '0'; + }, 100); + } else { + // 展开内容 + content.classList.remove('collapsed'); + content.style.padding = '1rem'; + content.style.maxHeight = '2000px'; // 使用足够大的高度 + content.style.opacity = '1'; + + // 为动画添加延迟 + setTimeout(() => { + content.style.overflow = 'visible'; + }, 300); + } + } } // 筛选有效密钥(根据失败次数阈值) @@ -344,28 +387,77 @@ function filterValidKeys() { // 初始化 document.addEventListener('DOMContentLoaded', () => { - // 移除对滚动按钮显示的控制,让它们由HTML/CSS控制 + // 初始化统计区块动画 + initStatItemAnimations(); - // 监听展开/折叠事件 - document.querySelectorAll('.key-list h2').forEach(header => { - header.addEventListener('click', () => { - // 不再调用updateScrollButtons + // 添加数字滚动动画效果 + const animateCounters = () => { + const statValues = document.querySelectorAll('.stat-value'); + statValues.forEach(valueElement => { + const finalValue = parseInt(valueElement.textContent, 10); + if (!isNaN(finalValue)) { + // 保存原始值以便稍后恢复 + if (!valueElement.dataset.originalValue) { + valueElement.dataset.originalValue = valueElement.textContent; + } + + // 数字滚动动画 + let startValue = 0; + const duration = 1500; + const startTime = performance.now(); + + const updateCounter = (currentTime) => { + const elapsedTime = currentTime - startTime; + if (elapsedTime < duration) { + const progress = elapsedTime / duration; + // 使用缓动函数使动画更自然 + const easeOutValue = 1 - Math.pow(1 - progress, 3); + const currentValue = Math.floor(easeOutValue * finalValue); + valueElement.textContent = currentValue; + requestAnimationFrame(updateCounter); + } else { + // 恢复为原始值,以确保准确性 + valueElement.textContent = valueElement.dataset.originalValue; + } + }; + + requestAnimationFrame(updateCounter); + } + }); + }; + + // 在页面加载后启动数字动画 + setTimeout(animateCounters, 300); + + // 添加卡片悬停效果 + document.querySelectorAll('.stats-card').forEach(card => { + card.addEventListener('mouseenter', () => { + card.classList.add('shadow-lg'); + card.style.transform = 'translateY(-2px)'; + }); + + card.addEventListener('mouseleave', () => { + card.classList.remove('shadow-lg'); + card.style.transform = ''; }); }); - - // 更新版权年份 - const copyrightYearElement = document.querySelector('.copyright script'); - if (copyrightYearElement && copyrightYearElement.parentNode.classList.contains('copyright')) { - // 确保只更新版权部分的年份 - copyrightYearElement.textContent = new Date().getFullYear(); - } - + + // 监听展开/折叠事件 + document.querySelectorAll('.stats-card-title').forEach(header => { + header.addEventListener('click', () => { + const card = header.closest('.stats-card'); + if (card) { + card.classList.toggle('active'); + } + }); + }); + // 添加筛选输入框事件监听 const thresholdInput = document.getElementById('failCountThreshold'); if (thresholdInput) { // 使用 'input' 事件实时响应输入变化 thresholdInput.addEventListener('input', filterValidKeys); - // 初始加载时应用一次筛选(基于默认值1) + // 初始加载时应用一次筛选 filterValidKeys(); } }); diff --git a/app/templates/config_editor.html b/app/templates/config_editor.html index 3c47c6b..6026618 100644 --- a/app/templates/config_editor.html +++ b/app/templates/config_editor.html @@ -54,7 +54,7 @@

Gemini Balance Logo - Gemini Balance + Gemini Balance - 配置编辑

@@ -63,7 +63,7 @@ 配置编辑 - 密钥状态 + 监控面板 错误日志 @@ -89,11 +89,7 @@ - -
- - 配置已保存 -
+
diff --git a/app/templates/error_logs.html b/app/templates/error_logs.html index c24f283..2b80c98 100644 --- a/app/templates/error_logs.html +++ b/app/templates/error_logs.html @@ -58,7 +58,7 @@

Gemini Balance Logo - Gemini Balance + Gemini Balance - 错误日志

@@ -67,7 +67,7 @@ 配置编辑
- 密钥状态 + 监控面板 错误日志 diff --git a/app/templates/keys_status.html b/app/templates/keys_status.html index d10ec73..fd1d2ca 100644 --- a/app/templates/keys_status.html +++ b/app/templates/keys_status.html @@ -20,6 +20,185 @@ transform: rotate(-90deg); } /* Copy status styling is handled by base.html's notification */ + + /* 现代数据统计样式 */ + .stats-dashboard { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + margin-bottom: 2rem; + position: relative; + z-index: 10; + } + + @media (min-width: 768px) { + .stats-dashboard { + grid-template-columns: 1fr 1fr; + } + } + + /* 统计卡片样式 */ + .stats-card { + background-color: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-radius: 0.75rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + border: 1px solid rgba(255, 255, 255, 0.4); + overflow: hidden; + transition: all 0.3s ease-in-out; + } + + .stats-card:hover { + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + } + + .stats-card-header { + background-color: rgba(255, 255, 255, 0.3); + padding: 0.75rem 1rem; + border-bottom: 1px solid rgba(243, 244, 246, 0.5); + display: flex; + align-items: center; + justify-content: space-between; + } + + .stats-card-title { + display: flex; + align-items: center; + font-size: 1rem; + font-weight: 500; + color: #374151; + } + + .stats-card-title i { + margin-right: 0.5rem; + color: #4F46E5; + } + + .stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + padding: 0.75rem; + } + + /* 统计项样式 */ + .stat-item { + padding: 0.75rem; + border-radius: 0.5rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + transition: all 0.3s ease-in-out; + position: relative; + overflow: hidden; + } + + .stat-item::before { + content: ''; + position: absolute; + inset: 0; + opacity: 0.05; + background-color: currentColor; + z-index: 0; + transition: opacity 0.3s ease-in-out; + } + + .stat-item:hover::before { + opacity: 0.1; + } + + .stat-item:hover { + transform: scale(1.05); + } + + .stat-value { + font-size: 1.5rem; + font-weight: 700; + z-index: 10; + position: relative; + color: #1F2937; + } + + .stat-label { + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 0.25rem; + z-index: 10; + position: relative; + color: #6B7280; + } + + .stat-icon { + position: absolute; + right: 0.5rem; + bottom: 0.25rem; + opacity: 0.1; + font-size: 1.875rem; + transform: rotate(12deg); + transition: all 0.3s ease-in-out; + } + + .stat-item:hover .stat-icon { + opacity: 0.2; + transform: scale(1.1) rotate(0deg); + } + + /* 统计类型样式 */ + .stat-primary { + color: #4F46E5; + background-color: rgba(238, 242, 255, 0.5); + } + + .stat-success { + color: #10B981; + background-color: rgba(236, 253, 245, 0.5); + } + + .stat-danger { + color: #EF4444; + background-color: rgba(254, 242, 242, 0.5); + } + + .stat-warning { + color: #F59E0B; + background-color: rgba(255, 251, 235, 0.5); + } + + .stat-info { + color: #3B82F6; + background-color: rgba(239, 246, 255, 0.5); + } + + /* 响应式调整 */ + @media (max-width: 640px) { + .stats-dashboard { + gap: 1rem; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + padding: 0.5rem; + } + + .stat-item { + padding: 0.5rem; + } + + .stat-value { + font-size: 1.25rem; + } + + .stat-label { + font-size: 0.625rem; + } + } + {% endblock %} @@ -29,40 +208,99 @@ {% endblock %} {% block content %} -
+
- +

Gemini Balance Logo - Gemini Balance + Gemini Balance - 监控面板

- +
+ + +
+ +
+
+

+ + 密钥统计 +

+ 总计: {{ total_keys }} +
+
+
+
{{ total_keys }}
+
总密钥数
+ +
+
+
{{ valid_key_count }}
+
有效密钥
+ +
+
+
{{ invalid_key_count }}
+
无效密钥
+ +
+
+
+ + +
+
+

+ + API调用统计 +

+ 本月: {{ api_stats.calls_month }} +
+
+
+
{{ api_stats.calls_1m }}
+
1分钟调用
+ +
+
+
{{ api_stats.calls_1h }}
+
1小时调用
+ +
+
+
{{ api_stats.calls_24h }}
+
24小时调用
+ +
+
+
+
-
-
+
+
- -

有效密钥

+ +

有效密钥列表 ({{ valid_key_count }})

- +
@@ -78,10 +316,11 @@
-
    - {% for key, fail_count in valid_keys.items() %} -
  • -
    +
      + {% if valid_keys %} + {% for key, fail_count in valid_keys.items() %} +
    • +
      有效 @@ -97,7 +336,7 @@ 失败: {{ fail_count }}
      -
      +
    • {% endfor %} + {% else %} +
    • 暂无有效密钥
    • + {% endif %}
-
-
+
+
- -

无效密钥

+ +

无效密钥列表 ({{ invalid_key_count }})

-
    - {% for key, fail_count in invalid_keys.items() %} -
  • -
    +
      + {% if invalid_keys %} + {% for key, fail_count in invalid_keys.items() %} +
    • +
      无效 @@ -158,7 +401,7 @@ 失败: {{ fail_count }}
      -
      +
    • {% endfor %} + {% else %} +
    • 暂无无效密钥
    • + {% endif %}
- - -
-
- 总密钥数:{{ total }} -
-
+ +
@@ -261,14 +502,34 @@ if (isNaN(threshold)) return; // Do nothing if input is not a number const keys = validKeysList.querySelectorAll('li'); + let visibleCount = 0; keys.forEach(keyItem => { - const failCount = parseInt(keyItem.getAttribute('data-fail-count'), 10); - if (failCount >= threshold) { - keyItem.style.display = ''; // Show item - } else { - keyItem.style.display = 'none'; // Hide item + // Check if it's a key item (has data-fail-count) before processing + if (keyItem.hasAttribute('data-fail-count')) { + const failCount = parseInt(keyItem.getAttribute('data-fail-count'), 10); + if (failCount >= threshold) { + keyItem.style.display = ''; // Show item + visibleCount++; + } else { + keyItem.style.display = 'none'; // Hide item + } } }); + // Optional: Show a message if no keys match the filter + const noMatchMsgId = 'no-valid-keys-msg'; + let noMatchMsg = validKeysList.querySelector(`#${noMatchMsgId}`); + if (visibleCount === 0 && keys.length > 0) { // Only show if there were keys initially + if (!noMatchMsg) { + noMatchMsg = document.createElement('li'); + noMatchMsg.id = noMatchMsgId; + noMatchMsg.className = 'text-center text-gray-500 py-4'; + noMatchMsg.textContent = '没有符合条件的有效密钥'; + validKeysList.appendChild(noMatchMsg); + } + noMatchMsg.style.display = ''; + } else if (noMatchMsg) { + noMatchMsg.style.display = 'none'; + } } if (thresholdInput && validKeysList) {