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