diff --git a/app/database/services.py b/app/database/services.py index 4ddf664..a92eeae 100644 --- a/app/database/services.py +++ b/app/database/services.py @@ -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 列表批量删除错误日志 (异步)。 diff --git a/app/router/error_log_routes.py b/app/router/error_log_routes.py index 8e9f1f2..762318d 100644 --- a/app/router/error_log_routes.py +++ b/app/router/error_log_routes.py @@ -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(...) diff --git a/app/router/routes.py b/app/router/routes.py index 990a4f7..07b25f4 100644 --- a/app/router/routes.py +++ b/app/router/routes.py @@ -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 diff --git a/app/service/chat/openai_chat_service.py b/app/service/chat/openai_chat_service.py index f743ba7..4553019 100644 --- a/app/service/chat/openai_chat_service.py +++ b/app/service/chat/openai_chat_service.py @@ -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" diff --git a/app/service/error_log/error_log_service.py b/app/service/error_log/error_log_service.py index 3bd84c7..8707b5d 100644 --- a/app/service/error_log/error_log_service.py +++ b/app/service/error_log/error_log_service.py @@ -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 批量删除错误日志。 diff --git a/app/service/stats/stats_service.py b/app/service/stats/stats_service.py index e371ad8..43de5b3 100644 --- a/app/service/stats/stats_service.py +++ b/app/service/stats/stats_service.py @@ -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}" ) diff --git a/app/static/js/keys_status.js b/app/static/js/keys_status.js index b54e94e..3cebac1 100644 --- a/app/static/js/keys_status.js +++ b/app/static/js/keys_status.js @@ -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 = ` +
Key: ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}
+
模型: ${detail.model_name || 'N/A'}
+
时间: ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}
+
错误类型: ${detail.error_type || 'N/A'}
+ `; + 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 = ` +
Key: ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}
+
模型: ${detail.model_name || 'N/A'}
+
时间: ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}
+
错误码: ${detail.error_code ?? 'N/A'}
+
错误类型: ${detail.error_type || 'N/A'}
+ `; + 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 = ` -
-

期间调用概览:

-
-
-

总计

-

${totalCalls}

-
-
-

成功

-

${successCalls}

-
-
-

失败

-

${failureCalls}

-
-
+
+ + + + + + + + + + + + + + + + + +
总计成功失败成功率
${totalCalls}${successCalls}${failureCalls}${succRate}%
`; } @@ -1907,7 +2000,10 @@ function renderApiCallDetails( 时间 密钥 (部分) 模型 + 状态码 + 耗时(ms) 状态 + 详情 @@ -1928,17 +2024,25 @@ function renderApiCallDetails( const statusIcon = call.status === "success" ? "fa-check-circle" : "fa-times-circle"; +const detailsBtn = + call.status === "failure" + ? `` + : "-"; + tableHtml += ` ${timestamp} ${keyDisplay} - ${ - call.model || "N/A" - } + ${call.model || "N/A"} + ${call.status_code ?? "-"} + ${call.latency_ms ?? "-"} ${call.status} + ${detailsBtn} `; }); @@ -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 = ` +
+ + + +
+
+ +
+
`; + 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 = `
-

该密钥在最近24小时内没有调用记录。

+

该时间段内没有 API 调用记录。

`; return; } let tableHtml = ` - - - - + + + + + + + + `; - 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' + ? `` + : '-'; tableHtml += ` - - - - `; + + + + + + + + `; }); - tableHtml += ` - -
模型名称调用次数 (24h)
时间模型状态码耗时(ms)状态详情
${model}${count}
${timestamp}${row.model || 'N/A'}${row.status_code ?? '-'}${row.latency_ms ?? '-'}${row.status}${detailsBtn}
`; + tableHtml += ``; container.innerHTML = tableHtml; } - - // 设置标题 - titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`; - - // 显示模态框并设置加载状态 - modal.classList.remove("hidden"); - contentArea.innerHTML = ` -
- -

加载中...

-
`; - - 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 = ` -
+ 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 = `
-

加载失败: ${apiError.message}

+

加载失败: ${e.message}

`; + } + } } + + // 绑定按钮事件与默认加载 + 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'); }; // 关闭密钥使用详情模态框 diff --git a/app/templates/keys_status.html b/app/templates/keys_status.html index 90c6c10..4639478 100644 --- a/app/templates/keys_status.html +++ b/app/templates/keys_status.html @@ -1268,8 +1268,8 @@ endblock %} {% block head_extra_styles %} 调用趋势图
- +
@@ -1887,7 +1887,8 @@ endblock %} {% block head_extra_styles %}