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

View File

@@ -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 列表批量删除错误日志 (异步)。

View File

@@ -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(...)

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)}"
)
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

View File

@@ -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"

View File

@@ -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 批量删除错误日志。

View File

@@ -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}"
)

View File

@@ -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');
};
// 关闭密钥使用详情模态框

View File

@@ -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"