361 lines
12 KiB
JavaScript
361 lines
12 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════
|
|
* page-logs.js — 系統事件紀錄
|
|
* 將伺服器紀錄轉成營運可讀的事件摘要,避免前台暴露工程細節。
|
|
* ═══════════════════════════════════════════════════════════ */
|
|
|
|
let autoRefreshEnabled = true;
|
|
let autoScrollEnabled = true;
|
|
let currentFilter = 'all';
|
|
let searchKeyword = '';
|
|
let refreshInterval = null;
|
|
let eventLogText = '';
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
if (typeof showLoading === 'function') showLoading('正在載入系統事件...', '請稍候');
|
|
fetchLogs();
|
|
startAutoRefresh();
|
|
});
|
|
|
|
function updateLastUpdateTime() {
|
|
const now = new Date();
|
|
document.getElementById('last-update').textContent = `最後更新: ${now.toLocaleTimeString('zh-TW')}`;
|
|
}
|
|
|
|
function fetchLogs() {
|
|
fetch('/api/logs')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
eventLogText = normalizeLogText(data.logs);
|
|
updateStats(eventLogText);
|
|
displayLogs();
|
|
updateLastUpdateTime();
|
|
document.getElementById('connection-status').textContent = '資料連線正常';
|
|
if (typeof hideLoading === 'function') hideLoading();
|
|
})
|
|
.catch(error => {
|
|
console.error('載入事件紀錄失敗:', error);
|
|
document.getElementById('connection-status').textContent = '資料連線異常';
|
|
if (typeof showToast === 'function') showToast('載入事件紀錄失敗', 'error');
|
|
if (typeof hideLoading === 'function') hideLoading();
|
|
});
|
|
}
|
|
|
|
function normalizeLogText(value) {
|
|
if (Array.isArray(value)) return value.join('\n');
|
|
return String(value || '');
|
|
}
|
|
|
|
function displayLogs() {
|
|
const container = document.getElementById('log-container');
|
|
const rows = getVisibleEvents();
|
|
|
|
if (!eventLogText.trim()) {
|
|
container.innerHTML = '<div class="log-empty"><i class="fas fa-clipboard-list"></i><p>暫無事件紀錄</p></div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = rows.length
|
|
? rows.map(renderEventRow).join('')
|
|
: '<div class="log-empty"><i class="fas fa-search"></i><p>沒有符合的事件</p></div>';
|
|
|
|
if (autoScrollEnabled) container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function getVisibleEvents() {
|
|
return eventLogText
|
|
.split('\n')
|
|
.map(toEventSummary)
|
|
.filter(Boolean)
|
|
.filter(eventItem => currentFilter === 'all' || eventItem.level === currentFilter)
|
|
.filter(eventItem => {
|
|
if (!searchKeyword) return true;
|
|
return eventItem.searchText.includes(searchKeyword.toLowerCase());
|
|
});
|
|
}
|
|
|
|
function toEventSummary(line) {
|
|
const source = String(line || '').trim();
|
|
if (!source) return null;
|
|
|
|
const level = detectEventLevel(source);
|
|
const eventInfo = classifyEvent(source, level);
|
|
const timestamp = extractTimestamp(source);
|
|
|
|
return {
|
|
level,
|
|
timestamp,
|
|
title: eventInfo.title,
|
|
action: eventInfo.action,
|
|
searchText: `${timestamp} ${eventInfo.title} ${eventInfo.action} ${source}`.toLowerCase(),
|
|
};
|
|
}
|
|
|
|
function classifyEvent(source, level) {
|
|
const lower = source.toLowerCase();
|
|
|
|
if (lower.includes('runnable') && lower.includes('browser')) {
|
|
return {
|
|
title: '雲端資料授權流程需確認',
|
|
action: '背景排程需使用已完成授權的憑證,請確認自動匯入授權檔是否已掛載並可續期。',
|
|
};
|
|
}
|
|
|
|
if (lower.includes('google') || lower.includes('drive') || lower.includes('授權') || lower.includes('credential') || lower.includes('token')) {
|
|
return {
|
|
title: '雲端資料連線需確認',
|
|
action: '請確認 Google Drive 授權、來源資料夾與當日業績檔是否可由排程服務讀取。',
|
|
};
|
|
}
|
|
|
|
if (lower.includes('database') || lower.includes('資料庫') || lower.includes('postgres') || lower.includes('sqlalchemy') || lower.includes('psycopg')) {
|
|
return {
|
|
title: '資料服務連線需確認',
|
|
action: '請先確認資料服務健康狀態,再重試匯入、分析或商品比價流程。',
|
|
};
|
|
}
|
|
|
|
if (lower.includes('import') || lower.includes('匯入') || lower.includes('excel') || lower.includes('csv') || lower.includes('業績')) {
|
|
return {
|
|
title: '業績資料匯入事件',
|
|
action: '請確認來源檔案、欄位內容與最近一次匯入結果,避免今日分析使用過期資料。',
|
|
};
|
|
}
|
|
|
|
if (lower.includes('crawler') || lower.includes('監控') || lower.includes('pchome') || lower.includes('momo') || lower.includes('price') || lower.includes('商品')) {
|
|
return {
|
|
title: '商品監控資料事件',
|
|
action: '請確認商品連結、價格資訊與候選比對資料是否已更新,必要時重新整理監控來源。',
|
|
};
|
|
}
|
|
|
|
if (lower.includes('scheduler') || lower.includes('排程') || lower.includes('job')) {
|
|
return {
|
|
title: '排程任務事件',
|
|
action: '請確認下一輪排程是否正常執行;若連續失敗,優先檢查資料來源與服務健康。',
|
|
};
|
|
}
|
|
|
|
if (lower.includes('telegram') || lower.includes('通知')) {
|
|
return {
|
|
title: '通知服務事件',
|
|
action: '請確認通知是否成功送達,必要時改用平台內事件紀錄追蹤處理狀態。',
|
|
};
|
|
}
|
|
|
|
if (level === 'error') {
|
|
return {
|
|
title: '系統流程需要處理',
|
|
action: '請先確認最近匯入、排程與服務健康狀態,必要時安排人工補救。',
|
|
};
|
|
}
|
|
|
|
if (level === 'warning') {
|
|
return {
|
|
title: '系統提醒需追蹤',
|
|
action: '請觀察下一輪是否恢復;若重複出現,加入今日營運處理清單。',
|
|
};
|
|
}
|
|
|
|
return {
|
|
title: '系統事件已記錄',
|
|
action: '目前不需立即處理,保留作為營運流程追蹤依據。',
|
|
};
|
|
}
|
|
|
|
function detectEventLevel(line) {
|
|
const upper = line.toUpperCase();
|
|
const lower = line.toLowerCase();
|
|
if (
|
|
upper.includes('ERROR') ||
|
|
upper.includes('CRITICAL') ||
|
|
lower.includes('失敗') ||
|
|
lower.includes('異常') ||
|
|
lower.includes('exception') ||
|
|
lower.includes('traceback')
|
|
) {
|
|
return 'error';
|
|
}
|
|
if (
|
|
upper.includes('WARNING') ||
|
|
upper.includes('WARN') ||
|
|
lower.includes('注意') ||
|
|
lower.includes('待確認') ||
|
|
lower.includes('retry')
|
|
) {
|
|
return 'warning';
|
|
}
|
|
return 'info';
|
|
}
|
|
|
|
function extractTimestamp(line) {
|
|
const match = String(line || '').match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})(?:,\d+)?/);
|
|
return match ? match[1] : '最新事件';
|
|
}
|
|
|
|
function renderEventRow(eventItem) {
|
|
return `
|
|
<div class="log-line ${eventItem.level}">
|
|
<span class="log-timestamp">${highlightText(eventItem.timestamp)}</span>
|
|
<strong class="log-event-title">${highlightText(eventItem.title)}</strong>
|
|
<span class="log-event-action">${highlightText(eventItem.action)}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function highlightText(value) {
|
|
const safe = escapeLogText(value);
|
|
if (!searchKeyword) return safe;
|
|
const safeKeyword = escapeLogText(searchKeyword);
|
|
const re = new RegExp(`(${escapeRegExp(safeKeyword)})`, 'gi');
|
|
return safe.replace(re, '<span class="highlight">$1</span>');
|
|
}
|
|
|
|
function escapeLogText(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function updateStats(logs) {
|
|
const events = normalizeLogText(logs)
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(Boolean);
|
|
document.getElementById('total-lines').textContent = events.length;
|
|
document.getElementById('error-count').textContent = events.filter(line => detectEventLevel(line) === 'error').length;
|
|
document.getElementById('warning-count').textContent = events.filter(line => detectEventLevel(line) === 'warning').length;
|
|
document.getElementById('info-count').textContent = events.filter(line => detectEventLevel(line) === 'info').length;
|
|
}
|
|
|
|
function refreshLogs() {
|
|
const btn = document.querySelector('.btn-control--refresh');
|
|
if (btn) {
|
|
btn.classList.add('spinning');
|
|
btn.disabled = true;
|
|
}
|
|
fetchLogs();
|
|
setTimeout(() => {
|
|
if (btn) {
|
|
btn.classList.remove('spinning');
|
|
btn.disabled = false;
|
|
}
|
|
if (typeof showToast === 'function') showToast('事件紀錄已更新', 'success');
|
|
}, 600);
|
|
}
|
|
|
|
function toggleAutoRefresh() {
|
|
autoRefreshEnabled = !autoRefreshEnabled;
|
|
const btn = document.getElementById('pause-btn');
|
|
if (autoRefreshEnabled) {
|
|
btn.className = 'btn-control btn-control--pause';
|
|
btn.innerHTML = '<i class="fas fa-pause"></i><span>暫停自動刷新</span>';
|
|
startAutoRefresh();
|
|
if (typeof showToast === 'function') showToast('已啟用自動刷新', 'success');
|
|
} else {
|
|
btn.className = 'btn-control btn-control--resume';
|
|
btn.innerHTML = '<i class="fas fa-play"></i><span>繼續自動刷新</span>';
|
|
stopAutoRefresh();
|
|
if (typeof showToast === 'function') showToast('已暫停自動刷新', 'info');
|
|
}
|
|
}
|
|
|
|
function startAutoRefresh() {
|
|
if (refreshInterval) clearInterval(refreshInterval);
|
|
refreshInterval = setInterval(fetchLogs, 5000);
|
|
}
|
|
|
|
function stopAutoRefresh() {
|
|
if (refreshInterval) {
|
|
clearInterval(refreshInterval);
|
|
refreshInterval = null;
|
|
}
|
|
}
|
|
|
|
function clearLogs() {
|
|
if (!confirm('確定要清除畫面上的事件紀錄嗎?(不會刪除正式紀錄)')) return;
|
|
eventLogText = '';
|
|
updateStats('');
|
|
displayLogs();
|
|
if (typeof showToast === 'function') showToast('已清除畫面事件', 'success');
|
|
}
|
|
|
|
function downloadLogs() {
|
|
const summary = buildEventSummaryText();
|
|
if (!summary) {
|
|
if (typeof showToast === 'function') showToast('沒有可下載的事件摘要', 'error');
|
|
return;
|
|
}
|
|
const blob = new Blob([summary], { type: 'text/plain;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
|
a.download = `momo_system_events_${ts}.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
if (typeof showToast === 'function') showToast('事件摘要已下載', 'success');
|
|
}
|
|
|
|
function buildEventSummaryText() {
|
|
const rows = eventLogText
|
|
.split('\n')
|
|
.map(toEventSummary)
|
|
.filter(Boolean);
|
|
if (!rows.length) return '';
|
|
return rows.map(eventItem => {
|
|
const label = { error: '需處理', warning: '需追蹤', info: '一般' }[eventItem.level] || '事件';
|
|
return `[${label}] ${eventItem.timestamp} ${eventItem.title} - ${eventItem.action}`;
|
|
}).join('\n');
|
|
}
|
|
|
|
function filterByLevel(level) {
|
|
currentFilter = level;
|
|
document.querySelectorAll('.btn-filter').forEach(button => {
|
|
button.classList.toggle('is-active', button.dataset.level === level);
|
|
});
|
|
displayLogs();
|
|
}
|
|
|
|
function searchLogs() {
|
|
const input = document.getElementById('search-input');
|
|
const box = document.getElementById('search-box');
|
|
searchKeyword = input.value.trim();
|
|
box.classList.toggle('has-text', !!searchKeyword);
|
|
displayLogs();
|
|
}
|
|
|
|
function clearSearch() {
|
|
const input = document.getElementById('search-input');
|
|
input.value = '';
|
|
searchKeyword = '';
|
|
document.getElementById('search-box').classList.remove('has-text');
|
|
displayLogs();
|
|
input.focus();
|
|
}
|
|
|
|
function changeFontSize(size) {
|
|
const container = document.getElementById('log-container');
|
|
container.classList.remove('font-small', 'font-medium', 'font-large');
|
|
container.classList.add(`font-${size}`);
|
|
document.querySelectorAll('.btn-font-size').forEach(button => {
|
|
button.classList.toggle('is-active', button.dataset.size === size);
|
|
});
|
|
}
|
|
|
|
function toggleAutoScroll() {
|
|
autoScrollEnabled = document.getElementById('auto-scroll-toggle').checked;
|
|
if (autoScrollEnabled) {
|
|
const container = document.getElementById('log-container');
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}
|