mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-06-08 00:59:43 +08:00
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:
@@ -3,7 +3,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
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_code: Optional[int] = None,
|
||||
request_msg: Optional[Union[Dict[str, Any], str]] = None,
|
||||
request_datetime: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
添加错误日志
|
||||
@@ -140,7 +141,9 @@ async def add_error_log(
|
||||
model_name=model_name,
|
||||
error_code=error_code,
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
# 新增函数:通过 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:
|
||||
"""
|
||||
根据提供的 ID 列表批量删除错误日志 (异步)。
|
||||
|
||||
@@ -120,6 +120,7 @@ class ErrorLogDetailResponse(BaseModel):
|
||||
request_msg: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
request_time: Optional[datetime] = None
|
||||
error_code: Optional[int] = None
|
||||
|
||||
|
||||
@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)
|
||||
async def delete_error_logs_bulk_api(
|
||||
request: Request, payload: Dict[str, List[int]] = Body(...)
|
||||
|
||||
@@ -230,3 +230,27 @@ def setup_api_stats_routes(app: FastAPI) -> None:
|
||||
f"Error fetching API stats details for period {period}: {str(e)}"
|
||||
)
|
||||
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
|
||||
|
||||
@@ -40,15 +40,31 @@ def _clean_json_schema_properties(obj: Any) -> Any:
|
||||
"""清理JSON Schema中Gemini API不支持的字段"""
|
||||
if not isinstance(obj, dict):
|
||||
return obj
|
||||
|
||||
|
||||
# Gemini API不支持的JSON Schema字段
|
||||
unsupported_fields = {
|
||||
"exclusiveMaximum", "exclusiveMinimum", "const", "examples",
|
||||
"contentEncoding", "contentMediaType", "if", "then", "else",
|
||||
"allOf", "anyOf", "oneOf", "not", "definitions", "$schema",
|
||||
"$id", "$ref", "$comment", "readOnly", "writeOnly"
|
||||
"exclusiveMaximum",
|
||||
"exclusiveMinimum",
|
||||
"const",
|
||||
"examples",
|
||||
"contentEncoding",
|
||||
"contentMediaType",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"allOf",
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
"not",
|
||||
"definitions",
|
||||
"$schema",
|
||||
"$id",
|
||||
"$ref",
|
||||
"$comment",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
}
|
||||
|
||||
|
||||
cleaned = {}
|
||||
for key, value in obj.items():
|
||||
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]
|
||||
else:
|
||||
cleaned[key] = value
|
||||
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
@@ -87,7 +103,7 @@ def _build_tools(
|
||||
|
||||
if model.endswith("-search"):
|
||||
tool["googleSearch"] = {}
|
||||
|
||||
|
||||
real_model = _get_real_model(model)
|
||||
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
|
||||
tool["urlContext"] = {}
|
||||
@@ -116,7 +132,7 @@ def _build_tools(
|
||||
names, functions = set(), []
|
||||
for fc in function_declarations:
|
||||
if fc.get("name") not in names:
|
||||
if fc.get("name")=="googleSearch":
|
||||
if fc.get("name") == "googleSearch":
|
||||
# cherry开启内置搜索时,添加googleSearch工具
|
||||
tool["googleSearch"] = {}
|
||||
else:
|
||||
@@ -130,7 +146,7 @@ def _build_tools(
|
||||
if tool.get("functionDeclarations"):
|
||||
tool.pop("googleSearch", None)
|
||||
tool.pop("codeExecution", None)
|
||||
tool.pop("urlContext",None)
|
||||
tool.pop("urlContext", None)
|
||||
|
||||
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(
|
||||
payload: Dict[str, Any],
|
||||
max_tokens: Optional[int],
|
||||
logger_instance
|
||||
payload: Dict[str, Any], max_tokens: Optional[int], logger_instance
|
||||
) -> None:
|
||||
"""验证并设置 max_tokens 参数"""
|
||||
if max_tokens is None:
|
||||
return
|
||||
|
||||
|
||||
# 参数验证和处理
|
||||
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 使用默认值
|
||||
else:
|
||||
payload["generationConfig"]["maxOutputTokens"] = max_tokens
|
||||
@@ -193,31 +209,33 @@ def _build_payload(
|
||||
"tools": _build_tools(request, messages),
|
||||
"safetySettings": _get_safety_settings(request.model),
|
||||
}
|
||||
|
||||
|
||||
# 处理 max_tokens 参数
|
||||
_validate_and_set_max_tokens(payload, request.max_tokens, logger)
|
||||
|
||||
# 处理 n 参数
|
||||
if request.n is not None and request.n > 0:
|
||||
payload["generationConfig"]["candidateCount"] = request.n
|
||||
|
||||
|
||||
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
|
||||
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
|
||||
|
||||
|
||||
if request.model.endswith("-non-thinking"):
|
||||
if "gemini-2.5-pro" in request.model:
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
|
||||
else:
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
||||
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
||||
|
||||
elif _get_real_model(request.model) in settings.THINKING_BUDGET_MAP:
|
||||
if settings.SHOW_THINKING_PROCESS:
|
||||
payload["generationConfig"]["thinkingConfig"] = {
|
||||
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000),
|
||||
"includeThoughts": True
|
||||
"includeThoughts": True,
|
||||
}
|
||||
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 (
|
||||
instruction
|
||||
@@ -284,13 +302,13 @@ class OpenAIChatService:
|
||||
is_success = False
|
||||
status_code = None
|
||||
response = None
|
||||
|
||||
|
||||
try:
|
||||
response = await self.api_client.generate_content(payload, model, api_key)
|
||||
usage_metadata = response.get("usageMetadata", {})
|
||||
is_success = True
|
||||
status_code = 200
|
||||
|
||||
|
||||
# 尝试处理响应,捕获可能的响应处理异常
|
||||
try:
|
||||
result = self.response_handler.handle_response(
|
||||
@@ -302,8 +320,10 @@ class OpenAIChatService:
|
||||
)
|
||||
return result
|
||||
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):
|
||||
logger.error("Response structure issue - missing or invalid parts")
|
||||
@@ -311,24 +331,26 @@ class OpenAIChatService:
|
||||
candidate = response["candidates"][0]
|
||||
content = candidate.get("content", {})
|
||||
logger.error(f"Content structure: {content}")
|
||||
|
||||
|
||||
# 重新抛出异常
|
||||
raise response_error
|
||||
|
||||
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
logger.error(f"API call failed for model {model}: {error_log_msg}")
|
||||
|
||||
|
||||
# 特别记录 max_tokens 相关的错误
|
||||
gen_config = payload.get('generationConfig', {})
|
||||
gen_config = payload.get("generationConfig", {})
|
||||
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:
|
||||
logger.error("This is likely a response processing error")
|
||||
|
||||
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
status_code = int(match.group(1)) if match else 500
|
||||
|
||||
@@ -339,13 +361,16 @@ class OpenAIChatService:
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload,
|
||||
request_datetime=request_datetime,
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
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(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
@@ -362,7 +387,7 @@ class OpenAIChatService:
|
||||
logger.info(
|
||||
f"Fake streaming enabled for model: {model}. Calling non-streaming endpoint."
|
||||
)
|
||||
|
||||
|
||||
api_response_task = asyncio.create_task(
|
||||
self.api_client.generate_content(payload, model, api_key)
|
||||
)
|
||||
@@ -372,9 +397,15 @@ class OpenAIChatService:
|
||||
while not api_response_task.done():
|
||||
i = i + 1
|
||||
"""定期发送空数据以保持连接"""
|
||||
if i >= settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS :
|
||||
if i >= settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS:
|
||||
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"
|
||||
logger.debug("Sent empty data chunk for fake stream heartbeat.")
|
||||
await asyncio.sleep(1)
|
||||
@@ -382,14 +413,18 @@ class OpenAIChatService:
|
||||
response = await api_response_task
|
||||
|
||||
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"
|
||||
logger.info(f"Sent full response content for fake stream: {model}")
|
||||
else:
|
||||
error_message = "Failed to get response from model"
|
||||
if (
|
||||
response and isinstance(response, dict) and response.get("error")
|
||||
):
|
||||
if response and isinstance(response, dict) and response.get("error"):
|
||||
error_details = response.get("error")
|
||||
if isinstance(error_details, dict):
|
||||
error_message = error_details.get("message", error_message)
|
||||
@@ -397,7 +432,9 @@ class OpenAIChatService:
|
||||
logger.error(
|
||||
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"
|
||||
|
||||
async def _real_stream_logic_impl(
|
||||
@@ -425,7 +462,11 @@ class OpenAIChatService:
|
||||
)
|
||||
continue
|
||||
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:
|
||||
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||
@@ -439,7 +480,9 @@ class OpenAIChatService:
|
||||
):
|
||||
yield optimized_chunk_data
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
按 ID 批量删除错误日志。
|
||||
|
||||
@@ -146,7 +146,7 @@ class StatsService:
|
||||
period: 时间段标识 ('1m', '1h', '24h')
|
||||
|
||||
Returns:
|
||||
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
|
||||
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status, status_code, latency_ms, error_log_id(可选)
|
||||
|
||||
Raises:
|
||||
ValueError: 如果 period 无效
|
||||
@@ -156,6 +156,8 @@ class StatsService:
|
||||
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:
|
||||
@@ -167,7 +169,8 @@ class StatsService:
|
||||
RequestLog.request_time.label("timestamp"),
|
||||
RequestLog.api_key.label("key"),
|
||||
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)
|
||||
.order_by(RequestLog.request_time.desc())
|
||||
@@ -175,29 +178,138 @@ class StatsService:
|
||||
|
||||
results = await database.fetch_all(query)
|
||||
|
||||
details = []
|
||||
# 为失败调用尝试查找匹配的错误日志ID(时间窗口 +/- 5 分钟)
|
||||
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"
|
||||
details.append(
|
||||
{
|
||||
"timestamp": row[
|
||||
"timestamp"
|
||||
].isoformat(),
|
||||
"key": row["key"],
|
||||
"model": row["model"],
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
|
||||
record = {
|
||||
"timestamp": row["timestamp"].isoformat(),
|
||||
"key": row["key"],
|
||||
"model": row["model"],
|
||||
"status": status,
|
||||
"status_code": row["status_code"],
|
||||
"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(
|
||||
f"Retrieved {len(details)} API call details for period '{period}'"
|
||||
)
|
||||
return details
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get API call details for period '{period}': {e}")
|
||||
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:
|
||||
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
|
||||
|
||||
async def get_key_usage_details_last_24h(self, key: str) -> Union[dict, None]:
|
||||
@@ -220,8 +332,7 @@ class StatsService:
|
||||
try:
|
||||
query = (
|
||||
select(
|
||||
RequestLog.model_name, func.count(
|
||||
RequestLog.id).label("call_count")
|
||||
RequestLog.model_name, func.count(RequestLog.id).label("call_count")
|
||||
)
|
||||
.where(
|
||||
RequestLog.api_key == key,
|
||||
@@ -240,8 +351,7 @@ class StatsService:
|
||||
)
|
||||
return {}
|
||||
|
||||
usage_details = {row["model_name"]: row["call_count"]
|
||||
for row in results}
|
||||
usage_details = {row["model_name"]: row["call_count"] for row in results}
|
||||
logger.info(
|
||||
f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}"
|
||||
)
|
||||
|
||||
@@ -346,7 +346,8 @@ function showResetModal(type) {
|
||||
// 设置确认按钮事件
|
||||
confirmButton.onclick = () => executeResetAll(type);
|
||||
|
||||
// 显示模态框
|
||||
// 显示模态框,确保位于最上层
|
||||
modalElement.style.zIndex = '1001';
|
||||
modalElement.classList.remove("hidden");
|
||||
}
|
||||
|
||||
@@ -1508,6 +1509,12 @@ function bucketizeDetails(period, details) {
|
||||
const HH = String(d.getHours()).padStart(2,'0');
|
||||
const mm = String(d.getMinutes()).padStart(2,'0');
|
||||
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 {
|
||||
// 24h: bucket by hour
|
||||
const MM = String(d.getMonth()+1).padStart(2,'0');
|
||||
@@ -1553,11 +1560,11 @@ async function renderApiChart(period) {
|
||||
}
|
||||
|
||||
function initChartControls() {
|
||||
const btn1m = document.getElementById('chartBtn1m');
|
||||
const btn1h = document.getElementById('chartBtn1h');
|
||||
const btn8h = document.getElementById('chartBtn8h');
|
||||
const btn24h = document.getElementById('chartBtn24h');
|
||||
const setActive = (activeBtn) => {
|
||||
[btn1m, btn1h, btn24h].forEach(btn => {
|
||||
[btn1h, btn8h, btn24h].forEach(btn => {
|
||||
if (!btn) return;
|
||||
if (btn === activeBtn) {
|
||||
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 (btn8h) btn8h.addEventListener('click', async () => { setActive(btn8h); await renderApiChart('8h'); });
|
||||
if (btn24h) btn24h.addEventListener('click', async () => { setActive(btn24h); await renderApiChart('24h'); });
|
||||
|
||||
// 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 调用详情模态框
|
||||
function closeApiCallDetailsModal() {
|
||||
const modal = document.getElementById("apiCallDetailsModal");
|
||||
@@ -1867,23 +1950,33 @@ function renderApiCallDetails(
|
||||
successCalls !== 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 = `
|
||||
<div class="mb-4 p-3 bg-white dark:bg-gray-700 rounded-lg">
|
||||
<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>
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">总计</p>
|
||||
<p class="text-lg font-bold text-primary-600 dark:text-primary-400">${totalCalls}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">成功</p>
|
||||
<p class="text-lg font-bold text-success-600 dark:text-success-400">${successCalls}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">失败</p>
|
||||
<p class="text-lg font-bold text-danger-600 dark:text-danger-400">${failureCalls}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">总计</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">失败</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
<tr>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-primary-600 dark:text-primary-400">${totalCalls}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-success-600 dark:text-success-400">${successCalls}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-danger-600 dark:text-danger-400">${failureCalls}</td>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
@@ -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">耗时(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>
|
||||
</tr>
|
||||
</thead>
|
||||
<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 =
|
||||
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 += `
|
||||
<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-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">${
|
||||
call.model || "N/A"
|
||||
}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.model || "N/A"}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.status_code ?? "-"}</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}">
|
||||
<i class="fas ${statusIcon} mr-1"></i>
|
||||
${call.status}
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm">${detailsBtn}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
@@ -1967,67 +2071,122 @@ window.showKeyUsageDetails = async function (key) {
|
||||
return;
|
||||
}
|
||||
|
||||
// renderKeyUsageDetails 变为 showKeyUsageDetails 的局部函数
|
||||
function renderKeyUsageDetails(data, container) {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
// 构建内容框架(时间范围按钮 + 图表 + 表格容器)
|
||||
const controlsHtml = `
|
||||
<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 = `
|
||||
<div class="text-center py-10 text-gray-500">
|
||||
<i class="fas fa-info-circle text-3xl"></i>
|
||||
<p class="mt-2">该密钥在最近24小时内没有调用记录。</p>
|
||||
<p class="mt-2">该时间段内没有 API 调用记录。</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
let tableHtml = `
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-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>
|
||||
</tr>
|
||||
<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">模型</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">耗时(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>
|
||||
<tbody class="bg-white divide-y divide-gray-200">`;
|
||||
const sortedModels = Object.entries(data).sort(
|
||||
([, countA], [, countB]) => countB - countA
|
||||
);
|
||||
sortedModels.forEach(([model, count]) => {
|
||||
data.forEach((row) => {
|
||||
const timestamp = new Date(row.timestamp).toLocaleString();
|
||||
const statusClass = row.status === 'success' ? 'text-success-600' : 'text-danger-600';
|
||||
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 += `
|
||||
<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-500">${count}</td>
|
||||
</tr>`;
|
||||
<tr>
|
||||
<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">${row.model || 'N/A'}</td>
|
||||
<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 += `
|
||||
</tbody>
|
||||
</table>`;
|
||||
tableHtml += `</tbody></table>`;
|
||||
container.innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
// 设置标题
|
||||
titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`;
|
||||
|
||||
// 显示模态框并设置加载状态
|
||||
modal.classList.remove("hidden");
|
||||
contentArea.innerHTML = `
|
||||
<div class="text-center py-10">
|
||||
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
|
||||
<p class="text-gray-500 mt-2">加载中...</p>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const data = await fetchAPI(`/api/key-usage-details/${key}`);
|
||||
if (data) {
|
||||
renderKeyUsageDetails(data, contentArea);
|
||||
} 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">
|
||||
async function renderForPeriod(period) {
|
||||
try {
|
||||
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');
|
||||
if (canvas && typeof Chart !== 'undefined') {
|
||||
const cfg = buildKeyChartConfig(labels, successData, failureData);
|
||||
if (keyUsageChart) keyUsageChart.destroy();
|
||||
keyUsageChart = new Chart(canvas.getContext('2d'), cfg);
|
||||
}
|
||||
renderKeyUsageTable(details || []);
|
||||
} catch (e) {
|
||||
console.error('加载密钥期内详情失败:', e);
|
||||
const tableContainer = document.getElementById('keyUsageTable');
|
||||
if (tableContainer) {
|
||||
tableContainer.innerHTML = `<div class="text-center py-10 text-danger-500">
|
||||
<i class="fas fa-exclamation-triangle text-3xl"></i>
|
||||
<p class="mt-2">加载失败: ${apiError.message}</p>
|
||||
<p class="mt-2">加载失败: ${e.message}</p>
|
||||
</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');
|
||||
};
|
||||
|
||||
// 关闭密钥使用详情模态框
|
||||
|
||||
@@ -1268,8 +1268,8 @@ endblock %} {% block head_extra_styles %}
|
||||
<span>调用趋势图</span>
|
||||
</h3>
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1887,7 +1887,8 @@ endblock %} {% block head_extra_styles %}
|
||||
<!-- 操作结果模态框 -->
|
||||
<div
|
||||
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
|
||||
class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200"
|
||||
|
||||
Reference in New Issue
Block a user