xiaoyukkkk's picture
Upload 2 files
eee7141 verified
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>日志查看器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: 'Consolas', 'Monaco', monospace;
background: #fafaf9;
display: flex;
align-items: center;
justify-content: center;
padding: 15px;
}
.container {
width: 100%;
max-width: 1400px;
height: calc(100vh - 30px);
background: white;
border-radius: 16px;
padding: 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
display: flex;
flex-direction: column;
}
h1 { color: #1a1a1a; font-size: 22px; font-weight: 600; margin-bottom: 20px; text-align: center; }
.stats {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat {
background: #fafaf9;
padding: 12px;
border: 1px solid #e5e5e5;
border-radius: 8px;
text-align: center;
transition: all 0.15s ease;
}
.stat:hover { border-color: #d4d4d4; }
.stat-label { color: #6b6b6b; font-size: 11px; margin-bottom: 4px; }
.stat-value { color: #1a1a1a; font-size: 18px; font-weight: 600; }
.controls {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.controls input, .controls select, .controls button {
padding: 6px 10px;
border: 1px solid #e5e5e5;
border-radius: 8px;
font-size: 13px;
}
.controls select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3 5L6 8L9 5' stroke='%236b6b6b' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
.controls input[type="text"] { flex: 1; min-width: 150px; }
.controls button {
background: #1a73e8;
color: white;
border: none;
cursor: pointer;
font-weight: 500;
transition: background 0.15s ease;
display: flex;
align-items: center;
gap: 6px;
}
.controls button:hover { background: #1557b0; }
.controls button.danger { background: #dc2626; }
.controls button.danger:hover { background: #b91c1c; }
.controls button svg { flex-shrink: 0; }
.log-container {
flex: 1;
background: #fafaf9;
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 12px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.15) transparent;
}
/* Webkit 滚动条样式 - 更窄且不占位 */
.log-container::-webkit-scrollbar {
width: 4px;
}
.log-container::-webkit-scrollbar-track {
background: transparent;
}
.log-container::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.15);
border-radius: 2px;
}
.log-container::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.3);
}
.log-entry {
padding: 8px 10px;
margin-bottom: 4px;
background: white;
border-radius: 6px;
border: 1px solid #e5e5e5;
font-size: 12px;
color: #1a1a1a;
display: flex;
align-items: center;
gap: 8px;
word-break: break-word;
}
.log-entry > div:first-child {
display: flex;
align-items: center;
gap: 8px;
}
.log-message {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.log-entry:hover { border-color: #d4d4d4; }
.log-time { color: #6b6b6b; }
.log-level {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
}
.log-level::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
}
.log-level.INFO { background: #e3f2fd; color: #1976d2; }
.log-level.INFO::before { background: #1976d2; }
.log-level.WARNING { background: #fff3e0; color: #f57c00; }
.log-level.WARNING::before { background: #f57c00; }
.log-level.ERROR { background: #ffebee; color: #d32f2f; }
.log-level.ERROR::before { background: #d32f2f; }
.log-level.DEBUG { background: #f3e5f5; color: #7b1fa2; }
.log-level.DEBUG::before { background: #7b1fa2; }
.log-group {
margin-bottom: 8px;
border: 1px solid #e5e5e5;
border-radius: 8px;
background: white;
}
.log-group-header {
padding: 10px 12px;
background: #f9f9f9;
border-radius: 8px 8px 0 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.15s ease;
}
.log-group-header:hover {
background: #f0f0f0;
}
.log-group-content {
padding: 8px;
}
.log-group .log-entry {
margin-bottom: 4px;
}
.log-group .log-entry:last-child {
margin-bottom: 0;
}
.toggle-icon {
display: inline-block;
transition: transform 0.2s ease;
}
.toggle-icon.collapsed {
transform: rotate(-90deg);
}
@media (max-width: 768px) {
body { padding: 0; }
.container { padding: 15px; height: 100vh; border-radius: 0; max-width: 100%; }
h1 { font-size: 18px; margin-bottom: 12px; }
.stats { grid-template-columns: repeat(3, 1fr); gap: 8px; }
.stat { padding: 8px; }
.controls { gap: 6px; }
.controls input, .controls select { min-height: 38px; }
.controls select { flex: 0 0 auto; }
.controls input[type="text"] { flex: 1 1 auto; min-width: 80px; }
.controls input[type="number"] { flex: 0 0 60px; }
.controls button { padding: 10px 8px; font-size: 12px; flex: 1 1 22%; justify-content: center; min-height: 38px; }
.log-entry {
font-size: 12px;
padding: 10px;
gap: 8px;
flex-direction: column;
align-items: flex-start;
}
.log-entry > div:first-child {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
flex-wrap: wrap;
}
.log-time { font-size: 11px; color: #9e9e9e; }
.log-level { font-size: 10px; }
.log-message {
width: 100%;
white-space: normal;
word-break: break-word;
line-height: 1.5;
margin-top: 4px;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Gemini API 日志查看器</h1>
<div class="stats">
<div class="stat">
<div class="stat-label">总数</div>
<div class="stat-value" id="total-count">-</div>
</div>
<div class="stat">
<div class="stat-label">对话</div>
<div class="stat-value" id="chat-count">-</div>
</div>
<div class="stat">
<div class="stat-label">INFO</div>
<div class="stat-value" id="info-count">-</div>
</div>
<div class="stat">
<div class="stat-label">WARNING</div>
<div class="stat-value" id="warning-count">-</div>
</div>
<div class="stat">
<div class="stat-label">ERROR</div>
<div class="stat-value" id="error-count">-</div>
</div>
<div class="stat">
<div class="stat-label">更新</div>
<div class="stat-value" id="last-update" style="font-size: 11px;">-</div>
</div>
</div>
<div class="controls">
<select id="level-filter">
<option value="">全部</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<input type="text" id="search-input" placeholder="搜索...">
<input type="number" id="limit-input" value="1500" min="10" max="3000" step="100" style="width: 80px;">
<button onclick="loadLogs()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
查询
</button>
<button onclick="exportJSON()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
导出
</button>
<button id="auto-refresh-btn" onclick="toggleAutoRefresh()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
自动刷新
</button>
<button onclick="clearAllLogs()" class="danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
清空
</button>
</div>
<div class="log-container" id="log-container">
<div style="color: #6b6b6b;">正在加载...</div>
</div>
</div>
<script>
let autoRefreshTimer = null;
async function loadLogs() {
const level = document.getElementById('level-filter').value;
const search = document.getElementById('search-input').value;
const limit = document.getElementById('limit-input').value;
// 从当前 URL 获取 key 参数
const urlParams = new URLSearchParams(window.location.search);
const key = urlParams.get('key');
// 构建 API URL(直接使用 /admin/log)
let url = `/admin/log?limit=${limit}`;
if (key) url += `&key=${key}`;
if (level) url += `&level=${level}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data && data.logs) {
displayLogs(data.logs);
updateStats(data.stats);
document.getElementById('last-update').textContent = new Date().toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
} else {
throw new Error('Invalid data format');
}
} catch (error) {
document.getElementById('log-container').innerHTML = '<div class="log-entry ERROR">加载失败: ' + error.message + '</div>';
}
}
function updateStats(stats) {
document.getElementById('total-count').textContent = stats.memory.total;
document.getElementById('info-count').textContent = stats.memory.by_level.INFO || 0;
document.getElementById('warning-count').textContent = stats.memory.by_level.WARNING || 0;
const errorCount = document.getElementById('error-count');
errorCount.textContent = stats.memory.by_level.ERROR || 0;
if (stats.errors && stats.errors.count > 0) errorCount.style.color = '#dc2626';
document.getElementById('chat-count').textContent = stats.chat_count || 0;
}
// 分类颜色配置(提取到外部避免重复定义)
const CATEGORY_COLORS = {
'SYSTEM': '#9e9e9e',
'CONFIG': '#607d8b',
'LOG': '#9e9e9e',
'AUTH': '#4caf50',
'SESSION': '#00bcd4',
'FILE': '#ff9800',
'CHAT': '#2196f3',
'API': '#8bc34a',
'CACHE': '#9c27b0',
'ACCOUNT': '#f44336',
'MULTI': '#673ab7'
};
// 账户颜色配置(提取到外部避免重复定义)
const ACCOUNT_COLORS = {
'account_1': '#9c27b0',
'account_2': '#e91e63',
'account_3': '#00bcd4',
'account_4': '#4caf50',
'account_5': '#ff9800'
};
function getCategoryColor(category) {
return CATEGORY_COLORS[category] || '#757575';
}
function getAccountColor(accountId) {
return ACCOUNT_COLORS[accountId] || '#757575';
}
function displayLogs(logs) {
const container = document.getElementById('log-container');
if (logs.length === 0) {
container.innerHTML = '<div class="log-entry">暂无日志</div>';
return;
}
// 按请求ID分组
const groups = {};
const ungrouped = [];
logs.forEach(log => {
const msg = escapeHtml(log.message);
const reqMatch = msg.match(/\[req_([a-z0-9]+)\]/);
if (reqMatch) {
const reqId = reqMatch[1];
if (!groups[reqId]) {
groups[reqId] = [];
}
groups[reqId].push(log);
} else {
ungrouped.push(log);
}
});
// 渲染分组
let html = '';
// 先渲染未分组的日志
ungrouped.forEach(log => {
html += renderLogEntry(log);
});
// 读取折叠状态
const foldState = JSON.parse(localStorage.getItem('log-fold-state') || '{}');
// 按请求ID分组渲染(最新的组在下面)
Object.keys(groups).forEach(reqId => {
const groupLogs = groups[reqId];
const firstLog = groupLogs[0];
const lastLog = groupLogs[groupLogs.length - 1];
// 判断状态
let status = 'in_progress';
let statusColor = '#ff9800';
let statusText = '进行中';
if (lastLog.message.includes('响应完成') || lastLog.message.includes('非流式响应完成')) {
status = 'success';
statusColor = '#4caf50';
statusText = '成功';
} else if (lastLog.level === 'ERROR' || lastLog.message.includes('失败')) {
status = 'error';
statusColor = '#f44336';
statusText = '失败';
} else {
// 检查超时(最后日志超过 5 分钟)
const lastLogTime = new Date(lastLog.time);
const now = new Date();
const diffMinutes = (now - lastLogTime) / 1000 / 60;
if (diffMinutes > 5) {
status = 'timeout';
statusColor = '#ffc107';
statusText = '超时';
}
}
// 提取账户ID和模型
const accountMatch = firstLog.message.match(/\[account_(\d+)\]/);
const modelMatch = firstLog.message.match(/收到请求: ([^ ]+)/);
const accountId = accountMatch ? `account_${accountMatch[1]}` : '';
const model = modelMatch ? modelMatch[1] : '';
// 检查折叠状态
const isCollapsed = foldState[reqId] === true;
const contentStyle = isCollapsed ? 'style="display: none;"' : '';
const iconClass = isCollapsed ? 'class="toggle-icon collapsed"' : 'class="toggle-icon"';
html += `
<div class="log-group" data-req-id="${reqId}">
<div class="log-group-header" onclick="toggleGroup('${reqId}')">
<span style="color: ${statusColor}; font-weight: 600; font-size: 11px;">⬤ ${statusText}</span>
<span style="color: #666; font-size: 11px; margin-left: 8px;">req_${reqId}</span>
${accountId ? `<span style="color: ${getAccountColor(accountId)}; font-size: 11px; margin-left: 8px;">${accountId}</span>` : ''}
${model ? `<span style="color: #999; font-size: 11px; margin-left: 8px;">${model}</span>` : ''}
<span style="color: #999; font-size: 11px; margin-left: 8px;">${groupLogs.length}条日志</span>
<span ${iconClass} style="margin-left: auto; color: #999;">▼</span>
</div>
<div class="log-group-content" ${contentStyle}>
${groupLogs.map(log => renderLogEntry(log)).join('')}
</div>
</div>
`;
});
container.innerHTML = html;
// 自动滚动到底部,显示最新日志
container.scrollTop = container.scrollHeight;
}
function renderLogEntry(log) {
const msg = escapeHtml(log.message);
let displayMsg = msg;
let categoryTags = [];
let accountId = null;
// 解析所有标签:[CATEGORY1] [CATEGORY2] [account_X] [req_X] message
let remainingMsg = msg;
const tagRegex = /^\[([A-Z_a-z0-9]+)\]/;
while (true) {
const match = remainingMsg.match(tagRegex);
if (!match) break;
const tag = match[1];
remainingMsg = remainingMsg.substring(match[0].length).trim();
// 跳过req_标签(已在组头部显示)
if (tag.startsWith('req_')) {
continue;
}
// 判断是否为账户ID
else if (tag.startsWith('account_')) {
accountId = tag;
} else {
// 普通分类标签
categoryTags.push(tag);
}
}
displayMsg = remainingMsg;
// 生成分类标签HTML
const categoryTagsHtml = categoryTags.map(cat =>
`<span class="log-category" style="background: ${getCategoryColor(cat)}; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 2px;">${cat}</span>`
).join('');
// 生成账户标签HTML
const accountTagHtml = accountId
? `<span style="color: ${getAccountColor(accountId)}; font-size: 11px; font-weight: 600; margin-left: 2px;">${accountId}</span>`
: '';
return `
<div class="log-entry ${log.level}">
<div>
<span class="log-time">${log.time}</span>
<span class="log-level ${log.level}">${log.level}</span>
${categoryTagsHtml}
${accountTagHtml}
</div>
<div class="log-message">${displayMsg}</div>
</div>
`;
}
function toggleGroup(reqId) {
const group = document.querySelector(`.log-group[data-req-id="${reqId}"]`);
const content = group.querySelector('.log-group-content');
const icon = group.querySelector('.toggle-icon');
const isCollapsed = content.style.display === 'none';
if (isCollapsed) {
content.style.display = 'block';
icon.classList.remove('collapsed');
} else {
content.style.display = 'none';
icon.classList.add('collapsed');
}
// 保存折叠状态到 localStorage
const foldState = JSON.parse(localStorage.getItem('log-fold-state') || '{}');
foldState[reqId] = !isCollapsed;
localStorage.setItem('log-fold-state', JSON.stringify(foldState));
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function exportJSON() {
try {
const urlParams = new URLSearchParams(window.location.search);
const key = urlParams.get('key');
let url = `/admin/log?limit=3000`;
if (key) url += `&key=${key}`;
const response = await fetch(url);
const data = await response.json();
const blob = new Blob([JSON.stringify({exported_at: new Date().toISOString(), logs: data.logs}, null, 2)], {type: 'application/json'});
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = 'logs_' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.json';
a.click();
URL.revokeObjectURL(blobUrl);
alert('导出成功');
} catch (error) {
alert('导出失败: ' + error.message);
}
}
async function clearAllLogs() {
if (!confirm('确定清空所有日志?')) return;
try {
const urlParams = new URLSearchParams(window.location.search);
const key = urlParams.get('key');
let url = `/admin/log?confirm=yes`;
if (key) url += `&key=${key}`;
const response = await fetch(url, {method: 'DELETE'});
if (response.ok) {
alert('已清空');
loadLogs();
} else {
alert('清空失败');
}
} catch (error) {
alert('清空失败: ' + error.message);
}
}
let autoRefreshEnabled = true;
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
const btn = document.getElementById('auto-refresh-btn');
if (autoRefreshEnabled) {
btn.style.background = '#1a73e8';
autoRefreshTimer = setInterval(loadLogs, 5000);
} else {
btn.style.background = '#6b6b6b';
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
}
document.addEventListener('DOMContentLoaded', () => {
loadLogs();
autoRefreshTimer = setInterval(loadLogs, 5000);
document.getElementById('search-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') loadLogs();
});
document.getElementById('level-filter').addEventListener('change', loadLogs);
document.getElementById('limit-input').addEventListener('change', loadLogs);
});
</script>
</body>
</html>