feat(api,ui): 新增按Key调用详情与错误日志查找并联动前端

引入按密钥维度的请求详情及错误日志关联,新增错误日志精确
查找接口,并扩展统计时间维度,提升故障定位与可观测性。

- 新增 /api/logs/errors/lookup 接口:支持按 gemini_key / timestamp /
  status_code 与时间窗口查找最接近的错误日志;ErrorLogDetailResponse
  增加 error_code 字段
- Stats 接口增强:get_api_call_details 返回 status_code、latency_ms,
  并在失败时尝试匹配 error_log_id;新增 /api/stats/key-details 获取指
  定密钥调用详情;新增 8h 时间段
- DB 层:add_error_log 支持传入 request_datetime(默认使用 UTC);新增
  find_error_log_by_info 封装按 key/时间窗口/状态码的查询
- 前端 keys_status:趋势图支持 8 小时区间;调用详情表新增状态码/耗时与
  失败详情按钮;可按 key 查看期内调用详情并查看匹配错误日志;优化统计
  摘要展示与模态层级(z-index)
- OpenAIChatService:错误记录携带请求时间;改进日志与健壮性处理
This commit is contained in:
snaily
2025-08-18 05:19:29 +08:00
parent 01312317a1
commit e9601ca76c
8 changed files with 607 additions and 133 deletions
+77 -2
View File
@@ -3,7 +3,7 @@
""" """
import json import json
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
from sqlalchemy import asc, delete, desc, func, insert, select, update from sqlalchemy import asc, delete, desc, func, insert, select, update
@@ -107,6 +107,7 @@ async def add_error_log(
error_log: Optional[str] = None, error_log: Optional[str] = None,
error_code: Optional[int] = None, error_code: Optional[int] = None,
request_msg: Optional[Union[Dict[str, Any], str]] = None, request_msg: Optional[Union[Dict[str, Any], str]] = None,
request_datetime: Optional[datetime] = None,
) -> bool: ) -> bool:
""" """
添加错误日志 添加错误日志
@@ -140,7 +141,9 @@ async def add_error_log(
model_name=model_name, model_name=model_name,
error_code=error_code, error_code=error_code,
request_msg=request_msg_json, request_msg=request_msg_json,
request_time=datetime.now(), request_time=(
request_datetime if request_datetime else datetime.now(timezone.utc)
),
) )
await database.execute(query) await database.execute(query)
logger.info(f"Added error log for key: {redact_key_for_logging(gemini_key)}") logger.info(f"Added error log for key: {redact_key_for_logging(gemini_key)}")
@@ -307,6 +310,78 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
raise raise
# 新增函数:通过 gemini_key / error_code / 时间窗口 查找最接近的错误日志
async def find_error_log_by_info(
gemini_key: str,
timestamp: datetime,
status_code: Optional[int] = None,
window_seconds: int = 1,
) -> Optional[Dict[str, Any]]:
"""
在给定时间窗口内,根据 gemini_key(精确匹配)及可选的 status_code 查找最接近 timestamp 的错误日志。
假设错误日志的 error_code 存储的是 HTTP 状态码或等价错误码。
Args:
gemini_key: 完整的 Gemini key 字符串。
timestamp: 目标时间(UTC 或本地,与存储一致)。
status_code: 可选的错误码,若提供则优先匹配该错误码。
window_seconds: 允许的时间偏差窗口,单位秒,默认为 1 秒。
Returns:
Optional[Dict[str, Any]]: 最匹配的一条错误日志的完整详情(字段与 get_error_log_details 一致),若未找到则返回 None。
"""
try:
start_time = timestamp - timedelta(seconds=window_seconds)
end_time = timestamp + timedelta(seconds=window_seconds)
base_query = select(ErrorLog).where(
ErrorLog.gemini_key == gemini_key,
ErrorLog.request_time >= start_time,
ErrorLog.request_time <= end_time,
)
# 若提供了状态码,先尝试按状态码过滤
if status_code is not None:
query = base_query.where(ErrorLog.error_code == status_code).order_by(
ErrorLog.request_time.desc()
)
candidates = await database.fetch_all(query)
if not candidates:
# 回退:不按状态码,仅按时间窗口
query2 = base_query.order_by(ErrorLog.request_time.desc())
candidates = await database.fetch_all(query2)
else:
query = base_query.order_by(ErrorLog.request_time.desc())
candidates = await database.fetch_all(query)
if not candidates:
return None
# 在 Python 中选择与 timestamp 最接近的一条
def _to_dict(row: Any) -> Dict[str, Any]:
d = dict(row)
if "request_msg" in d and d["request_msg"] is not None:
try:
d["request_msg"] = json.dumps(
d["request_msg"], ensure_ascii=False, indent=2
)
except TypeError:
d["request_msg"] = str(d["request_msg"])
return d
best = min(
candidates,
key=lambda r: abs((r["request_time"] - timestamp).total_seconds()),
)
return _to_dict(best)
except Exception as e:
logger.exception(
f"Failed to find error log by info (key=***{gemini_key[-4:] if gemini_key else ''}, code={status_code}, ts={timestamp}, window={window_seconds}s): {str(e)}"
)
raise
async def delete_error_logs_by_ids(log_ids: List[int]) -> int: async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
""" """
根据提供的 ID 列表批量删除错误日志 (异步)。 根据提供的 ID 列表批量删除错误日志 (异步)。
+38
View File
@@ -120,6 +120,7 @@ class ErrorLogDetailResponse(BaseModel):
request_msg: Optional[str] = None request_msg: Optional[str] = None
model_name: Optional[str] = None model_name: Optional[str] = None
request_time: Optional[datetime] = None request_time: Optional[datetime] = None
error_code: Optional[int] = None
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse) @router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
@@ -151,6 +152,43 @@ async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=
) )
@router.get("/errors/lookup", response_model=ErrorLogDetailResponse)
async def lookup_error_log_by_info(
request: Request,
gemini_key: str = Query(..., description="完整的 Gemini key"),
timestamp: datetime = Query(..., description="请求时间 (ISO8601)"),
status_code: Optional[int] = Query(None, description="错误码 (可选)"),
window_seconds: int = Query(
100, ge=1, le=300, description="时间窗口(秒), 默认100秒"
),
):
"""
通过 key / 错误码 / 时间窗口 查找最匹配的一条错误日志详情。
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to lookup error log by info")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
detail = await error_log_service.process_find_error_log_by_info(
gemini_key=gemini_key,
timestamp=timestamp,
status_code=status_code,
window_seconds=window_seconds,
)
if not detail:
raise HTTPException(status_code=404, detail="No matching error log found")
return ErrorLogDetailResponse(**detail)
except HTTPException as http_exc:
raise http_exc
except Exception as e:
logger.exception(
f"Failed to lookup error log by info for key=***{gemini_key[-4:] if gemini_key else ''}: {str(e)}"
)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/errors", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/errors", status_code=status.HTTP_204_NO_CONTENT)
async def delete_error_logs_bulk_api( async def delete_error_logs_bulk_api(
request: Request, payload: Dict[str, List[int]] = Body(...) request: Request, payload: Dict[str, List[int]] = Body(...)
+24
View File
@@ -230,3 +230,27 @@ def setup_api_stats_routes(app: FastAPI) -> None:
f"Error fetching API stats details for period {period}: {str(e)}" f"Error fetching API stats details for period {period}: {str(e)}"
) )
return {"error": "Internal server error"}, 500 return {"error": "Internal server error"}, 500
@app.get("/api/stats/key-details")
async def api_stats_key_details(request: Request, key: str, period: str):
"""获取指定密钥在指定时间段内的调用详情"""
try:
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to API key stats details")
return {"error": "Unauthorized"}, 401
logger.info(f"Fetching key call details for key=...{key[-4:] if key else ''}, period: {period}")
stats_service = StatsService()
details = await stats_service.get_key_call_details(key, period)
return details
except ValueError as e:
logger.warning(
f"Invalid period requested for key stats details: {period} - {str(e)}"
)
return {"error": str(e)}, 400
except Exception as e:
logger.error(
f"Error fetching key stats details for period {period}: {str(e)}"
)
return {"error": "Internal server error"}, 500
+88 -45
View File
@@ -40,15 +40,31 @@ def _clean_json_schema_properties(obj: Any) -> Any:
"""清理JSON Schema中Gemini API不支持的字段""" """清理JSON Schema中Gemini API不支持的字段"""
if not isinstance(obj, dict): if not isinstance(obj, dict):
return obj return obj
# Gemini API不支持的JSON Schema字段 # Gemini API不支持的JSON Schema字段
unsupported_fields = { unsupported_fields = {
"exclusiveMaximum", "exclusiveMinimum", "const", "examples", "exclusiveMaximum",
"contentEncoding", "contentMediaType", "if", "then", "else", "exclusiveMinimum",
"allOf", "anyOf", "oneOf", "not", "definitions", "$schema", "const",
"$id", "$ref", "$comment", "readOnly", "writeOnly" "examples",
"contentEncoding",
"contentMediaType",
"if",
"then",
"else",
"allOf",
"anyOf",
"oneOf",
"not",
"definitions",
"$schema",
"$id",
"$ref",
"$comment",
"readOnly",
"writeOnly",
} }
cleaned = {} cleaned = {}
for key, value in obj.items(): for key, value in obj.items():
if key in unsupported_fields: if key in unsupported_fields:
@@ -59,7 +75,7 @@ def _clean_json_schema_properties(obj: Any) -> Any:
cleaned[key] = [_clean_json_schema_properties(item) for item in value] cleaned[key] = [_clean_json_schema_properties(item) for item in value]
else: else:
cleaned[key] = value cleaned[key] = value
return cleaned return cleaned
@@ -87,7 +103,7 @@ def _build_tools(
if model.endswith("-search"): if model.endswith("-search"):
tool["googleSearch"] = {} tool["googleSearch"] = {}
real_model = _get_real_model(model) real_model = _get_real_model(model)
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED: if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
tool["urlContext"] = {} tool["urlContext"] = {}
@@ -116,7 +132,7 @@ def _build_tools(
names, functions = set(), [] names, functions = set(), []
for fc in function_declarations: for fc in function_declarations:
if fc.get("name") not in names: if fc.get("name") not in names:
if fc.get("name")=="googleSearch": if fc.get("name") == "googleSearch":
# cherry开启内置搜索时,添加googleSearch工具 # cherry开启内置搜索时,添加googleSearch工具
tool["googleSearch"] = {} tool["googleSearch"] = {}
else: else:
@@ -130,7 +146,7 @@ def _build_tools(
if tool.get("functionDeclarations"): if tool.get("functionDeclarations"):
tool.pop("googleSearch", None) tool.pop("googleSearch", None)
tool.pop("codeExecution", None) tool.pop("codeExecution", None)
tool.pop("urlContext",None) tool.pop("urlContext", None)
return [tool] if tool else [] return [tool] if tool else []
@@ -160,17 +176,17 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
def _validate_and_set_max_tokens( def _validate_and_set_max_tokens(
payload: Dict[str, Any], payload: Dict[str, Any], max_tokens: Optional[int], logger_instance
max_tokens: Optional[int],
logger_instance
) -> None: ) -> None:
"""验证并设置 max_tokens 参数""" """验证并设置 max_tokens 参数"""
if max_tokens is None: if max_tokens is None:
return return
# 参数验证和处理 # 参数验证和处理
if max_tokens <= 0: if max_tokens <= 0:
logger_instance.warning(f"Invalid max_tokens value: {max_tokens}, will not set maxOutputTokens") logger_instance.warning(
f"Invalid max_tokens value: {max_tokens}, will not set maxOutputTokens"
)
# 不设置 maxOutputTokens,让 Gemini API 使用默认值 # 不设置 maxOutputTokens,让 Gemini API 使用默认值
else: else:
payload["generationConfig"]["maxOutputTokens"] = max_tokens payload["generationConfig"]["maxOutputTokens"] = max_tokens
@@ -193,31 +209,33 @@ def _build_payload(
"tools": _build_tools(request, messages), "tools": _build_tools(request, messages),
"safetySettings": _get_safety_settings(request.model), "safetySettings": _get_safety_settings(request.model),
} }
# 处理 max_tokens 参数 # 处理 max_tokens 参数
_validate_and_set_max_tokens(payload, request.max_tokens, logger) _validate_and_set_max_tokens(payload, request.max_tokens, logger)
# 处理 n 参数 # 处理 n 参数
if request.n is not None and request.n > 0: if request.n is not None and request.n > 0:
payload["generationConfig"]["candidateCount"] = request.n payload["generationConfig"]["candidateCount"] = request.n
if request.model.endswith("-image") or request.model.endswith("-image-generation"): if request.model.endswith("-image") or request.model.endswith("-image-generation"):
payload["generationConfig"]["responseModalities"] = ["Text", "Image"] payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
if request.model.endswith("-non-thinking"): if request.model.endswith("-non-thinking"):
if "gemini-2.5-pro" in request.model: if "gemini-2.5-pro" in request.model:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128} payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
else: else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0} payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
elif _get_real_model(request.model) in settings.THINKING_BUDGET_MAP: elif _get_real_model(request.model) in settings.THINKING_BUDGET_MAP:
if settings.SHOW_THINKING_PROCESS: if settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = { payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000), "thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000),
"includeThoughts": True "includeThoughts": True,
} }
else: else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)} payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)
}
if ( if (
instruction instruction
@@ -284,13 +302,13 @@ class OpenAIChatService:
is_success = False is_success = False
status_code = None status_code = None
response = None response = None
try: try:
response = await self.api_client.generate_content(payload, model, api_key) response = await self.api_client.generate_content(payload, model, api_key)
usage_metadata = response.get("usageMetadata", {}) usage_metadata = response.get("usageMetadata", {})
is_success = True is_success = True
status_code = 200 status_code = 200
# 尝试处理响应,捕获可能的响应处理异常 # 尝试处理响应,捕获可能的响应处理异常
try: try:
result = self.response_handler.handle_response( result = self.response_handler.handle_response(
@@ -302,8 +320,10 @@ class OpenAIChatService:
) )
return result return result
except Exception as response_error: except Exception as response_error:
logger.error(f"Response processing failed for model {model}: {str(response_error)}") logger.error(
f"Response processing failed for model {model}: {str(response_error)}"
)
# 记录详细的错误信息 # 记录详细的错误信息
if "parts" in str(response_error): if "parts" in str(response_error):
logger.error("Response structure issue - missing or invalid parts") logger.error("Response structure issue - missing or invalid parts")
@@ -311,24 +331,26 @@ class OpenAIChatService:
candidate = response["candidates"][0] candidate = response["candidates"][0]
content = candidate.get("content", {}) content = candidate.get("content", {})
logger.error(f"Content structure: {content}") logger.error(f"Content structure: {content}")
# 重新抛出异常 # 重新抛出异常
raise response_error raise response_error
except Exception as e: except Exception as e:
is_success = False is_success = False
error_log_msg = str(e) error_log_msg = str(e)
logger.error(f"API call failed for model {model}: {error_log_msg}") logger.error(f"API call failed for model {model}: {error_log_msg}")
# 特别记录 max_tokens 相关的错误 # 特别记录 max_tokens 相关的错误
gen_config = payload.get('generationConfig', {}) gen_config = payload.get("generationConfig", {})
if "maxOutputTokens" in gen_config: if "maxOutputTokens" in gen_config:
logger.error(f"Request had maxOutputTokens: {gen_config['maxOutputTokens']}") logger.error(
f"Request had maxOutputTokens: {gen_config['maxOutputTokens']}"
)
# 如果是响应处理错误,记录更多信息 # 如果是响应处理错误,记录更多信息
if "parts" in error_log_msg: if "parts" in error_log_msg:
logger.error("This is likely a response processing error") logger.error("This is likely a response processing error")
match = re.search(r"status code (\d+)", error_log_msg) match = re.search(r"status code (\d+)", error_log_msg)
status_code = int(match.group(1)) if match else 500 status_code = int(match.group(1)) if match else 500
@@ -339,13 +361,16 @@ class OpenAIChatService:
error_log=error_log_msg, error_log=error_log_msg,
error_code=status_code, error_code=status_code,
request_msg=payload, request_msg=payload,
request_datetime=request_datetime,
) )
raise e raise e
finally: finally:
end_time = time.perf_counter() end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000) latency_ms = int((end_time - start_time) * 1000)
logger.info(f"Normal completion finished - Success: {is_success}, Latency: {latency_ms}ms") logger.info(
f"Normal completion finished - Success: {is_success}, Latency: {latency_ms}ms"
)
await add_request_log( await add_request_log(
model_name=model, model_name=model,
api_key=api_key, api_key=api_key,
@@ -362,7 +387,7 @@ class OpenAIChatService:
logger.info( logger.info(
f"Fake streaming enabled for model: {model}. Calling non-streaming endpoint." f"Fake streaming enabled for model: {model}. Calling non-streaming endpoint."
) )
api_response_task = asyncio.create_task( api_response_task = asyncio.create_task(
self.api_client.generate_content(payload, model, api_key) self.api_client.generate_content(payload, model, api_key)
) )
@@ -372,9 +397,15 @@ class OpenAIChatService:
while not api_response_task.done(): while not api_response_task.done():
i = i + 1 i = i + 1
"""定期发送空数据以保持连接""" """定期发送空数据以保持连接"""
if i >= settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS : if i >= settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS:
i = 0 i = 0
empty_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None) empty_chunk = self.response_handler.handle_response(
{},
model,
stream=True,
finish_reason="stop",
usage_metadata=None,
)
yield f"data: {json.dumps(empty_chunk)}\n\n" yield f"data: {json.dumps(empty_chunk)}\n\n"
logger.debug("Sent empty data chunk for fake stream heartbeat.") logger.debug("Sent empty data chunk for fake stream heartbeat.")
await asyncio.sleep(1) await asyncio.sleep(1)
@@ -382,14 +413,18 @@ class OpenAIChatService:
response = await api_response_task response = await api_response_task
if response and response.get("candidates"): if response and response.get("candidates"):
response = self.response_handler.handle_response(response, model, stream=True, finish_reason='stop', usage_metadata=response.get("usageMetadata", {})) response = self.response_handler.handle_response(
response,
model,
stream=True,
finish_reason="stop",
usage_metadata=response.get("usageMetadata", {}),
)
yield f"data: {json.dumps(response)}\n\n" yield f"data: {json.dumps(response)}\n\n"
logger.info(f"Sent full response content for fake stream: {model}") logger.info(f"Sent full response content for fake stream: {model}")
else: else:
error_message = "Failed to get response from model" error_message = "Failed to get response from model"
if ( if response and isinstance(response, dict) and response.get("error"):
response and isinstance(response, dict) and response.get("error")
):
error_details = response.get("error") error_details = response.get("error")
if isinstance(error_details, dict): if isinstance(error_details, dict):
error_message = error_details.get("message", error_message) error_message = error_details.get("message", error_message)
@@ -397,7 +432,9 @@ class OpenAIChatService:
logger.error( logger.error(
f"No candidates or error in response for fake stream model {model}: {response}" f"No candidates or error in response for fake stream model {model}: {response}"
) )
error_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None) error_chunk = self.response_handler.handle_response(
{}, model, stream=True, finish_reason="stop", usage_metadata=None
)
yield f"data: {json.dumps(error_chunk)}\n\n" yield f"data: {json.dumps(error_chunk)}\n\n"
async def _real_stream_logic_impl( async def _real_stream_logic_impl(
@@ -425,7 +462,11 @@ class OpenAIChatService:
) )
continue continue
openai_chunk = self.response_handler.handle_response( openai_chunk = self.response_handler.handle_response(
chunk, model, stream=True, finish_reason=None, usage_metadata=usage_metadata chunk,
model,
stream=True,
finish_reason=None,
usage_metadata=usage_metadata,
) )
if openai_chunk: if openai_chunk:
text = self._extract_text_from_openai_chunk(openai_chunk) text = self._extract_text_from_openai_chunk(openai_chunk)
@@ -439,7 +480,9 @@ class OpenAIChatService:
): ):
yield optimized_chunk_data yield optimized_chunk_data
else: else:
if openai_chunk.get("choices") and openai_chunk["choices"][0].get("delta", {}).get("tool_calls"): if openai_chunk.get("choices") and openai_chunk["choices"][
0
].get("delta", {}).get("tool_calls"):
tool_call_flag = True tool_call_flag = True
yield f"data: {json.dumps(openai_chunk)}\n\n" yield f"data: {json.dumps(openai_chunk)}\n\n"
@@ -121,6 +121,30 @@ async def process_get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]
raise raise
async def process_find_error_log_by_info(
gemini_key: str,
timestamp: datetime,
status_code: Optional[int] = None,
window_seconds: int = 100,
) -> Optional[Dict[str, Any]]:
"""
根据 key/状态码/时间窗口 查询最匹配的一条错误日志,未找到则返回 None。
"""
try:
return await db_services.find_error_log_by_info(
gemini_key=gemini_key,
timestamp=timestamp,
status_code=status_code,
window_seconds=window_seconds,
)
except Exception as e:
logger.error(
f"Service error in process_find_error_log_by_info: {e}",
exc_info=True,
)
raise
async def process_delete_error_logs_by_ids(log_ids: List[int]) -> int: async def process_delete_error_logs_by_ids(log_ids: List[int]) -> int:
""" """
按 ID 批量删除错误日志。 按 ID 批量删除错误日志。
+128 -18
View File
@@ -146,7 +146,7 @@ class StatsService:
period: 时间段标识 ('1m', '1h', '24h') period: 时间段标识 ('1m', '1h', '24h')
Returns: Returns:
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status 包含调用详情的字典列表,每个字典包含 timestamp, key, model, status, status_code, latency_ms, error_log_id(可选)
Raises: Raises:
ValueError: 如果 period 无效 ValueError: 如果 period 无效
@@ -156,6 +156,8 @@ class StatsService:
start_time = now - datetime.timedelta(minutes=1) start_time = now - datetime.timedelta(minutes=1)
elif period == "1h": elif period == "1h":
start_time = now - datetime.timedelta(hours=1) start_time = now - datetime.timedelta(hours=1)
elif period == "8h":
start_time = now - datetime.timedelta(hours=8)
elif period == "24h": elif period == "24h":
start_time = now - datetime.timedelta(hours=24) start_time = now - datetime.timedelta(hours=24)
else: else:
@@ -167,7 +169,8 @@ class StatsService:
RequestLog.request_time.label("timestamp"), RequestLog.request_time.label("timestamp"),
RequestLog.api_key.label("key"), RequestLog.api_key.label("key"),
RequestLog.model_name.label("model"), RequestLog.model_name.label("model"),
RequestLog.status_code, RequestLog.status_code.label("status_code"),
RequestLog.latency_ms.label("latency_ms"),
) )
.where(RequestLog.request_time >= start_time) .where(RequestLog.request_time >= start_time)
.order_by(RequestLog.request_time.desc()) .order_by(RequestLog.request_time.desc())
@@ -175,29 +178,138 @@ class StatsService:
results = await database.fetch_all(query) results = await database.fetch_all(query)
details = [] # 为失败调用尝试查找匹配的错误日志ID(时间窗口 +/- 5 分钟)
from app.database.models import ErrorLog # 延迟导入避免循环依赖
details: list[dict] = []
for row in results: for row in results:
status = "failure" status = "failure"
if row["status_code"] is not None: if row["status_code"] is not None:
status = "success" if 200 <= row["status_code"] < 300 else "failure" status = "success" if 200 <= row["status_code"] < 300 else "failure"
details.append(
{ record = {
"timestamp": row[ "timestamp": row["timestamp"].isoformat(),
"timestamp" "key": row["key"],
].isoformat(), "model": row["model"],
"key": row["key"], "status": status,
"model": row["model"], "status_code": row["status_code"],
"status": status, "latency_ms": row["latency_ms"],
} }
)
# 如果失败,尝试附带一个相关的错误日志ID,便于前端拉取详情
if status == "failure" and row["key"]:
try:
ts = row["timestamp"]
start_win = ts - datetime.timedelta(minutes=5)
end_win = ts + datetime.timedelta(minutes=5)
err_query = (
select(ErrorLog.id)
.where(
ErrorLog.gemini_key == row["key"],
ErrorLog.request_time >= start_win,
ErrorLog.request_time <= end_win,
)
.order_by(ErrorLog.request_time.desc())
.limit(1)
)
err = await database.fetch_one(err_query)
if err:
record["error_log_id"] = err["id"]
except Exception as _e:
logger.debug(
f"No matching error log found for key ending ...{row['key'][-4:] if row['key'] else ''}: {_e}"
)
details.append(record)
logger.info( logger.info(
f"Retrieved {len(details)} API call details for period '{period}'" f"Retrieved {len(details)} API call details for period '{period}'"
) )
return details return details
except Exception as e:
logger.error(f"Failed to get API call details for period '{period}': {e}")
raise
async def get_key_call_details(self, key: str, period: str) -> list[dict]:
"""获取指定密钥在指定时间段内的调用详情 (与 get_api_call_details 结构一致)"""
now = datetime.datetime.now()
if period == "1m":
start_time = now - datetime.timedelta(minutes=1)
elif period == "1h":
start_time = now - datetime.timedelta(hours=1)
elif period == "8h":
start_time = now - datetime.timedelta(hours=8)
elif period == "24h":
start_time = now - datetime.timedelta(hours=24)
else:
raise ValueError(f"无效的时间段标识: {period}")
try:
query = (
select(
RequestLog.request_time.label("timestamp"),
RequestLog.api_key.label("key"),
RequestLog.model_name.label("model"),
RequestLog.status_code.label("status_code"),
RequestLog.latency_ms.label("latency_ms"),
)
.where(RequestLog.request_time >= start_time, RequestLog.api_key == key)
.order_by(RequestLog.request_time.desc())
)
results = await database.fetch_all(query)
from app.database.models import ErrorLog
details: list[dict] = []
for row in results:
status = "failure"
if row["status_code"] is not None:
status = "success" if 200 <= row["status_code"] < 300 else "failure"
record = {
"timestamp": row["timestamp"].isoformat(),
"key": row["key"],
"model": row["model"],
"status": status,
"status_code": row["status_code"],
"latency_ms": row["latency_ms"],
}
if status == "failure" and row["key"]:
try:
ts = row["timestamp"]
start_win = ts - datetime.timedelta(minutes=5)
end_win = ts + datetime.timedelta(minutes=5)
err_query = (
select(ErrorLog.id)
.where(
ErrorLog.gemini_key == row["key"],
ErrorLog.request_time >= start_win,
ErrorLog.request_time < end_win,
)
.order_by(ErrorLog.request_time.desc())
.limit(1)
)
err = await database.fetch_one(err_query)
if err:
record["error_log_id"] = err["id"]
except Exception as _e:
logger.debug(
f"No matching error log found for key ending ...{row['key'][-4:] if row['key'] else ''}: {_e}"
)
details.append(record)
logger.info(
f"Retrieved {len(details)} key call details for key=...{key[-4:] if key else ''} period '{period}'"
)
return details
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Failed to get API call details for period '{period}': {e}") f"Failed to get key call details for key=...{key[-4:] if key else ''} period '{period}': {e}"
)
raise raise
async def get_key_usage_details_last_24h(self, key: str) -> Union[dict, None]: async def get_key_usage_details_last_24h(self, key: str) -> Union[dict, None]:
@@ -220,8 +332,7 @@ class StatsService:
try: try:
query = ( query = (
select( select(
RequestLog.model_name, func.count( RequestLog.model_name, func.count(RequestLog.id).label("call_count")
RequestLog.id).label("call_count")
) )
.where( .where(
RequestLog.api_key == key, RequestLog.api_key == key,
@@ -240,8 +351,7 @@ class StatsService:
) )
return {} return {}
usage_details = {row["model_name"]: row["call_count"] usage_details = {row["model_name"]: row["call_count"] for row in results}
for row in results}
logger.info( logger.info(
f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}" f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}"
) )
+225 -66
View File
@@ -346,7 +346,8 @@ function showResetModal(type) {
// 设置确认按钮事件 // 设置确认按钮事件
confirmButton.onclick = () => executeResetAll(type); confirmButton.onclick = () => executeResetAll(type);
// 显示模态框 // 显示模态框,确保位于最上层
modalElement.style.zIndex = '1001';
modalElement.classList.remove("hidden"); modalElement.classList.remove("hidden");
} }
@@ -1508,6 +1509,12 @@ function bucketizeDetails(period, details) {
const HH = String(d.getHours()).padStart(2,'0'); const HH = String(d.getHours()).padStart(2,'0');
const mm = String(d.getMinutes()).padStart(2,'0'); const mm = String(d.getMinutes()).padStart(2,'0');
return `${HH}:${mm}`; return `${HH}:${mm}`;
} else if (period === '8h') {
// bucket by hour for 8h window (same as 24h)
const MM = String(d.getMonth()+1).padStart(2,'0');
const DD = String(d.getDate()).padStart(2,'0');
const HH = String(d.getHours()).padStart(2,'0');
return `${MM}-${DD} ${HH}:00`;
} else { } else {
// 24h: bucket by hour // 24h: bucket by hour
const MM = String(d.getMonth()+1).padStart(2,'0'); const MM = String(d.getMonth()+1).padStart(2,'0');
@@ -1553,11 +1560,11 @@ async function renderApiChart(period) {
} }
function initChartControls() { function initChartControls() {
const btn1m = document.getElementById('chartBtn1m');
const btn1h = document.getElementById('chartBtn1h'); const btn1h = document.getElementById('chartBtn1h');
const btn8h = document.getElementById('chartBtn8h');
const btn24h = document.getElementById('chartBtn24h'); const btn24h = document.getElementById('chartBtn24h');
const setActive = (activeBtn) => { const setActive = (activeBtn) => {
[btn1m, btn1h, btn24h].forEach(btn => { [btn1h, btn8h, btn24h].forEach(btn => {
if (!btn) return; if (!btn) return;
if (btn === activeBtn) { if (btn === activeBtn) {
btn.classList.remove('bg-gray-200'); btn.classList.remove('bg-gray-200');
@@ -1569,8 +1576,8 @@ function initChartControls() {
}); });
}; };
if (btn1m) btn1m.addEventListener('click', async () => { setActive(btn1m); await renderApiChart('1m'); });
if (btn1h) btn1h.addEventListener('click', async () => { setActive(btn1h); await renderApiChart('1h'); }); if (btn1h) btn1h.addEventListener('click', async () => { setActive(btn1h); await renderApiChart('1h'); });
if (btn8h) btn8h.addEventListener('click', async () => { setActive(btn8h); await renderApiChart('8h'); });
if (btn24h) btn24h.addEventListener('click', async () => { setActive(btn24h); await renderApiChart('24h'); }); if (btn24h) btn24h.addEventListener('click', async () => { setActive(btn24h); await renderApiChart('24h'); });
// default period // default period
@@ -1844,6 +1851,82 @@ async function showApiCallDetails(
} }
} }
// 获取并显示错误日志详情(通过日志ID)
async function fetchAndShowErrorDetail(logId) {
try {
const detail = await fetchAPI(`/api/logs/errors/${logId}/details`);
if (!detail) {
showResultModal(false, `未找到日志 ${logId}`, false);
return;
}
const container = document.createElement('div');
container.className = 'space-y-3 text-sm';
const basic = document.createElement('div');
basic.innerHTML = `
<div><span class="font-semibold">Key:</span> ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}</div>
<div><span class="font-semibold">模型:</span> ${detail.model_name || 'N/A'}</div>
<div><span class="font-semibold">时间:</span> ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}</div>
<div><span class="font-semibold">错误类型:</span> ${detail.error_type || 'N/A'}</div>
`;
const codeBlock = document.createElement('pre');
codeBlock.className = 'bg-red-50 border border-red-200 rounded p-3 whitespace-pre-wrap break-words text-red-700';
codeBlock.textContent = detail.error_log || '无错误日志内容';
const reqBlock = document.createElement('pre');
reqBlock.className = 'bg-gray-50 border border-gray-200 rounded p-3 whitespace-pre-wrap break-words';
reqBlock.textContent = detail.request_msg || '';
container.appendChild(basic);
container.appendChild(codeBlock);
if (detail.request_msg) container.appendChild(reqBlock);
showResultModal(false, container, false);
} catch (e) {
showResultModal(false, `加载日志详情失败: ${e.message}`, false);
}
}
// 新增:根据 key / 状态码 / 时间窗口(±100秒) 查询并显示错误日志详情
async function fetchAndShowErrorDetailByInfo(geminiKey, statusCode, timestampISO) {
try {
if (!geminiKey || !timestampISO) {
showResultModal(false, '缺少必要参数,无法查询错误详情', false);
return;
}
const params = new URLSearchParams();
params.set('gemini_key', geminiKey);
params.set('timestamp', timestampISO);
if (statusCode !== null && statusCode !== undefined) {
params.set('status_code', String(statusCode));
}
params.set('window_seconds', '100');
const detail = await fetchAPI(`/api/logs/errors/lookup?${params.toString()}`);
if (!detail) {
showResultModal(false, '未找到匹配的错误日志', false);
return;
}
const container = document.createElement('div');
container.className = 'space-y-3 text-sm';
const basic = document.createElement('div');
basic.innerHTML = `
<div><span class="font-semibold">Key:</span> ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}</div>
<div><span class="font-semibold">模型:</span> ${detail.model_name || 'N/A'}</div>
<div><span class="font-semibold">时间:</span> ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}</div>
<div><span class="font-semibold">错误码:</span> ${detail.error_code ?? 'N/A'}</div>
<div><span class="font-semibold">错误类型:</span> ${detail.error_type || 'N/A'}</div>
`;
const codeBlock = document.createElement('pre');
codeBlock.className = 'bg-red-50 border border-red-200 rounded p-3 whitespace-pre-wrap break-words text-red-700';
codeBlock.textContent = detail.error_log || '无错误日志内容';
const reqBlock = document.createElement('pre');
reqBlock.className = 'bg-gray-50 border border-gray-200 rounded p-3 whitespace-pre-wrap break-words';
reqBlock.textContent = detail.request_msg || '';
container.appendChild(basic);
container.appendChild(codeBlock);
if (detail.request_msg) container.appendChild(reqBlock);
showResultModal(false, container, false);
} catch (e) {
showResultModal(false, `加载日志详情失败: ${e.message}`, false);
}
}
// 关闭 API 调用详情模态框 // 关闭 API 调用详情模态框
function closeApiCallDetailsModal() { function closeApiCallDetailsModal() {
const modal = document.getElementById("apiCallDetailsModal"); const modal = document.getElementById("apiCallDetailsModal");
@@ -1867,23 +1950,33 @@ function renderApiCallDetails(
successCalls !== undefined && successCalls !== undefined &&
failureCalls !== undefined failureCalls !== undefined
) { ) {
const total = Number(totalCalls) || 0;
const succ = Number(successCalls) || 0;
const fail = Number(failureCalls) || 0;
const denom = total > 0 ? total : succ + fail;
const succRate = denom > 0 ? ((succ / denom) * 100).toFixed(1) : '0.0';
const failRate = denom > 0 ? ((fail / denom) * 100).toFixed(1) : '0.0';
summaryHtml = ` summaryHtml = `
<div class="mb-4 p-3 bg-white dark:bg-gray-700 rounded-lg"> <div class="mb-4">
<h4 class="font-semibold text-gray-700 dark:text-gray-200 mb-2 text-md border-b pb-1.5 dark:border-gray-600">期间调用概览:</h4> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded-lg overflow-hidden">
<div class="grid grid-cols-3 gap-2 text-center"> <thead class="bg-gray-50 dark:bg-gray-700/50">
<div> <tr>
<p class="text-sm text-gray-500 dark:text-gray-400">总计</p> <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">总计</th>
<p class="text-lg font-bold text-primary-600 dark:text-primary-400">${totalCalls}</p> <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功</th>
</div> <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">失败</th>
<div> <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功率</th>
<p class="text-sm text-gray-500 dark:text-gray-400">成功</p> </tr>
<p class="text-lg font-bold text-success-600 dark:text-success-400">${successCalls}</p> </thead>
</div> <tbody class="bg-white dark:bg-gray-800">
<div> <tr>
<p class="text-sm text-gray-500 dark:text-gray-400">失败</p> <td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-primary-600 dark:text-primary-400">${totalCalls}</td>
<p class="text-lg font-bold text-danger-600 dark:text-danger-400">${failureCalls}</p> <td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-success-600 dark:text-success-400">${successCalls}</td>
</div> <td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-danger-600 dark:text-danger-400">${failureCalls}</td>
</div> <td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-success-600 dark:text-success-400">${succRate}%</td>
</tr>
</tbody>
</table>
</div> </div>
`; `;
} }
@@ -1907,7 +2000,10 @@ function renderApiCallDetails(
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">时间</th> <th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">时间</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">密钥 (部分)</th> <th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">密钥 (部分)</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">模型</th> <th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">模型</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态码</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">耗时(ms)</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态</th> <th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">详情</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
@@ -1928,17 +2024,25 @@ function renderApiCallDetails(
const statusIcon = const statusIcon =
call.status === "success" ? "fa-check-circle" : "fa-times-circle"; call.status === "success" ? "fa-check-circle" : "fa-times-circle";
const detailsBtn =
call.status === "failure"
? `<button class="px-2 py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs" onclick="fetchAndShowErrorDetailByInfo('${call.key}', ${call.status_code ?? 'null'}, '${call.timestamp}')">
<i class="fas fa-info-circle mr-1"></i>详情
</button>`
: "-";
tableHtml += ` tableHtml += `
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30"> <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">${timestamp}</td> <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">${timestamp}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">${keyDisplay}</td> <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">${keyDisplay}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${ <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.model || "N/A"}</td>
call.model || "N/A" <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.status_code ?? "-"}</td>
}</td> <td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.latency_ms ?? "-"}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm ${statusClass}"> <td class="px-4 py-2 whitespace-nowrap text-sm ${statusClass}">
<i class="fas ${statusIcon} mr-1"></i> <i class="fas ${statusIcon} mr-1"></i>
${call.status} ${call.status}
</td> </td>
<td class="px-4 py-2 whitespace-nowrap text-sm">${detailsBtn}</td>
</tr> </tr>
`; `;
}); });
@@ -1967,67 +2071,122 @@ window.showKeyUsageDetails = async function (key) {
return; return;
} }
// renderKeyUsageDetails 变为 showKeyUsageDetails 的局部函数 // 构建内容框架(时间范围按钮 + 图表 + 表格容器)
function renderKeyUsageDetails(data, container) { const controlsHtml = `
if (!data || Object.keys(data).length === 0) { <div class="flex items-center gap-2 mb-3 text-xs">
<button id="keyBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button>
<button id="keyBtn8h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">8小时</button>
<button id="keyBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button>
</div>
<div class="h-48 mb-4">
<canvas id="keyUsageChart"></canvas>
</div>
<div id="keyUsageTable"></div>`;
contentArea.innerHTML = controlsHtml;
// 设置标题
titleElement.textContent = `密钥 ${keyDisplay} - 请求详情`;
// 显示模态框
modal.classList.remove("hidden");
let keyUsageChart = null;
function buildKeyChartConfig(labels, successData, failureData) {
return buildChartConfig(labels, successData, failureData);
}
function bucketizeKeyDetails(period, details) {
return bucketizeDetails(period, details);
}
function renderKeyUsageTable(data) {
const container = document.getElementById('keyUsageTable');
if (!container) return;
if (!data || data.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="text-center py-10 text-gray-500"> <div class="text-center py-10 text-gray-500">
<i class="fas fa-info-circle text-3xl"></i> <i class="fas fa-info-circle text-3xl"></i>
<p class="mt-2">该密钥在最近24小时内没有调用记录。</p> <p class="mt-2">该时间段内没有 API 调用记录。</p>
</div>`; </div>`;
return; return;
} }
let tableHtml = ` let tableHtml = `
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">调用次数 (24h)</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型</th>
</tr> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态码</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">耗时(ms)</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">详情</th>
</tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200">`; <tbody class="bg-white divide-y divide-gray-200">`;
const sortedModels = Object.entries(data).sort( data.forEach((row) => {
([, countA], [, countB]) => countB - countA const timestamp = new Date(row.timestamp).toLocaleString();
); const statusClass = row.status === 'success' ? 'text-success-600' : 'text-danger-600';
sortedModels.forEach(([model, count]) => { const statusIcon = row.status === 'success' ? 'fa-check-circle' : 'fa-times-circle';
const detailsBtn = row.status === 'failure'
? `<button class="px-2 py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs" onclick="fetchAndShowErrorDetailByInfo('${row.key}', ${row.status_code ?? 'null'}, '${row.timestamp}')">
<i class="fas fa-info-circle mr-1"></i>详情
</button>`
: '-';
tableHtml += ` tableHtml += `
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${model}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">${timestamp}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${count}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.model || 'N/A'}</td>
</tr>`; <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.status_code ?? '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.latency_ms ?? '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm ${statusClass}"><i class="fas ${statusIcon} mr-1"></i>${row.status}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">${detailsBtn}</td>
</tr>`;
}); });
tableHtml += ` tableHtml += `</tbody></table>`;
</tbody>
</table>`;
container.innerHTML = tableHtml; container.innerHTML = tableHtml;
} }
async function renderForPeriod(period) {
// 设置标题 try {
titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`; const details = await fetchAPI(`/api/stats/key-details?key=${encodeURIComponent(key)}&period=${period}`);
const { labels, successData, failureData } = bucketizeKeyDetails(period, details || []);
// 显示模态框并设置加载状态 const canvas = document.getElementById('keyUsageChart');
modal.classList.remove("hidden"); if (canvas && typeof Chart !== 'undefined') {
contentArea.innerHTML = ` const cfg = buildKeyChartConfig(labels, successData, failureData);
<div class="text-center py-10"> if (keyUsageChart) keyUsageChart.destroy();
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i> keyUsageChart = new Chart(canvas.getContext('2d'), cfg);
<p class="text-gray-500 mt-2">加载中...</p> }
</div>`; renderKeyUsageTable(details || []);
} catch (e) {
try { console.error('加载密钥期内详情失败:', e);
const data = await fetchAPI(`/api/key-usage-details/${key}`); const tableContainer = document.getElementById('keyUsageTable');
if (data) { if (tableContainer) {
renderKeyUsageDetails(data, contentArea); tableContainer.innerHTML = `<div class="text-center py-10 text-danger-500">
} else {
renderKeyUsageDetails({}, contentArea); // Show empty state if no data
}
} catch (apiError) {
console.error("获取密钥使用详情失败:", apiError);
contentArea.innerHTML = `
<div class="text-center py-10 text-danger-500">
<i class="fas fa-exclamation-triangle text-3xl"></i> <i class="fas fa-exclamation-triangle text-3xl"></i>
<p class="mt-2">加载失败: ${apiError.message}</p> <p class="mt-2">加载失败: ${e.message}</p>
</div>`; </div>`;
}
}
} }
// 绑定按钮事件与默认加载
const btn1h = document.getElementById('keyBtn1h');
const btn8h = document.getElementById('keyBtn8h');
const btn24h = document.getElementById('keyBtn24h');
const setActive = (activeBtn) => {
[btn1h, btn8h, btn24h].forEach((btn) => {
if (!btn) return;
if (btn === activeBtn) {
btn.classList.remove('bg-gray-200');
btn.classList.add('bg-primary-600','text-white');
} else {
btn.classList.add('bg-gray-200');
btn.classList.remove('bg-primary-600','text-white');
}
});
};
if (btn1h) btn1h.addEventListener('click', () => { setActive(btn1h); renderForPeriod('1h'); });
if (btn8h) btn8h.addEventListener('click', () => { setActive(btn8h); renderForPeriod('8h'); });
if (btn24h) btn24h.addEventListener('click', () => { setActive(btn24h); renderForPeriod('24h'); });
if (btn1h) setActive(btn1h);
renderForPeriod('1h');
}; };
// 关闭密钥使用详情模态框 // 关闭密钥使用详情模态框
+3 -2
View File
@@ -1268,8 +1268,8 @@ endblock %} {% block head_extra_styles %}
<span>调用趋势图</span> <span>调用趋势图</span>
</h3> </h3>
<div class="flex items-center gap-2 text-xs"> <div class="flex items-center gap-2 text-xs">
<button id="chartBtn1m" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1分钟</button>
<button id="chartBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button> <button id="chartBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button>
<button id="chartBtn8h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">8小时</button>
<button id="chartBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button> <button id="chartBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button>
</div> </div>
</div> </div>
@@ -1887,7 +1887,8 @@ endblock %} {% block head_extra_styles %}
<!-- 操作结果模态框 --> <!-- 操作结果模态框 -->
<div <div
id="resultModal" id="resultModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center hidden"
style="z-index: 1001;"
> >
<div <div
class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200" class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200"