feat(api,ui): 新增24h错误码最高Key统计与面板

- 新增 GET /api/stats/attention-keys 接口,统计最近24小时指定
  状态码(默认429)错误次数最多的 Key,仅统计内存中的 Key,
  支持 limit 与 status_code 参数
- StatsService 新增 get_attention_keys_last_24h,按 api_key 分组计数并
  降序返回
- UI 新增“值得注意的Key”卡片:支持 429/403/400 快捷切换、自定义状态码
  与数量限制,默认展示 429 前 10
- 列表项支持验证、查看 24h 详情、复制、删除等快捷操作
- 将 Chart.js 与页面脚本改为 defer,保证 DOM 就绪与执行顺序
- 修复:补充获取数量输入框引用,避免初始化未声明变量报错
- 其他:微调日志输出格式
This commit is contained in:
snaily
2025-08-18 06:28:48 +08:00
parent e9601ca76c
commit 1aa3d267bb
4 changed files with 198 additions and 5 deletions

View File

@@ -231,6 +231,35 @@ def setup_api_stats_routes(app: FastAPI) -> None:
)
return {"error": "Internal server error"}, 500
@app.get("/api/stats/attention-keys")
async def api_stats_attention_keys(
request: Request, limit: int = 20, status_code: int = 429
):
"""返回最近24小时指定错误码次数最多的Key仅包含内存Key列表中的。默认错误码429。"""
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 attention-keys")
return {"error": "Unauthorized"}, 401
# 支持所有标准HTTP状态码范围
# if not isinstance(status_code, int) or status_code < 100 or status_code > 599:
# return {"error": f"Unsupported status_code: {status_code}"}, 400
key_manager = await get_key_manager_instance()
keys_status = await key_manager.get_keys_by_status()
in_memory_keys = set(keys_status.get("valid_keys", [])) | set(
keys_status.get("invalid_keys", [])
)
stats_service = StatsService()
data = await stats_service.get_attention_keys_last_24h(
in_memory_keys, limit, status_code
)
return data
except Exception as e:
logger.error(f"Error fetching attention keys: {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):
"""获取指定密钥在指定时间段内的调用详情"""
@@ -240,7 +269,9 @@ def setup_api_stats_routes(app: FastAPI) -> None:
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}")
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

View File

@@ -312,6 +312,41 @@ class StatsService:
)
raise
async def get_attention_keys_last_24h(self, include_keys: set[str], limit: int = 20, status_code: int = 429) -> list[dict]:
"""返回最近24小时内指定状态码(默认429)最多的Key列表仅包含include_keys中的Key。
Returns: [{"key": str, "count": int, "status_code": int}, ...] 按次数降序
"""
try:
now = datetime.datetime.now()
start_time = now - datetime.timedelta(hours=24)
if not include_keys:
return []
query = (
select(
RequestLog.api_key.label("key"),
func.count(RequestLog.id).label("count"),
)
.where(
RequestLog.request_time >= start_time,
RequestLog.status_code == status_code,
RequestLog.api_key.isnot(None),
RequestLog.api_key.in_(list(include_keys)),
)
.group_by(RequestLog.api_key)
.order_by(func.count(RequestLog.id).desc())
.limit(limit)
)
rows = await database.fetch_all(query)
return [
{"key": row["key"], "count": row["count"], "status_code": status_code}
for row in rows
if row["key"]
]
except Exception as e:
logger.error(f"Failed to get attention keys ({status_code}) in last 24h: {e}")
return []
async def get_key_usage_details_last_24h(self, key: str) -> Union[dict, None]:
"""
获取指定 API 密钥在过去 24 小时内按模型统计的调用次数。

View File

@@ -1559,6 +1559,56 @@ async function renderApiChart(period) {
}
}
// --- Helpers for Attention Keys panel ---
// track current active status code tab
let currentStatus = 429;
function getLimit() {
const el = document.getElementById('attentionLimitInput');
const v = parseInt(el && el.value, 10);
if (isNaN(v)) return 10;
// clamp between 1 and 1000 to match input limits
return Math.min(1000, Math.max(1, v));
}
async function fetchAndRenderAttentionKeys(statusCode = 429, limit = 10) {
const listEl = document.getElementById('attentionKeysList');
if (!listEl) return;
try {
const data = await fetchAPI(`/api/stats/attention-keys?status_code=${statusCode}&limit=${limit}`);
listEl.innerHTML = '';
if (!data || (Array.isArray(data) && data.length === 0) || data.error) {
listEl.innerHTML = '<li class="text-center text-gray-500 py-2">暂无需要注意的Key</li>';
return;
}
data.forEach(item => {
const li = document.createElement('li');
li.className = 'flex items-center justify-between bg-white rounded border px-3 py-2';
const masked = item.key ? `${item.key.substring(0,4)}...${item.key.substring(item.key.length-4)}` : 'N/A';
const code = item.status_code ?? statusCode;
li.innerHTML = `
\u003cdiv class=\"flex items-center gap-3\"\u003e
\u003cspan class=\"font-mono text-sm\"\u003e${masked}\u003c/span\u003e
\u003cspan class=\"text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded\"\u003e${code}: ${item.count}\u003c/span\u003e
\u003c/div\u003e
\u003cdiv class=\"flex items-center gap-2\"\u003e
\u003cbutton class=\"px-2 py-1 text-xs rounded bg-success-600 hover:bg-success-700 text-white\" title=\"验证此Key\"\u003e验证\u003c/button\u003e
\u003cbutton class=\"px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white\" title=\"查看24小时详情\"\u003e详情\u003c/button\u003e
\u003cbutton class=\"px-2 py-1 text-xs rounded bg-blue-500 hover:bg-blue-600 text-white\" title=\"复制Key\"\u003e复制\u003c/button\u003e
\u003cbutton class=\"px-2 py-1 text-xs rounded bg-red-800 hover:bg-red-900 text-white\" title=\"删除此Key\"\u003e删除\u003c/button\u003e
\u003c/div\u003e`;
const [verifyBtn, detailBtn, copyBtn, deleteBtn] = li.querySelectorAll('button');
verifyBtn.addEventListener('click', (e) => verifyKey(item.key, e.currentTarget));
detailBtn.addEventListener('click', () => window.showKeyUsageDetails(item.key));
copyBtn.addEventListener('click', () => copyKey(item.key));
deleteBtn.addEventListener('click', (e) => showSingleKeyDeleteConfirmModal(item.key, e.currentTarget));
listEl.appendChild(li);
});
} catch (e) {
listEl.innerHTML = `<li class="text-center text-red-500 py-2">加载失败: ${e.message}</li>`;
}
}
function initChartControls() {
const btn1h = document.getElementById('chartBtn1h');
const btn8h = document.getElementById('chartBtn8h');
@@ -1585,6 +1635,53 @@ function initChartControls() {
renderApiChart('1h');
}
function initAttentionKeysControls() {
const btn429 = document.getElementById('attentionErr429');
const btn403 = document.getElementById('attentionErr403');
const btn400 = document.getElementById('attentionErr400');
// 修复:补充获取数量输入框,避免未声明变量导致初始化报错
const limitInput = document.getElementById('attentionLimitInput');
const setActive = (activeBtn) => {
[btn429, btn403, btn400].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 (btn429) btn429.addEventListener('click', () => { setActive(btn429); currentStatus = 429; fetchAndRenderAttentionKeys(429, getLimit()); });
if (btn403) btn403.addEventListener('click', () => { setActive(btn403); currentStatus = 403; fetchAndRenderAttentionKeys(403, getLimit()); });
if (btn400) btn400.addEventListener('click', () => { setActive(btn400); currentStatus = 400; fetchAndRenderAttentionKeys(400, getLimit()); });
// 自定义查询
const input = document.getElementById('attentionErrCustom');
const go = document.getElementById('attentionErrGo');
const trigger = () => {
if (!input) return;
const val = parseInt(input.value, 10);
if (!isNaN(val) && val >= 100 && val <= 599) {
setActive(null);
[btn429, btn403, btn400].forEach(btn=>{ if(btn){ btn.classList.add('bg-gray-200'); btn.classList.remove('bg-primary-600','text-white'); }});
currentStatus = val;
fetchAndRenderAttentionKeys(val, getLimit());
} else {
showNotification('请输入100-599之间的HTTP状态码', 'warning');
}
};
if (go) go.addEventListener('click', trigger);
if (input) input.addEventListener('keydown', (e)=>{ if(e.key==='Enter'){ trigger(); }});
// limit变化实时刷新当前状态码
if (limitInput) limitInput.addEventListener('change', () => {
fetchAndRenderAttentionKeys(currentStatus, getLimit());
});
if (btn429) setActive(btn429); // default active
}
// 初始化
document.addEventListener("DOMContentLoaded", () => {
initializePageAnimationsAndEffects();
@@ -1596,6 +1693,8 @@ document.addEventListener("DOMContentLoaded", () => {
registerServiceWorker();
initializeDropdownMenu(); // 初始化下拉菜单
initChartControls(); // 初始化图表与时间区间切换
initAttentionKeysControls(); // 初始化值得注意的Key错误码切换
fetchAndRenderAttentionKeys(429, 10); // 默认渲染429数量10
// Initial batch actions update might be needed if not covered by displayPage
// updateBatchActions('valid');

View File

@@ -1109,9 +1109,9 @@ endblock %} {% block head_extra_styles %}
</style>
{% endblock %} {% block head_extra_scripts %}
<!-- Chart.js for time-series chart -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<!-- keys_status.js needs to be loaded in head because it might be used by inline scripts -->
<script src="/static/js/keys_status.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer></script>
<!-- Load page script with defer to guarantee DOM is ready and keep execution order -->
<script src="/static/js/keys_status.js" defer></script>
{% endblock %} {% block content %}
<div class="container max-w-6xl mx-auto px-4">
<!-- Increased max-width -->
@@ -1276,7 +1276,35 @@ endblock %} {% block head_extra_styles %}
<div class="p-4 chart-container">
<canvas id="apiStatsChart"></canvas>
</div>
</div>
</div>
<!-- 值得注意的 Key 卡片(错误码统计,可切换) -->
<div class="stats-card chart-wide">
<div class="stats-card-header">
<h3 class="stats-card-title">
<i class="fas fa-exclamation-circle"></i>
<span>值得注意的Key24h内错误码最多</span>
</h3>
<div class="flex items-center gap-2 text-xs">
<button id="attentionErr429" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700" title="429 Too Many Requests">429</button>
<button id="attentionErr403" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700" title="403 Forbidden">403</button>
<button id="attentionErr400" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700" title="400 Bad Request">400</button>
<div class="flex items-center gap-1 ml-2">
<input id="attentionErrCustom" type="number" min="100" max="599" placeholder="自定义" class="form-input h-7 w-20 px-2 py-1 text-xs border rounded focus:ring-primary-500 focus:border-primary-500" />
<button id="attentionErrGo" class="px-2 py-1 rounded bg-blue-500 hover:bg-blue-600 text-white" title="查询">查询</button>
</div>
<div class="flex items-center gap-1 ml-3">
<label for="attentionLimitInput" class="text-xs text-gray-600">数量</label>
<input id="attentionLimitInput" type="number" min="1" max="1000" value="10" class="form-input h-7 w-20 px-2 py-1 text-xs border rounded focus:ring-primary-500 focus:border-primary-500" />
</div>
</div>
</div>
<div class="p-4">
<ul id="attentionKeysList" class="space-y-2">
<li class="text-center text-gray-500 py-2">加载中...</li>
</ul>
</div>
</div>
</div>
<!-- 有效密钥区域 -->