mirror of
https://github.com/rnvm9wjdtj-bot/myaps_api.git
synced 2026-06-02 05:54:40 +00:00
1012 lines
36 KiB
HTML
1012 lines
36 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title data-i18n-page-title="page.live_logs">实时日志 - 系统监控</title>
|
|
<link rel="icon" href="/static/swagger/favicon.png" type="image/png">
|
|
<link rel="stylesheet" href="/static/monitor/css/monitor.css">
|
|
<script src="/static/lib/i18n/i18n.js"></script>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
background: #f5f7fa;
|
|
margin: 0;
|
|
padding: 20px;
|
|
}
|
|
.live-logs-container {
|
|
max-width: 100%;
|
|
margin: 0 auto;
|
|
background: #fff;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
|
|
overflow: hidden;
|
|
}
|
|
.live-logs-header {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 15px 30px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 15px;
|
|
}
|
|
.live-logs-header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
}
|
|
.live-logs-header h1 {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
}
|
|
.connection-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
font-size: 13px;
|
|
}
|
|
.status-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
background: #e74c3c;
|
|
}
|
|
.status-dot.connected {
|
|
background: #2ecc71;
|
|
}
|
|
.status-dot.connecting {
|
|
background: #f39c12;
|
|
animation: pulse 1s infinite;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
.header-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.header-control-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.header-control-group label {
|
|
font-size: 12px;
|
|
color: rgba(255, 255, 255, 0.8);
|
|
}
|
|
.header-control-group select,
|
|
.header-control-group input {
|
|
padding: 5px 10px;
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
background: rgba(255, 255, 255, 0.15);
|
|
color: white;
|
|
}
|
|
.header-control-group input::placeholder {
|
|
color: rgba(255, 255, 255, 0.6);
|
|
}
|
|
.header-control-group select option {
|
|
background: #667eea;
|
|
color: white;
|
|
}
|
|
.toggle-switch {
|
|
position: relative;
|
|
width: 44px;
|
|
height: 24px;
|
|
}
|
|
.toggle-switch input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
.toggle-slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: rgba(255, 255, 255, 0.3);
|
|
transition: 0.3s;
|
|
border-radius: 24px;
|
|
}
|
|
.toggle-slider:before {
|
|
position: absolute;
|
|
content: "";
|
|
height: 18px;
|
|
width: 18px;
|
|
left: 3px;
|
|
bottom: 3px;
|
|
background-color: white;
|
|
transition: 0.3s;
|
|
border-radius: 50%;
|
|
}
|
|
.toggle-switch input:checked + .toggle-slider {
|
|
background-color: #2ecc71;
|
|
}
|
|
.toggle-switch input:checked + .toggle-slider:before {
|
|
transform: translateX(20px);
|
|
}
|
|
.btn {
|
|
padding: 6px 14px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
transition: all 0.2s;
|
|
}
|
|
.btn-primary {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
color: white;
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
}
|
|
.btn-primary:hover {
|
|
background: rgba(255, 255, 255, 0.3);
|
|
}
|
|
.btn-secondary {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
color: white;
|
|
}
|
|
.btn-secondary:hover {
|
|
background: rgba(255, 255, 255, 0.25);
|
|
}
|
|
.btn-danger {
|
|
background: rgba(220, 53, 69, 0.8);
|
|
color: white;
|
|
}
|
|
.btn-danger:hover {
|
|
background: rgba(220, 53, 69, 1);
|
|
}
|
|
.logs-container {
|
|
height: calc(100vh - 180px);
|
|
min-height: 400px;
|
|
overflow-y: auto;
|
|
padding: 15px 30px;
|
|
background: #1e1e1e;
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
font-size: 15px;
|
|
line-height: 1.7;
|
|
}
|
|
.log-entry {
|
|
padding: 6px 0;
|
|
border-bottom: 1px solid #2d2d2d;
|
|
display: flex;
|
|
gap: 15px;
|
|
}
|
|
.log-entry:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.log-time {
|
|
color: #888;
|
|
white-space: nowrap;
|
|
width: 85px;
|
|
flex-shrink: 0;
|
|
}
|
|
.log-level {
|
|
font-weight: 600;
|
|
width: 70px;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.log-level.DEBUG { color: #9e9e9e; }
|
|
.log-level.INFO { color: #4fc3f7; }
|
|
.log-level.WARNING { color: #ffb74d; }
|
|
.log-level.ERROR { color: #ef5350; }
|
|
.log-level.CRITICAL { color: #e57373; background: #3e2723; padding: 0 6px; border-radius: 3px; }
|
|
.log-module {
|
|
color: #9e9e9e;
|
|
width: 250px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
.log-message {
|
|
color: #e0e0e0;
|
|
flex: 1;
|
|
word-break: break-all;
|
|
}
|
|
.log-entry.DEBUG .log-message {
|
|
color: #a0a0a0;
|
|
}
|
|
.log-entry.INFO .log-message {
|
|
color: #4fc3f7;
|
|
}
|
|
.log-entry.WARNING .log-message {
|
|
color: #ffb74d;
|
|
}
|
|
.log-entry.ERROR .log-message {
|
|
color: #ef5350;
|
|
}
|
|
.log-entry.CRITICAL .log-message {
|
|
color: #e57373;
|
|
font-weight: 500;
|
|
}
|
|
.no-logs {
|
|
text-align: center;
|
|
color: #666;
|
|
padding: 60px 20px;
|
|
}
|
|
.log-stats {
|
|
display: flex;
|
|
gap: 20px;
|
|
padding: 10px 30px;
|
|
background: #2d2d2d;
|
|
color: #888;
|
|
font-size: 12px;
|
|
}
|
|
.log-stats span {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
.level-count {
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-weight: 500;
|
|
}
|
|
.level-count.DEBUG { background: #424242; color: #888; }
|
|
.level-count.INFO { background: #1565c0; color: #4fc3f7; }
|
|
.level-count.WARNING { background: #e65100; color: #ffb74d; }
|
|
.level-count.ERROR { background: #b71c1c; color: #ef5350; }
|
|
.level-count.CRITICAL { background: #3e2723; color: #e57373; }
|
|
.auto-scroll-indicator {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
background: rgba(102, 126, 234, 0.9);
|
|
color: white;
|
|
padding: 8px 16px;
|
|
border-radius: 20px;
|
|
font-size: 12px;
|
|
display: none;
|
|
}
|
|
.auto-scroll-indicator.show {
|
|
display: block;
|
|
}
|
|
|
|
/* 失活超时模态框 */
|
|
.inactivity-modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: 1000;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
.inactivity-modal.show {
|
|
display: flex;
|
|
}
|
|
.inactivity-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
backdrop-filter: blur(3px);
|
|
}
|
|
.inactivity-content {
|
|
position: relative;
|
|
background: #2d2d2d;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
min-width: 400px;
|
|
max-width: 90%;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
text-align: center;
|
|
}
|
|
.inactivity-content .modal-icon {
|
|
font-size: 48px;
|
|
margin-bottom: 15px;
|
|
}
|
|
.inactivity-content h3 {
|
|
color: #ffb74d;
|
|
margin: 0 0 10px 0;
|
|
font-size: 20px;
|
|
}
|
|
.inactivity-content p {
|
|
color: #a0a0a0;
|
|
margin: 8px 0;
|
|
font-size: 14px;
|
|
}
|
|
.inactivity-content .btn {
|
|
margin-top: 20px;
|
|
padding: 10px 30px;
|
|
font-size: 14px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="live-logs-container">
|
|
<div class="live-logs-header">
|
|
<div class="live-logs-header-left">
|
|
<h3 data-i18n="monitor.page.live_logs">实时日志流</h3>
|
|
<div class="connection-status">
|
|
<div class="status-dot connecting" id="statusDot"></div>
|
|
<span id="statusText" data-i18n="monitor.status.connecting">连接中...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="header-controls">
|
|
<select id="lang-selector" class="lang-selector" onchange="i18n.switchLanguage(this.value)" style="margin-right: 12px;">
|
|
<option value="zh-CN">🇨🇳 中文</option>
|
|
<option value="en-US">🇺🇸 English</option>
|
|
<option value="de-DE">🇩🇪 Deutsch</option>
|
|
</select>
|
|
<div class="header-control-group">
|
|
<label data-i18n="monitor.filter.level">级别:</label>
|
|
<select id="levelFilter" onchange="applyFilters()">
|
|
<option value="" data-i18n="monitor.filter.level">全部</option>
|
|
<option value="INFO" selected>INFO</option>
|
|
<option value="WARNING">WARNING</option>
|
|
<option value="ERROR">ERROR</option>
|
|
<option value="CRITICAL">CRITICAL</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="header-control-group">
|
|
<input type="text" id="searchInput" data-i18n-placeholder="filter.keyword_placeholder" placeholder="搜索日志..." onkeyup="handleSearch(event)">
|
|
</div>
|
|
|
|
<div class="header-control-group">
|
|
<label data-i18n="monitor.other.auto_scroll">自动滚动</label>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="autoScroll" checked onchange="toggleAutoScroll()">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
|
|
<button class="btn btn-secondary" onclick="togglePause()">
|
|
<span id="pauseBtnText" data-i18n="monitor.btn.pause">暂停</span>
|
|
</button>
|
|
|
|
<button class="btn btn-danger" onclick="clearLogs()" data-i18n="monitor.btn.clear">清空</button>
|
|
|
|
<button class="btn btn-primary" onclick="openInNewWindow()" data-i18n="monitor.btn.new_window">新窗口</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="log-stats">
|
|
<span data-i18n="monitor.metric.total">总日志: <strong id="totalCount">0</strong></span>
|
|
<span>DEBUG: <span class="level-count DEBUG" id="debugCount">0</span></span>
|
|
<span>INFO: <span class="level-count INFO" id="infoCount">0</span></span>
|
|
<span>WARNING: <span class="level-count WARNING" id="warningCount">0</span></span>
|
|
<span>ERROR: <span class="level-count ERROR" id="errorCount">0</span></span>
|
|
<span>CRITICAL: <span class="level-count CRITICAL" id="criticalCount">0</span></span>
|
|
</div>
|
|
|
|
<div class="logs-container" id="logsContainer">
|
|
<div class="no-logs" id="noLogs" data-i18n="monitor.other.waiting_logs">
|
|
正在等待日志数据...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="auto-scroll-indicator" id="autoScrollIndicator">
|
|
⬇️ 已暂停自动滚动 - 点击滚动到底部
|
|
</div>
|
|
|
|
<!-- 失活超时模态框 -->
|
|
<div class="inactivity-modal" id="inactivityModal">
|
|
<div class="inactivity-overlay"></div>
|
|
<div class="inactivity-content">
|
|
<div class="modal-icon">⌛</div>
|
|
<h3>日志流已暂停</h3>
|
|
<p>由于长时间未活动,日志流已自动暂停以节省资源。</p>
|
|
<p>如需继续查看实时日志,请点击下方按钮恢复连接。</p>
|
|
<button class="btn btn-primary" onclick="resumeLiveLogs()">恢复连接</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let eventSource = null;
|
|
let logs = [];
|
|
const MAX_LOGS = 500; // 前端日志数量上限,控制内存占用
|
|
let isPaused = false;
|
|
let autoScroll = true;
|
|
let searchTerm = '';
|
|
let levelFilter = 'INFO';
|
|
let stats = { DEBUG: 0, INFO: 0, WARNING: 0, ERROR: 0, CRITICAL: 0 };
|
|
let lastActivity = Date.now();
|
|
const INACTIVITY_TIMEOUT = 180 * 60 * 1000; // 180分钟超时
|
|
|
|
// SSE重连配置
|
|
let reconnectAttempts = 0;
|
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
const BASE_RECONNECT_DELAY = 1000; // 基础重连延迟1秒
|
|
const MAX_RECONNECT_DELAY = 30000; // 最大重连延迟30秒
|
|
|
|
// 心跳检测配置
|
|
let lastHeartbeatTime = Date.now();
|
|
const HEARTBEAT_TIMEOUT = 60000; // 心跳超时60秒
|
|
let heartbeatTimer = null;
|
|
let isDomReady = false;
|
|
let isRendering = false; // 防止渲染冲突
|
|
let pendingRerender = false; // 标记是否有待处理的重渲染
|
|
const levelPriority = { DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50 }; // 全局级别优先级
|
|
let inactivityTimer = null; // 失活检查定时器引用
|
|
let eventListeners = []; // 存储事件监听器引用,用于清理
|
|
|
|
function checkDomReady() {
|
|
const container = document.getElementById('logsContainer');
|
|
const noLogs = document.getElementById('noLogs');
|
|
const statusDot = document.getElementById('statusDot');
|
|
const statusText = document.getElementById('statusText');
|
|
|
|
if (container && noLogs && statusDot && statusText) {
|
|
isDomReady = true;
|
|
console.log('DOM元素已就绪');
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function connect() {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
|
|
// 如果暂停状态,不建立连接
|
|
if (isPaused) {
|
|
return;
|
|
}
|
|
|
|
if (isDomReady) {
|
|
updateStatus('connecting');
|
|
}
|
|
|
|
const sseUrl = `/monitor/api/live-logs/stream?_=${Date.now()}`;
|
|
console.log('正在连接 SSE:', sseUrl);
|
|
|
|
eventSource = new EventSource(sseUrl);
|
|
|
|
eventSource.onopen = () => {
|
|
// 连接成功,重置重连计数器
|
|
reconnectAttempts = 0;
|
|
lastHeartbeatTime = Date.now();
|
|
updateStatus('connected');
|
|
|
|
// 启动心跳检测
|
|
startHeartbeatCheck();
|
|
};
|
|
|
|
eventSource.onmessage = (event) => {
|
|
if (isPaused) return;
|
|
|
|
// 更新心跳时间
|
|
lastHeartbeatTime = Date.now();
|
|
|
|
try {
|
|
if (!event.data) {
|
|
console.warn('收到空数据');
|
|
return;
|
|
}
|
|
|
|
// 处理心跳消息
|
|
if (event.data === 'heartbeat') {
|
|
console.debug('收到心跳');
|
|
return;
|
|
}
|
|
|
|
const logData = JSON.parse(event.data);
|
|
if (!logData || !logData.level || !logData.message) {
|
|
console.warn('无效的日志数据:', logData);
|
|
return;
|
|
}
|
|
addLog(logData);
|
|
} catch (e) {
|
|
console.error('解析日志数据失败:', e);
|
|
}
|
|
};
|
|
|
|
eventSource.onerror = (error) => {
|
|
console.error('SSE 连接错误:', error);
|
|
|
|
// 停止心跳检测
|
|
stopHeartbeatCheck();
|
|
|
|
if (isDomReady) {
|
|
updateStatus('disconnected');
|
|
}
|
|
|
|
// 使用指数退避策略重连
|
|
scheduleReconnect();
|
|
};
|
|
}
|
|
|
|
// 计算指数退避延迟
|
|
function getReconnectDelay() {
|
|
const delay = Math.min(
|
|
BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts),
|
|
MAX_RECONNECT_DELAY
|
|
);
|
|
// 添加随机抖动,避免多个客户端同时重连
|
|
const jitter = delay * 0.1 * (Math.random() - 0.5);
|
|
return delay + jitter;
|
|
}
|
|
|
|
// 调度重连
|
|
function scheduleReconnect() {
|
|
if (isPaused) {
|
|
console.log('当前处于暂停状态,跳过重连');
|
|
return;
|
|
}
|
|
|
|
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
console.error(`已达到最大重连次数(${MAX_RECONNECT_ATTEMPTS}),停止重连`);
|
|
if (isDomReady) {
|
|
const statusText = document.getElementById('statusText');
|
|
if (statusText) {
|
|
statusText.textContent = i18n.t('monitor.error.connection_failed');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const delay = getReconnectDelay();
|
|
reconnectAttempts++;
|
|
|
|
console.log(`第 ${reconnectAttempts} 次重连,延迟 ${Math.round(delay)}ms`);
|
|
|
|
setTimeout(() => {
|
|
// 再次检查状态
|
|
if (!isPaused) {
|
|
connect();
|
|
}
|
|
}, delay);
|
|
}
|
|
|
|
// 启动心跳检测
|
|
function startHeartbeatCheck() {
|
|
stopHeartbeatCheck();
|
|
heartbeatTimer = setInterval(() => {
|
|
const now = Date.now();
|
|
if (now - lastHeartbeatTime > HEARTBEAT_TIMEOUT) {
|
|
console.warn('心跳超时,尝试重连');
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
scheduleReconnect();
|
|
}
|
|
}, 10000); // 每10秒检查一次
|
|
}
|
|
|
|
// 停止心跳检测
|
|
function stopHeartbeatCheck() {
|
|
if (heartbeatTimer) {
|
|
clearInterval(heartbeatTimer);
|
|
heartbeatTimer = null;
|
|
}
|
|
}
|
|
|
|
function updateStatus(status) {
|
|
const dot = document.getElementById('statusDot');
|
|
const text = document.getElementById('statusText');
|
|
|
|
if (!dot || !text) {
|
|
return;
|
|
}
|
|
|
|
dot.className = 'status-dot';
|
|
|
|
switch (status) {
|
|
case 'connected':
|
|
dot.classList.add('connected');
|
|
text.textContent = i18n.t('monitor.status.connected');
|
|
break;
|
|
case 'connecting':
|
|
dot.classList.add('connecting');
|
|
text.textContent = i18n.t('monitor.status.connecting');
|
|
break;
|
|
case 'disconnected':
|
|
text.textContent = i18n.t('monitor.status.disconnected') + ' (' + i18n.t('monitor.other.auto_reconnect') + ')';
|
|
break;
|
|
}
|
|
}
|
|
|
|
let lastRenderIndex = 0; // 记录上次渲染到的索引
|
|
|
|
function addLog(logData) {
|
|
logs.push(logData);
|
|
|
|
// 限制前端日志数量,防止内存泄漏
|
|
if (logs.length > MAX_LOGS) {
|
|
const removeCount = logs.length - MAX_LOGS;
|
|
// 同步更新统计
|
|
for (let i = 0; i < removeCount; i++) {
|
|
const removedLog = logs[i];
|
|
if (stats[removedLog.level] > 0) {
|
|
stats[removedLog.level]--;
|
|
}
|
|
}
|
|
logs.splice(0, removeCount);
|
|
lastRenderIndex = Math.max(0, lastRenderIndex - removeCount);
|
|
}
|
|
|
|
stats[logData.level] = (stats[logData.level] || 0) + 1;
|
|
|
|
if (isDomReady) {
|
|
updateStats();
|
|
scheduleRender(); // 使用调度渲染代替直接渲染
|
|
}
|
|
}
|
|
|
|
let renderDebounceTimer = null;
|
|
|
|
function scheduleRender() {
|
|
// 如果正在渲染,标记待处理
|
|
if (isRendering) {
|
|
pendingRerender = true;
|
|
return;
|
|
}
|
|
|
|
// 防抖:16ms(约1帧)内合并新的渲染请求,平衡性能和响应速度
|
|
clearTimeout(renderDebounceTimer);
|
|
renderDebounceTimer = setTimeout(() => {
|
|
requestAnimationFrame(() => {
|
|
isRendering = true;
|
|
renderLogs();
|
|
isRendering = false;
|
|
|
|
// 如果有新的待处理渲染请求
|
|
if (pendingRerender) {
|
|
pendingRerender = false;
|
|
scheduleRender();
|
|
}
|
|
});
|
|
}, 16);
|
|
}
|
|
|
|
function updateStats() {
|
|
const totalCount = document.getElementById('totalCount');
|
|
const debugCount = document.getElementById('debugCount');
|
|
const infoCount = document.getElementById('infoCount');
|
|
const warningCount = document.getElementById('warningCount');
|
|
const errorCount = document.getElementById('errorCount');
|
|
const criticalCount = document.getElementById('criticalCount');
|
|
|
|
if (!totalCount || !debugCount || !infoCount || !warningCount || !errorCount || !criticalCount) {
|
|
return;
|
|
}
|
|
|
|
totalCount.textContent = logs.length;
|
|
debugCount.textContent = stats.DEBUG || 0;
|
|
infoCount.textContent = stats.INFO || 0;
|
|
warningCount.textContent = stats.WARNING || 0;
|
|
errorCount.textContent = stats.ERROR || 0;
|
|
criticalCount.textContent = stats.CRITICAL || 0;
|
|
}
|
|
|
|
function createLogElement(log) {
|
|
const logEntry = document.createElement('div');
|
|
logEntry.className = `log-entry ${log.level}`;
|
|
|
|
const timeSpan = document.createElement('span');
|
|
timeSpan.className = 'log-time';
|
|
timeSpan.textContent = new Date(log.timestamp * 1000).toLocaleTimeString('zh-CN');
|
|
|
|
const levelSpan = document.createElement('span');
|
|
levelSpan.className = `log-level ${log.level}`;
|
|
levelSpan.textContent = log.level;
|
|
|
|
const moduleSpan = document.createElement('span');
|
|
moduleSpan.className = 'log-module';
|
|
moduleSpan.textContent = log.module;
|
|
moduleSpan.title = log.module;
|
|
|
|
const messageSpan = document.createElement('span');
|
|
messageSpan.className = 'log-message';
|
|
messageSpan.textContent = log.message;
|
|
|
|
logEntry.appendChild(timeSpan);
|
|
logEntry.appendChild(levelSpan);
|
|
logEntry.appendChild(moduleSpan);
|
|
logEntry.appendChild(messageSpan);
|
|
|
|
return logEntry;
|
|
}
|
|
|
|
function renderLogs() {
|
|
const container = document.getElementById('logsContainer');
|
|
if (!container) {
|
|
console.error('日志容器元素 logsContainer 未找到');
|
|
return;
|
|
}
|
|
|
|
if (logs.length === 0) {
|
|
container.innerHTML = '<div class="no-logs" id="noLogs">' + i18n.t('monitor.other.waiting_logs') + '</div>';
|
|
lastRenderIndex = 0;
|
|
return;
|
|
}
|
|
|
|
const hasFilter = levelFilter || searchTerm;
|
|
|
|
if (hasFilter) {
|
|
// 有过滤器时,需要全量渲染
|
|
const filteredLogs = logs.filter(log => {
|
|
if (levelFilter && levelPriority[log.level] < levelPriority[levelFilter]) return false;
|
|
if (searchTerm && !log.message.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
|
return true;
|
|
});
|
|
|
|
if (filteredLogs.length === 0) {
|
|
container.innerHTML = '<div class="no-logs" id="noLogs">' + i18n.t('monitor.other.no_matching_logs') + '</div>';
|
|
lastRenderIndex = 0;
|
|
return;
|
|
}
|
|
|
|
// 使用 DocumentFragment 提高 DOM 操作效率
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
for (const log of filteredLogs) {
|
|
fragment.appendChild(createLogElement(log));
|
|
}
|
|
|
|
container.innerHTML = '';
|
|
container.appendChild(fragment);
|
|
lastRenderIndex = logs.length;
|
|
|
|
if (autoScroll && !isPaused) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
} else {
|
|
// 无过滤器时,使用增量更新
|
|
const newLogs = logs.slice(lastRenderIndex);
|
|
|
|
if (newLogs.length > 0) {
|
|
const fragment = document.createDocumentFragment();
|
|
for (const log of newLogs) {
|
|
fragment.appendChild(createLogElement(log));
|
|
}
|
|
container.appendChild(fragment);
|
|
lastRenderIndex = logs.length;
|
|
}
|
|
|
|
// 检查是否需要清理超出显示范围的旧日志(保持DOM节点数量合理)
|
|
const maxDomEntries = 500; // 最多保持500个DOM节点
|
|
const childCount = container.children.length;
|
|
if (childCount > maxDomEntries) {
|
|
const removeCount = childCount - maxDomEntries;
|
|
for (let i = 0; i < removeCount; i++) {
|
|
container.removeChild(container.firstChild);
|
|
}
|
|
}
|
|
|
|
if (autoScroll && !isPaused) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyFilters() {
|
|
const levelFilterEl = document.getElementById('levelFilter');
|
|
const searchInputEl = document.getElementById('searchInput');
|
|
|
|
if (levelFilterEl) {
|
|
levelFilter = levelFilterEl.value;
|
|
}
|
|
if (searchInputEl) {
|
|
searchTerm = searchInputEl.value;
|
|
}
|
|
scheduleRender();
|
|
}
|
|
|
|
function handleSearch(event) {
|
|
if (event.key === 'Enter') {
|
|
applyFilters();
|
|
}
|
|
}
|
|
|
|
function togglePause() {
|
|
isPaused = !isPaused;
|
|
const pauseBtnText = document.getElementById('pauseBtnText');
|
|
const autoScrollIndicator = document.getElementById('autoScrollIndicator');
|
|
|
|
if (pauseBtnText) {
|
|
pauseBtnText.textContent = isPaused ? i18n.t('monitor.btn.resume') : i18n.t('monitor.btn.pause');
|
|
}
|
|
if (autoScrollIndicator) {
|
|
autoScrollIndicator.classList.toggle('show', isPaused);
|
|
}
|
|
|
|
if (isPaused) {
|
|
// 暂停时关闭 SSE 连接,节省资源
|
|
stopHeartbeatCheck();
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
if (isDomReady) {
|
|
updateStatus('disconnected');
|
|
}
|
|
}
|
|
// 重置重连计数器
|
|
reconnectAttempts = 0;
|
|
} else {
|
|
// 继续时重新建立连接
|
|
reconnectAttempts = 0;
|
|
connect();
|
|
}
|
|
}
|
|
|
|
function toggleAutoScroll() {
|
|
const autoScrollEl = document.getElementById('autoScroll');
|
|
if (autoScrollEl) {
|
|
autoScroll = autoScrollEl.checked;
|
|
}
|
|
if (autoScroll && !isPaused) {
|
|
const container = document.getElementById('logsContainer');
|
|
if (container) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearLogs() {
|
|
logs = [];
|
|
stats = { DEBUG: 0, INFO: 0, WARNING: 0, ERROR: 0, CRITICAL: 0 };
|
|
lastRenderIndex = 0;
|
|
updateStats();
|
|
const logsContainer = document.getElementById('logsContainer');
|
|
if (logsContainer) {
|
|
logsContainer.innerHTML = '<div class="no-logs">' + i18n.t('monitor.success.logs_cleared') + '</div>';
|
|
}
|
|
}
|
|
|
|
function openInNewWindow() {
|
|
window.open('/monitor/live-logs', '_blank');
|
|
}
|
|
|
|
const logsContainer = document.getElementById('logsContainer');
|
|
const autoScrollIndicator = document.getElementById('autoScrollIndicator');
|
|
|
|
// 添加事件监听器并存储引用(使用唯一名称避免覆盖全局方法)
|
|
function bindEventListener(el, event, handler) {
|
|
el.addEventListener(event, handler);
|
|
eventListeners.push({ el, event, handler });
|
|
}
|
|
|
|
if (logsContainer) {
|
|
bindEventListener(logsContainer, 'click', () => {
|
|
if (isPaused) {
|
|
togglePause();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (autoScrollIndicator) {
|
|
bindEventListener(autoScrollIndicator, 'click', () => {
|
|
if (isPaused) {
|
|
togglePause();
|
|
}
|
|
const autoScrollEl = document.getElementById('autoScroll');
|
|
if (autoScrollEl) {
|
|
autoScrollEl.checked = true;
|
|
}
|
|
autoScroll = true;
|
|
const container = document.getElementById('logsContainer');
|
|
if (container) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
});
|
|
}
|
|
|
|
// 每10秒检查一次失活状态
|
|
inactivityTimer = setInterval(() => {
|
|
if (Date.now() - lastActivity >= INACTIVITY_TIMEOUT && !isPaused) {
|
|
handleInactivityTimeout();
|
|
}
|
|
}, 10000);
|
|
|
|
// 监听用户活动,重置失活计时器
|
|
const activityHandler = () => { lastActivity = Date.now(); };
|
|
bindEventListener(window, 'mousemove', activityHandler);
|
|
bindEventListener(window, 'keydown', activityHandler);
|
|
bindEventListener(window, 'scroll', activityHandler);
|
|
bindEventListener(window, 'click', activityHandler);
|
|
|
|
// 处理失活超时
|
|
function handleInactivityTimeout() {
|
|
togglePause();
|
|
showInactivityModal();
|
|
}
|
|
|
|
// 显示失活模态框
|
|
function showInactivityModal() {
|
|
const modal = document.getElementById('inactivityModal');
|
|
if (modal) {
|
|
modal.classList.add('show');
|
|
}
|
|
}
|
|
|
|
// 隐藏失活模态框
|
|
function hideInactivityModal() {
|
|
const modal = document.getElementById('inactivityModal');
|
|
if (modal) {
|
|
modal.classList.remove('show');
|
|
}
|
|
}
|
|
|
|
// 恢复日志流
|
|
function resumeLiveLogs() {
|
|
hideInactivityModal();
|
|
togglePause();
|
|
lastActivity = Date.now();
|
|
}
|
|
|
|
// 清理所有事件监听器
|
|
function cleanupEventListeners() {
|
|
eventListeners.forEach(({ el, event, handler }) => {
|
|
el.removeEventListener(event, handler);
|
|
});
|
|
eventListeners = [];
|
|
}
|
|
|
|
// 页面卸载时清理资源,防止内存泄漏
|
|
window.addEventListener('beforeunload', () => {
|
|
// 关闭 SSE 连接
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
|
|
// 清理定时器
|
|
if (inactivityTimer) {
|
|
clearInterval(inactivityTimer);
|
|
inactivityTimer = null;
|
|
}
|
|
|
|
// 清理心跳检测定时器
|
|
stopHeartbeatCheck();
|
|
|
|
// 清理渲染防抖定时器
|
|
if (renderDebounceTimer) {
|
|
clearTimeout(renderDebounceTimer);
|
|
renderDebounceTimer = null;
|
|
}
|
|
|
|
// 清理事件监听器
|
|
cleanupEventListeners();
|
|
});
|
|
|
|
async function checkServiceStatus() {
|
|
try {
|
|
const response = await fetch('/monitor/api/live-logs/status');
|
|
const status = await response.json();
|
|
console.log('日志流服务状态:', status);
|
|
return status.is_running;
|
|
} catch (error) {
|
|
console.error('检查服务状态失败:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// DOMContentLoaded 事件触发时,DOM已经就绪
|
|
if (checkDomReady()) {
|
|
updateStats();
|
|
renderLogs();
|
|
}
|
|
// 立即连接,不要延迟
|
|
connect();
|
|
|
|
// 检查服务状态(仅用于日志输出)
|
|
const serviceStatus = await checkServiceStatus();
|
|
console.log('日志流服务是否运行:', serviceStatus);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|