// Filename: frontend/js/pages/logs/index.js import { apiFetchJson } from '../../services/api.js'; import LogList from './logList.js'; import CustomSelectV2 from '../../components/customSelectV2.js'; import { debounce } from '../../utils/utils.js'; import FilterPopover from '../../components/filterPopover.js'; import { STATIC_ERROR_MAP, STATUS_CODE_MAP } from './logList.js'; import SystemLogTerminal from './systemLog.js'; import { initBatchActions } from './batchActions.js'; import flatpickr from '../../vendor/flatpickr.js'; import LogSettingsModal from './logSettingsModal.js'; const dataStore = { groups: new Map(), keys: new Map(), }; class LogsPage { constructor() { this.state = { logs: [], pagination: { page: 1, pages: 1, total: 0, page_size: 20 }, isLoading: true, filters: { page: 1, page_size: 20, q: '', key_ids: new Set(), group_ids: new Set(), error_types: new Set(), status_codes: new Set(), start_date: null, end_date: null, }, selectedLogIds: new Set(), currentView: 'error', }; this.elements = { tabsContainer: document.querySelector('[data-sliding-tabs-container]'), contentContainer: document.getElementById('log-content-container'), errorFilters: document.getElementById('error-logs-filters'), systemControls: document.getElementById('system-logs-controls'), errorTemplate: document.getElementById('error-logs-template'), systemTemplate: document.getElementById('system-logs-template'), settingsBtn: document.querySelector('button[aria-label="日志设置"]'), }; this.initialized = !!this.elements.contentContainer; if (this.initialized) { this.logList = null; this.systemLogTerminal = null; this.debouncedLoadAndRender = debounce(() => this.loadAndRenderLogs(), 300); this.fp = null; this.themeObserver = null; this.settingsModal = null; this.currentSettings = {}; } } async init() { if (!this.initialized) return; this._initPermanentEventListeners(); await this.loadCurrentSettings(); this._initSettingsModal(); await this.loadGroupsOnce(); this.state.currentView = null; this.switchToView('error'); } _initSettingsModal() { if (!this.elements.settingsBtn) return; this.settingsModal = new LogSettingsModal({ onSave: this.handleSaveSettings.bind(this) }); this.elements.settingsBtn.addEventListener('click', () => { const settingsForModal = { log_level: this.currentSettings.log_level, auto_cleanup: { enabled: this.currentSettings.log_auto_cleanup_enabled, retention_days: this.currentSettings.log_auto_cleanup_retention_days, exec_time: this.currentSettings.log_auto_cleanup_time, interval: 'daily', } }; this.settingsModal.open(settingsForModal); }); } async loadCurrentSettings() { try { const { success, data } = await apiFetchJson('/admin/settings'); if (success) { this.currentSettings = data; } else { console.error('Failed to load settings from server.'); this.currentSettings = { log_auto_cleanup_time: '04:05' }; } } catch (error) { console.error('Failed to load log settings:', error); this.currentSettings = { log_auto_cleanup_time: '04:05' }; } } async handleSaveSettings(settingsData) { const partialPayload = { "log_level": settingsData.log_level, "log_auto_cleanup_enabled": settingsData.auto_cleanup.enabled, "log_auto_cleanup_time": settingsData.auto_cleanup.exec_time, }; if (settingsData.auto_cleanup.enabled) { let retentionDays = settingsData.auto_cleanup.retention_days; if (retentionDays === null || retentionDays <= 0) { retentionDays = 30; } partialPayload.log_auto_cleanup_retention_days = retentionDays; } console.log('Sending PARTIAL settings update to /admin/settings:', partialPayload); try { const { success, message } = await apiFetchJson('/admin/settings', { method: 'PUT', body: JSON.stringify(partialPayload) }); if (!success) { throw new Error(message || 'Failed to save settings'); } Object.assign(this.currentSettings, partialPayload); } catch (error) { console.error('Error saving log settings:', error); throw error; } } _initPermanentEventListeners() { this.elements.tabsContainer.addEventListener('click', (event) => { const tabItem = event.target.closest('[data-tab-target]'); if (!tabItem) return; event.preventDefault(); const viewName = tabItem.dataset.tabTarget; if (viewName) { this.switchToView(viewName); } }); } switchToView(viewName) { if (this.state.currentView === viewName && this.elements.contentContainer.innerHTML !== '') return; if (this.systemLogTerminal) { this.systemLogTerminal.disconnect(); this.systemLogTerminal = null; } if (this.fp) { this.fp.destroy(); this.fp = null; } if (this.themeObserver) { this.themeObserver.disconnect(); this.themeObserver = null; } this.state.currentView = viewName; this.elements.contentContainer.innerHTML = ''; const isErrorView = viewName === 'error'; this.elements.errorFilters.style.display = isErrorView ? 'flex' : 'none'; this.elements.systemControls.style.display = isErrorView ? 'none' : 'flex'; if (isErrorView) { const template = this.elements.errorTemplate.content.cloneNode(true); this.elements.contentContainer.appendChild(template); requestAnimationFrame(() => { this._initErrorLogView(); }); } else if (viewName === 'system') { const template = this.elements.systemTemplate.content.cloneNode(true); this.elements.contentContainer.appendChild(template); requestAnimationFrame(() => { this._initSystemLogView(); }); } } _initErrorLogView() { this.elements.tableBody = document.getElementById('logs-table-body'); this.elements.selectedCount = document.querySelector('.flex-1.text-sm span.font-semibold:nth-child(1)'); this.elements.totalCount = document.querySelector('.flex-1.text-sm span:last-child'); this.elements.pageSizeSelect = document.querySelector('[data-component="custom-select-v2"] select'); this.elements.pageInfo = document.querySelector('.flex.w-\\[100px\\]'); this.elements.paginationBtns = document.querySelectorAll('[data-pagination-controls] button'); this.elements.selectAllCheckbox = document.querySelector('thead .table-head-cell input[type="checkbox"]'); this.elements.searchInput = document.getElementById('log-search-input'); this.elements.errorTypeFilterBtn = document.getElementById('filter-error-type-btn'); this.elements.errorCodeFilterBtn = document.getElementById('filter-error-code-btn'); this.elements.dateRangeFilterBtn = document.getElementById('filter-date-range-btn'); this.logList = new LogList(this.elements.tableBody, dataStore); const selectContainer = document.querySelector('[data-component="custom-select-v2"]'); if (selectContainer) { new CustomSelectV2(selectContainer); } this.initFilterPopovers(); this.initDateRangePicker(); this.initEventListeners(); this._observeThemeChanges(); initBatchActions(this); this.loadAndRenderLogs(); } _observeThemeChanges() { const applyTheme = () => { if (!this.fp || !this.fp.calendarContainer) return; if (document.documentElement.classList.contains('dark')) { this.fp.calendarContainer.classList.add('dark'); } else { this.fp.calendarContainer.classList.remove('dark'); } }; this.themeObserver = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'attributes' && mutation.attributeName === 'class') { applyTheme(); } } }); this.themeObserver.observe(document.documentElement, { attributes: true }); applyTheme(); } _initSystemLogView() { this.systemLogTerminal = new SystemLogTerminal( this.elements.contentContainer, this.elements.systemControls ); Swal.fire({ width: '20rem', backdrop: `rgba(0,0,0,0.5)`, heightAuto: false, customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` }, title: '系统终端日志', text: '您即将连接到实时系统日志流窗口。', showCancelButton: true, confirmButtonText: '确认', cancelButtonText: '取消', reverseButtons: false, confirmButtonColor: 'rgba(31, 102, 255, 0.8)', cancelButtonColor: '#6b7280', focusConfirm: false, focusCancel: false, target: '#main-content-wrapper', }).then((result) => { if (result.isConfirmed) { this.systemLogTerminal.connect(); } else { const errorLogTab = Array.from(this.elements.tabsContainer.querySelectorAll('[data-tab-target="error"]'))[0]; if (errorLogTab) errorLogTab.click(); } }); } initFilterPopovers() { const errorTypeOptions = [ ...Object.values(STATUS_CODE_MAP).map(v => ({ value: v.type, label: v.type })), ...Object.values(STATIC_ERROR_MAP).map(v => ({ value: v.type, label: v.type })) ]; const uniqueErrorTypeOptions = Array.from(new Map(errorTypeOptions.map(item => [item.value, item])).values()); if (this.elements.errorTypeFilterBtn) { new FilterPopover(this.elements.errorTypeFilterBtn, uniqueErrorTypeOptions, '筛选错误类型'); } const statusCodeOptions = Object.keys(STATUS_CODE_MAP).map(code => ({ value: code, label: code })); if (this.elements.errorCodeFilterBtn) { new FilterPopover(this.elements.errorCodeFilterBtn, statusCodeOptions, '筛选状态码'); } } initDateRangePicker() { if (!this.elements.dateRangeFilterBtn) return; const buttonTextSpan = this.elements.dateRangeFilterBtn.querySelector('span'); const originalText = buttonTextSpan.textContent; this.fp = flatpickr(this.elements.dateRangeFilterBtn, { mode: 'range', dateFormat: 'Y-m-d', onClose: (selectedDates) => { if (selectedDates.length === 2) { const [start, end] = selectedDates; end.setHours(23, 59, 59, 999); this.state.filters.start_date = start.toISOString(); this.state.filters.end_date = end.toISOString(); const startDateStr = start.toISOString().split('T')[0]; const endDateStr = end.toISOString().split('T')[0]; buttonTextSpan.textContent = `${startDateStr} ~ ${endDateStr}`; this.elements.dateRangeFilterBtn.classList.add('!border-primary', '!text-primary'); this.state.filters.page = 1; this.loadAndRenderLogs(); } }, onReady: (selectedDates, dateStr, instance) => { if (document.documentElement.classList.contains('dark')) { instance.calendarContainer.classList.add('dark'); } const clearButton = document.createElement('button'); clearButton.textContent = '清除'; clearButton.className = 'button flatpickr-button flatpickr-clear-button'; clearButton.addEventListener('click', (e) => { e.preventDefault(); instance.clear(); this.state.filters.start_date = null; this.state.filters.end_date = null; buttonTextSpan.textContent = originalText; this.elements.dateRangeFilterBtn.classList.remove('!border-primary', '!text-primary'); this.state.filters.page = 1; this.loadAndRenderLogs(); instance.close(); }); instance.calendarContainer.appendChild(clearButton); const nativeMonthSelect = instance.monthsDropdownContainer; if (!nativeMonthSelect) return; const monthYearContainer = nativeMonthSelect.parentElement; const wrapper = document.createElement('div'); wrapper.className = 'custom-select-v2-container relative inline-block text-left'; wrapper.innerHTML = ` `; const template = document.createElement('template'); template.className = 'custom-select-panel-template'; template.innerHTML = ` `; nativeMonthSelect.classList.add('hidden'); wrapper.appendChild(nativeMonthSelect); wrapper.appendChild(template); monthYearContainer.prepend(wrapper); const customSelect = new CustomSelectV2(wrapper); instance.customMonthSelect = customSelect; }, onMonthChange: (selectedDates, dateStr, instance) => { if (instance.customMonthSelect) { instance.customMonthSelect.updateTriggerText(); } }, }); } initEventListeners() { if (this.elements.pageSizeSelect) { this.elements.pageSizeSelect.addEventListener('change', (e) => this.changePageSize(parseInt(e.target.value, 10))); } if (this.elements.paginationBtns.length >= 4) { this.elements.paginationBtns[0].addEventListener('click', () => this.goToPage(1)); this.elements.paginationBtns[1].addEventListener('click', () => this.goToPage(this.state.pagination.page - 1)); this.elements.paginationBtns[2].addEventListener('click', () => this.goToPage(this.state.pagination.page + 1)); this.elements.paginationBtns[3].addEventListener('click', () => this.goToPage(this.state.pagination.pages)); } if (this.elements.selectAllCheckbox) { this.elements.selectAllCheckbox.addEventListener('change', (event) => this.handleSelectAllChange(event)); } if (this.elements.tableBody) { this.elements.tableBody.addEventListener('click', (event) => { const checkbox = event.target.closest('input[type="checkbox"]'); const actionButton = event.target.closest('button[data-action]'); if (checkbox) { this.handleSelectionChange(checkbox); } else if (actionButton) { this._handleLogRowAction(actionButton); } }); } if (this.elements.searchInput) { this.elements.searchInput.addEventListener('input', (event) => this.handleSearchInput(event)); } if (this.elements.errorTypeFilterBtn) { this.elements.errorTypeFilterBtn.addEventListener('filter-change', (e) => this.handleFilterChange(e)); } if (this.elements.errorCodeFilterBtn) { this.elements.errorCodeFilterBtn.addEventListener('filter-change', (e) => this.handleFilterChange(e)); } } handleFilterChange(event) { const { filterKey, selected } = event.detail; if (filterKey === 'filter-error-type-btn') { this.state.filters.error_types = selected; } else if (filterKey === 'filter-error-code-btn') { this.state.filters.status_codes = selected; } this.state.filters.page = 1; this.loadAndRenderLogs(); } handleSearchInput(event) { const searchTerm = event.target.value.trim().toLowerCase(); this.state.filters.page = 1; this.state.filters.q = ''; this.state.filters.key_ids = new Set(); this.state.filters.group_ids = new Set(); if (searchTerm === '') { this.debouncedLoadAndRender(); return; } const matchedGroupIds = new Set(); dataStore.groups.forEach(group => { if (group.display_name.toLowerCase().includes(searchTerm)) { matchedGroupIds.add(group.id); } }); const matchedKeyIds = new Set(); dataStore.keys.forEach(key => { if (key.APIKey && key.APIKey.toLowerCase().includes(searchTerm)) { matchedKeyIds.add(key.ID); } }); if (matchedGroupIds.size > 0) this.state.filters.group_ids = matchedGroupIds; if (matchedKeyIds.size > 0) this.state.filters.key_ids = matchedKeyIds; if (matchedGroupIds.size === 0 && matchedKeyIds.size === 0) { this.state.filters.q = searchTerm; } this.debouncedLoadAndRender(); } handleSelectionChange(checkbox) { const row = checkbox.closest('.table-row'); if (!row) return; const logId = parseInt(row.dataset.logId, 10); if (isNaN(logId)) return; if (checkbox.checked) { this.state.selectedLogIds.add(logId); } else { this.state.selectedLogIds.delete(logId); } this.syncSelectionUI(); } handleSelectAllChange(event) { const isChecked = event.target.checked; this.state.logs.forEach(log => { if (isChecked) { this.state.selectedLogIds.add(log.ID); } else { this.state.selectedLogIds.delete(log.ID); } }); this.syncRowCheckboxes(); this.syncSelectionUI(); } syncRowCheckboxes() { const isAllChecked = this.elements.selectAllCheckbox.checked; this.elements.tableBody.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.checked = isAllChecked; }); } syncSelectionUI() { if (!this.elements.selectAllCheckbox || !this.elements.selectedCount) return; const selectedCount = this.state.selectedLogIds.size; const visibleLogsCount = this.state.logs.length; if (selectedCount === 0) { this.elements.selectAllCheckbox.checked = false; this.elements.selectAllCheckbox.indeterminate = false; } else if (selectedCount < visibleLogsCount) { this.elements.selectAllCheckbox.checked = false; this.elements.selectAllCheckbox.indeterminate = true; } else if (selectedCount === visibleLogsCount && visibleLogsCount > 0) { this.elements.selectAllCheckbox.checked = true; this.elements.selectAllCheckbox.indeterminate = false; } this.elements.selectedCount.textContent = selectedCount; const hasSelection = selectedCount > 0; const deleteSelectedBtn = document.getElementById('delete-selected-logs-btn'); if (deleteSelectedBtn) { deleteSelectedBtn.disabled = !hasSelection; } } async _handleLogRowAction(button) { const action = button.dataset.action; const row = button.closest('.table-row'); const isDarkMode = document.documentElement.classList.contains('dark'); if (!row) return; const logId = parseInt(row.dataset.logId, 10); const log = this.state.logs.find(l => l.ID === logId); if (!log) { Swal.fire({ toast: true, position: 'top-end', icon: 'error', title: '找不到日志数据', showConfirmButton: false, timer: 2000 }); return; } switch (action) { case 'view-log-details': { const detailsHtml = `

状态码

${log.StatusCode || 'N/A'}

状态

${log.Status || 'N/A'}

模型

${log.ModelName || 'N/A'}

错误消息

${log.ErrorMessage ? log.ErrorMessage.replace(/\n/g, '
') : '无错误消息。'}
`; Swal.fire({ target: '#main-content-wrapper', width: '32rem', backdrop: `rgba(0,0,0,0.5)`, heightAuto: false, customClass: { popup: `swal2-custom-style rounded-xl ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}`, title: 'text-lg font-bold', htmlContainer: 'm-0 text-left', }, title: '日志详情', html: detailsHtml, showCloseButton: false, showConfirmButton: false, }); break; } case 'copy-api-key': { const key = dataStore.keys.get(log.KeyID); if (key && key.APIKey) { navigator.clipboard.writeText(key.APIKey).then(() => { Swal.fire({ toast: true, position: 'top-end', customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` }, icon: 'success', title: 'API Key 已复制', showConfirmButton: false, timer: 1500 }); }).catch(err => { Swal.fire({ toast: true, position: 'top-end', icon: 'error', title: '复制失败', text: err.message, showConfirmButton: false, timer: 2000 }); }); } else { Swal.fire({ toast: true, position: 'top-end', icon: 'warning', title: '未找到完整的API Key', showConfirmButton: false, timer: 2000 }); return; } if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(key.APIKey).then(() => { Swal.fire({ toast: true, position: 'top-end', icon: 'success', title: 'API Key 已复制', showConfirmButton: false, timer: 1500 }); }).catch(err => { Swal.fire({ toast: true, position: 'top-end', icon: 'error', title: '复制失败', text: err.message, showConfirmButton: false, timer: 2000 }); }); } else { // 如果不可用,则提供明确的错误提示 Swal.fire({ icon: 'error', title: '复制失败', text: '此功能需要安全连接 (HTTPS) 或在 localhost 环境下使用。', target: '#main-content-wrapper', customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` }, }); } break; } case 'delete-log': { Swal.fire({ width: '20rem', backdrop: `rgba(0,0,0,0.5)`, heightAuto: false, customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` }, title: '确认删除', text: `您确定要删除这条日志吗?此操作不可撤销。`, showCancelButton: true, confirmButtonText: '确认删除', cancelButtonText: '取消', reverseButtons: false, confirmButtonColor: '#ef4444', cancelButtonColor: '#6b7280', focusCancel: true, target: '#main-content-wrapper', }).then(async (result) => { if (result.isConfirmed) { try { const url = `/admin/logs?ids=${logId}`; const { success, message } = await apiFetchJson(url, { method: 'DELETE' }); if (success) { Swal.fire({ toast: true, position: 'top-end', icon: 'success', title: '删除成功', showConfirmButton: false, timer: 2000, timerProgressBar: true }); this.loadAndRenderLogs(); } else { throw new Error(message || '删除失败,请稍后重试。'); } } catch (error) { Swal.fire({ icon: 'error', title: '操作失败', text: error.message, target: '#main-content-wrapper' }); } } }); break; } } } changePageSize(newSize) { this.state.filters.page_size = newSize; this.state.filters.page = 1; this.loadAndRenderLogs(); } goToPage(page) { if (page < 1 || page > this.state.pagination.pages || this.state.isLoading) return; this.state.filters.page = page; this.loadAndRenderLogs(); } updatePaginationUI() { const { page, pages, total } = this.state.pagination; if (this.elements.pageInfo) { this.elements.pageInfo.textContent = `第 ${page} / ${pages} 页`; } if (this.elements.totalCount) { this.elements.totalCount.textContent = total; } if (this.elements.paginationBtns.length >= 4) { const isFirstPage = page === 1; const isLastPage = page === pages || pages === 0; this.elements.paginationBtns[0].disabled = isFirstPage; this.elements.paginationBtns[1].disabled = isFirstPage; this.elements.paginationBtns[2].disabled = isLastPage; this.elements.paginationBtns[3].disabled = isLastPage; } } async loadGroupsOnce() { if (dataStore.groups.size > 0) return; try { const { success, data } = await apiFetchJson("/admin/keygroups"); if (success && Array.isArray(data)) { data.forEach(group => dataStore.groups.set(group.id, group)); } } catch (error) { console.error("Failed to load key groups:", error); } } async loadAndRenderLogs() { this.state.isLoading = true; this.state.selectedLogIds.clear(); this.logList.renderLoading(); this.updatePaginationUI(); this.syncSelectionUI(); try { const finalParams = {}; const { filters } = this.state; Object.keys(filters).forEach(key => { if (!(filters[key] instanceof Set)) { finalParams[key] = filters[key]; } }); // --- [MODIFIED] START: Combine all error-related filters into a single parameter for OR logic --- const allErrorCodes = new Set(); const allStatusCodes = new Set(filters.status_codes); if (filters.error_types.size > 0) { filters.error_types.forEach(type => { // Find matching static error codes (e.g., 'API_KEY_INVALID') for (const [code, obj] of Object.entries(STATIC_ERROR_MAP)) { if (obj.type === type) { allErrorCodes.add(code); } } // Find matching status codes (e.g., 400, 401) for (const [code, obj] of Object.entries(STATUS_CODE_MAP)) { if (obj.type === type) { allStatusCodes.add(code); } } }); } // Pass the combined codes to the backend. The backend will handle the OR logic. if (allErrorCodes.size > 0) finalParams.error_codes = [...allErrorCodes].join(','); if (allStatusCodes.size > 0) finalParams.status_codes = [...allStatusCodes].join(','); // --- [MODIFIED] END --- if (filters.key_ids.size > 0) finalParams.key_ids = [...filters.key_ids].join(','); if (filters.group_ids.size > 0) finalParams.group_ids = [...filters.group_ids].join(','); Object.keys(finalParams).forEach(key => { if (finalParams[key] === '' || finalParams[key] === null || finalParams[key] === undefined) { delete finalParams[key]; } }); const query = new URLSearchParams(finalParams); const { success, data } = await apiFetchJson( `/admin/logs?${query.toString()}`, { cache: 'no-cache', noCache: true } ); if (success && typeof data === 'object' && data.items) { const { items, total, page, page_size } = data; this.state.logs = items; const totalPages = Math.ceil(total / page_size); this.state.pagination = { page, page_size, total, pages: totalPages > 0 ? totalPages : 1 }; await this.enrichLogsWithKeyNames(items); this.logList.render(this.state.logs, this.state.pagination, this.state.selectedLogIds); } else { this.state.logs = []; this.state.pagination = { ...this.state.pagination, total: 0, pages: 1, page: 1 }; this.logList.render([], this.state.pagination); } } catch (error) { console.error("Failed to load logs:", error); this.state.logs = []; this.state.pagination = { ...this.state.pagination, total: 0, pages: 1, page: 1 }; this.logList.render([], this.state.pagination); } finally { this.state.isLoading = false; this.updatePaginationUI(); this.syncSelectionUI(); } } async enrichLogsWithKeyNames(logs) { const missingKeyIds = [...new Set( logs.filter(log => log.KeyID && !dataStore.keys.has(log.KeyID)).map(log => log.KeyID) )]; if (missingKeyIds.length === 0) return; try { const idsQuery = missingKeyIds.join(','); const { success, data } = await apiFetchJson(`/admin/apikeys?ids=${idsQuery}`); if (success && Array.isArray(data)) { data.forEach(key => dataStore.keys.set(key.ID, key)); } } catch (error) { console.error(`Failed to fetch key details:`, error); } } } export default function() { const page = new LogsPage(); page.init(); }