mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-07-21 04:22:16 +08:00
feat(monitoring): 添加 API 请求统计和监控面板
本次提交引入了 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 密钥状态和请求频率。
This commit is contained in:
@@ -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"<ErrorLog(id='{self.id}', gemini_key='{self.gemini_key}')>"
|
||||
|
||||
# 新增 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"<RequestLog(id='{self.id}', key='{self.api_key[:4]}...', success='{self.is_success}')>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
69
app/service/stats_service.py
Normal file
69
app/service/stats_service.py
Normal file
@@ -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,
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 = '<i class="fas fa-redo-alt"></i> 重置';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
|
||||
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
|
||||
Gemini Balance
|
||||
Gemini Balance - 配置编辑
|
||||
</h1>
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
@@ -63,7 +63,7 @@
|
||||
<i class="fas fa-cog"></i> 配置编辑
|
||||
</a>
|
||||
<a href="/keys" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
||||
<i class="fas fa-key"></i> 密钥状态
|
||||
<i class="fas fa-tachometer-alt"></i> 监控面板
|
||||
</a>
|
||||
<a href="/logs" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
||||
<i class="fas fa-exclamation-triangle"></i> 错误日志
|
||||
@@ -89,11 +89,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Save Status Banner -->
|
||||
<div class="save-status fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-green-500 text-white px-8 py-4 rounded-xl font-medium flex items-center gap-3 shadow-xl z-50 opacity-0 transition-all duration-300 scale-105" id="saveStatus">
|
||||
<span class="status-icon text-xl"><i class="fas fa-check-circle"></i></span>
|
||||
<span class="status-text text-lg">配置已保存</span>
|
||||
</div>
|
||||
<!-- Save Status Banner (Removed - using notification component now) -->
|
||||
|
||||
<!-- Configuration Form -->
|
||||
<form id="configForm" class="mt-6">
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
|
||||
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
|
||||
Gemini Balance
|
||||
Gemini Balance - 错误日志
|
||||
</h1>
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
@@ -67,7 +67,7 @@
|
||||
<i class="fas fa-cog"></i> 配置编辑
|
||||
</a>
|
||||
<a href="/keys" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
||||
<i class="fas fa-key"></i> 密钥状态
|
||||
<i class="fas fa-tachometer-alt"></i> 监控面板
|
||||
</a>
|
||||
<a href="/logs" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-primary-600 text-white shadow-md">
|
||||
<i class="fas fa-exclamation-triangle"></i> 错误日志
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,40 +208,99 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container max-w-4xl mx-auto px-4">
|
||||
<div class="container max-w-6xl mx-auto px-4"> <!-- Increased max-width -->
|
||||
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
|
||||
<button class="absolute top-6 right-6 bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300" onclick="refreshPage(this)">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
|
||||
|
||||
<h1 class="text-3xl font-extrabold text-center text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-700 mb-4">
|
||||
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
|
||||
Gemini Balance
|
||||
Gemini Balance - 监控面板
|
||||
</h1>
|
||||
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="flex justify-center mb-8 overflow-x-auto pb-2 gap-2">
|
||||
<a href="/config" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
||||
<i class="fas fa-cog"></i> 配置编辑
|
||||
</a>
|
||||
<a href="/keys" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-primary-600 text-white shadow-md">
|
||||
<i class="fas fa-key"></i> 密钥状态
|
||||
<i class="fas fa-tachometer-alt"></i> 监控面板
|
||||
</a>
|
||||
<a href="/logs" class="whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg bg-white bg-opacity-50 hover:bg-opacity-70 text-gray-700 transition-all duration-200">
|
||||
<i class="fas fa-exclamation-triangle"></i> 错误日志
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 现代化统计面板 -->
|
||||
<div class="stats-dashboard animate-fade-in" style="animation-delay: 0.1s">
|
||||
<!-- 密钥统计卡片 -->
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-header">
|
||||
<h3 class="stats-card-title">
|
||||
<i class="fas fa-key"></i>
|
||||
<span>密钥统计</span>
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500">总计: {{ total_keys }}</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item stat-primary" title="总密钥数">
|
||||
<div class="stat-value">{{ total_keys }}</div>
|
||||
<div class="stat-label">总密钥数</div>
|
||||
<i class="stat-icon fas fa-key"></i>
|
||||
</div>
|
||||
<div class="stat-item stat-success" title="有效密钥">
|
||||
<div class="stat-value">{{ valid_key_count }}</div>
|
||||
<div class="stat-label">有效密钥</div>
|
||||
<i class="stat-icon fas fa-check-circle"></i>
|
||||
</div>
|
||||
<div class="stat-item stat-danger" title="无效密钥">
|
||||
<div class="stat-value">{{ invalid_key_count }}</div>
|
||||
<div class="stat-label">无效密钥</div>
|
||||
<i class="stat-icon fas fa-times-circle"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API调用统计卡片 -->
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-header">
|
||||
<h3 class="stats-card-title">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<span>API调用统计</span>
|
||||
</h3>
|
||||
<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-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-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-value">{{ api_stats.calls_24h }}</div>
|
||||
<div class="stat-label">24小时调用</div>
|
||||
<i class="stat-icon fas fa-calendar-day"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 有效密钥区域 -->
|
||||
<div class="bg-white bg-opacity-70 rounded-xl shadow-md overflow-hidden mb-6 animate-fade-in">
|
||||
<div class="flex justify-between items-center p-4 bg-white bg-opacity-80 cursor-pointer" onclick="toggleSection(this, 'validKeys')">
|
||||
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.2s">
|
||||
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'validKeys')">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
|
||||
<i class="fas fa-check-circle text-success-500 text-xl"></i>
|
||||
<h2 class="text-lg font-semibold">有效密钥</h2>
|
||||
<i class="fas fa-check-circle text-success-500"></i>
|
||||
<h2 class="text-lg font-semibold">有效密钥列表 ({{ valid_key_count }})</h2>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<label for="failCountThreshold" class="text-sm text-gray-600 select-none">失败次数≥</label>
|
||||
<input type="number" id="failCountThreshold" value="0" min="0" class="form-input h-7 w-16 px-2 py-1 text-sm border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500">
|
||||
<input type="number" id="failCountThreshold" value="0" min="0" class="form-input h-7 w-16 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@@ -78,10 +316,11 @@
|
||||
</div>
|
||||
|
||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||
<ul id="validKeys" class="space-y-3">
|
||||
{% for key, fail_count in valid_keys.items() %}
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow duration-200 border border-gray-100" data-fail-count="{{ fail_count }}">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<ul id="validKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{% if valid_keys %}
|
||||
{% for key, fail_count in valid_keys.items() %}
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-success-300 transform hover:-translate-y-1" data-fail-count="{{ fail_count }}">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-600">
|
||||
<i class="fas fa-check mr-1"></i> 有效
|
||||
@@ -97,7 +336,7 @@
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
@@ -114,17 +353,20 @@
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="text-center text-gray-500 py-4">暂无有效密钥</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无效密钥区域 -->
|
||||
<div class="bg-white bg-opacity-70 rounded-xl shadow-md overflow-hidden mb-6 animate-fade-in" style="animation-delay: 0.2s">
|
||||
<div class="flex justify-between items-center p-4 bg-white bg-opacity-80 cursor-pointer" onclick="toggleSection(this, 'invalidKeys')">
|
||||
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.4s">
|
||||
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'invalidKeys')">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
|
||||
<i class="fas fa-times-circle text-danger-500 text-xl"></i>
|
||||
<h2 class="text-lg font-semibold">无效密钥</h2>
|
||||
<i class="fas fa-times-circle text-danger-500"></i>
|
||||
<h2 class="text-lg font-semibold">无效密钥列表 ({{ invalid_key_count }})</h2>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); resetAllKeysFailCount('invalid', event)" data-reset-type="invalid">
|
||||
@@ -139,10 +381,11 @@
|
||||
</div>
|
||||
|
||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||
<ul id="invalidKeys" class="space-y-3">
|
||||
{% for key, fail_count in invalid_keys.items() %}
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow duration-200 border border-gray-100">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<ul id="invalidKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{% if invalid_keys %}
|
||||
{% for key, fail_count in invalid_keys.items() %}
|
||||
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-danger-300 transform hover:-translate-y-1">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-50 text-danger-600">
|
||||
<i class="fas fa-times mr-1"></i> 无效
|
||||
@@ -158,7 +401,7 @@
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
@@ -175,16 +418,14 @@
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="text-center text-gray-500 py-4">暂无无效密钥</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 总密钥数显示 -->
|
||||
<div class="bg-white bg-opacity-70 rounded-xl shadow-md p-4 text-center animate-fade-in" style="animation-delay: 0.4s">
|
||||
<div class="flex items-center justify-center gap-2 text-primary-700 font-semibold text-lg">
|
||||
<i class="fas fa-key"></i> 总密钥数:{{ total }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Removed old total keys display -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user