| <!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;
|
| }
|
|
|
| .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;
|
|
|
| const urlParams = new URLSearchParams(window.location.search);
|
| const key = urlParams.get('key');
|
|
|
| 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;
|
| }
|
|
|
|
|
| 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') || '{}');
|
|
|
|
|
| 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 {
|
|
|
| const lastLogTime = new Date(lastLog.time);
|
| const now = new Date();
|
| const diffMinutes = (now - lastLogTime) / 1000 / 60;
|
| if (diffMinutes > 5) {
|
| status = 'timeout';
|
| statusColor = '#ffc107';
|
| statusText = '超时';
|
| }
|
| }
|
|
|
|
|
| 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;
|
|
|
|
|
| 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();
|
|
|
|
|
| if (tag.startsWith('req_')) {
|
| continue;
|
| }
|
|
|
| else if (tag.startsWith('account_')) {
|
| accountId = tag;
|
| } else {
|
|
|
| categoryTags.push(tag);
|
| }
|
| }
|
|
|
| displayMsg = remainingMsg;
|
|
|
|
|
| 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('');
|
|
|
|
|
| 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');
|
| }
|
|
|
|
|
| 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> |